commit d2375abf887f6c0f911cafd7ff773e477f9758f6 Author: wehub-resource-sync Date: Mon Jul 13 12:20:32 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..0156438 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,73 @@ +# AnythingLLM Development Container Setup + +Welcome to the AnythingLLM development container configuration, designed to create a seamless and feature-rich development environment for this project. + +

PLEASE READ THIS

+ +## Prerequisites + +- [Docker](https://www.docker.com/get-started) +- [Visual Studio Code](https://code.visualstudio.com/) +- [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) VS Code extension + +## Features + +- **Base Image**: Built on `mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm`, thus Node.JS LTS v18. +- **Additional Tools**: Includes `hadolint`, and essential apt-packages such as `curl`, `gnupg`, and more. +- **Ports**: Configured to auto-forward ports `3000` (Frontend) and `3001` (Backend). +- **Environment Variables**: Sets `NODE_ENV` to `development` and `ESLINT_USE_FLAT_CONFIG` to `true`. +- **VS Code Extensions**: A suite of extensions such as `Prettier`, `Docker`, `ESLint`, and more are automatically installed. Please revise if you do not agree with any of these extensions. AI-powered extensions and time trackers are (for now) not included to avoid any privacy concerns, but you can install them later in your own environment. + +## Getting Started + +1. Using GitHub Codespaces. Just select to create a new workspace, and the devcontainer will be created for you. + +2. Using your Local VSCode (Release or Insiders). We suggest you first make a fork of the repo and then clone it to your local machine using VSCode tools. Then open the project folder in VSCode, which will prompt you to open the project in a devcontainer. Select yes, and the devcontainer will be created for you. If this does not happen, you can open the command palette and select "Remote-Containers: Reopen in Container". + +## On Creation: + +When the container is built for the first time, it will automatically run `yarn setup` to ensure everything is in place for the Collector, Server and Frontend. This command is expected to be automatically re-run if there is a content change on next reboot. + +## Work in the Container: + +Once the container is up, be patient. Some extensions may complain because dependencies are still being installed, and in the Extensions tab, some may ask you to "Reload" the project. Don't do that yet. First, wait until all settle down for the first time. We suggest you create a new VSCode profile for this devcontainer, so any configuration and extensions you change, won't affect your default profile. + +Checklist: + +- [ ] The usual message asking you to start the Server and Frontend in different windows are now "hidden" in the building process of the devcontainer. Don't forget to do as suggested. +- [ ] Open a JavaScript file, for example "server/index.js" and check if `eslint` is working. It will complain that `'err' is defined but never used.`. This means it is working. +- [ ] Open a React File, for example, "frontend/src/main.jsx," and check if `eslint` complains about `Fast refresh only works when a file has exports. Move your component(s) to a separate file.`. Again, it means `eslint` is working. Now check at the status bar if the `Prettier` has a double checkmark :heavy_check_mark: (double). It means Prettier is working. You will see a nice extension `Formatting:`:heavy_check_mark: that can be used to disable the `Format on Save` feature temporarily. +- [ ] Check if, on the left pane, you have the NPM Scripts (this may be disabled; look at the "Explorer" tree-dots up-right). There will be scripts inside the `package.json` files. You will basically need to run the `dev:collector`, `dev:server` and the `dev:frontend` in this order. When the frontend finishes starting, a window browser will open **inside** the VSCode. Still, you can open it outside. + +:warning: **Important for all developers** :warning: + +- [ ] When you are using the `NODE_ENV=development` the server will not store the configurations you set for security reasons. Please set the proper config on file `.env.development`. The side-effect if you don't, everytime you restart the server, you will be sent to the "Onboarding" page again. + +**Note when using GitHub Codespaces** + +- [ ] When running the "Server" for the first time, it will automatically configure its port to be publicly accessible by default, as this is required for the front end to reach the server backend. To know more, read the content of the `.env` file on the frontend folder about this, and if any issues occur, make sure to manually set the port "Visibility" of the "Server" is set to "Public" if needed. Again, this is only needed for developing on GitHub Codespaces. + + +**For the Collector:** + +- [x] In the past, the Collector dwelled within the Python domain, but now it has journeyed to the splendid realm of Node.JS. Consequently, the configuration complexities of bygone versions are no longer a concern. + +### Now it is ready to start + +In the status bar you will see three shortcuts names `Collector`, `Server` and `Frontend`. Just click-and-wait on that order (don't forget to set the Server port 3001 to Public if you are using GH Codespaces **_before_** starting the Frontend). + +Now you can enjoy your time developing instead of reconfiguring everything. + +## Debugging with the devcontainers + +### For debugging the collector, server and frontend + +First, make sure the built-in extension (ms-vscode.js-debug) is active (I don't know why it would not be, but just in case). If you want, you can install the nightly version (ms-vscode.js-debug-nightly) + +Then, in the "Run and Debug" tab (Ctrl+shift+D), you can select on the menu: + +- Collector debug. This will start the collector in debug mode and attach the debugger. Works very well. +- Server debug. This will start the server in debug mode and attach the debugger. Works very well. +- Frontend debug. This will start the frontend in debug mode and attach the debugger. I am still struggling with this one. I don't know if VSCode can handle the .jsx files seamlessly as the pure .js on the server. Maybe there is a need for a particular configuration for Vite or React. Anyway, it starts. Another two configurations launch Chrome and Edge, and I think we could add breakpoints on .jsx files somehow. The best scenario would be always to use the embedded browser. WIP. + +Please leave comments on the Issues tab or the [![](https://img.shields.io/discord/1114740394715004990?logo=Discord&logoColor=white&label=Discord&labelColor=%235568ee&color=%2355A2DD&link=https%3A%2F%2Fdiscord.gg%2F6UyHPeGZAC)]("https://discord.gg/6UyHPeGZAC") diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b841797 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,214 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node +{ + "name": "Node.js", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + // "build": { + // "args": { + // "ARG_UID": "1000", + // "ARG_GID": "1000" + // }, + // "dockerfile": "Dockerfile" + // }, + // "containerUser": "anythingllm", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm", + + "forwardPorts": [3001, 3000], + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + // Docker very useful linter + "ghcr.io/dhoeric/features/hadolint:1": { + "version": "latest" + }, + // Terraform support + "ghcr.io/devcontainers/features/terraform:1": {}, + // Just a wrap to install needed packages + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + // Dependencies copied from ../docker/Dockerfile plus some dev stuff + "packages": [ + "build-essential", + "ca-certificates", + "curl", + "ffmpeg", + "fonts-liberation", + "git", + "gnupg", + "htop", + "less", + "libappindicator1", + "libasound2", + "libatk-bridge2.0-0", + "libatk1.0-0", + "libc6", + "libcairo2", + "libcups2", + "libdbus-1-3", + "libexpat1", + "libfontconfig1", + "libgbm1", + "libgcc1", + "libgfortran5", + "libglib2.0-0", + "libgtk-3-0", + "libnspr4", + "libnss3", + "libpango-1.0-0", + "libpangocairo-1.0-0", + "libstdc++6", + "libx11-6", + "libx11-xcb1", + "libxcb1", + "libxcomposite1", + "libxcursor1", + "libxdamage1", + "libxext6", + "libxfixes3", + "libxi6", + "libxrandr2", + "libxrender1", + "libxss1", + "libxtst6", + "locales", + "lsb-release", + "procps", + "tzdata", + "wget", + "xdg-utils" + ] + } + }, + "updateContentCommand": "cd server && yarn && cd ../collector && PUPPETEER_DOWNLOAD_BASE_URL=https://storage.googleapis.com/chrome-for-testing-public yarn && cd ../frontend && yarn && cd .. && yarn setup:envs && yarn prisma:setup && echo \"Please run yarn dev:server, yarn dev:collector, and yarn dev:frontend in separate terminal tabs.\"", + // Use 'postCreateCommand' to run commands after the container is created. + // This configures VITE for github codespaces and installs gh cli + "postCreateCommand": "if [ \"${CODESPACES}\" = \"true\" ]; then echo 'VITE_API_BASE=\"https://$CODESPACE_NAME-3001.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/api\"' > ./frontend/.env && (type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) && sudo mkdir -p -m 755 /etc/apt/keyrings && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update && sudo apt install gh -y; fi", + "portsAttributes": { + "3001": { + "label": "Backend", + "onAutoForward": "notify" + }, + "3000": { + "label": "Frontend", + "onAutoForward": "openPreview" + } + }, + "capAdd": [ + "SYS_ADMIN" // needed for puppeteer using headless chrome in sandbox + ], + "remoteEnv": { + "NODE_ENV": "development", + "ESLINT_USE_FLAT_CONFIG": "true", + "ANYTHING_LLM_RUNTIME": "docker" + }, + // "initializeCommand": "echo Initialize....", + "shutdownAction": "stopContainer", + // Configure tool-specific properties. + "customizations": { + "codespaces": { + "openFiles": [ + "README.md", + ".devcontainer/README.md" + ] + }, + "vscode": { + "openFiles": [ + "README.md", + ".devcontainer/README.md" + ], + "extensions": [ + "bierner.github-markdown-preview", + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "exiasr.hadolint", + "flowtype.flow-for-vscode", + "gamunu.vscode-yarn", + "hashicorp.terraform", + "mariusschulz.yarn-lock-syntax", + "ms-azuretools.vscode-docker", + "streetsidesoftware.code-spell-checker", + "actboy168.tasks", + "tombonnike.vscode-status-bar-format-toggle", + "ms-vscode.js-debug" + ], + "settings": { + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[dockercompose]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[dockerfile]": { + "editor.defaultFormatter": "ms-azuretools.vscode-docker" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[postcss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + }, + "eslint.debug": true, + "eslint.enable": true, + "eslint.experimental.useFlatConfig": true, + "eslint.run": "onSave", + "files.associations": { + ".*ignore": "ignore", + ".editorconfig": "editorconfig", + ".env*": "properties", + ".flowconfig": "ini", + ".prettierrc": "json", + "*.css": "tailwindcss", + "*.md": "markdown", + "*.sh": "shellscript", + "docker-compose.*": "dockercompose", + "Dockerfile*": "dockerfile", + "yarn.lock": "yarnlock" + }, + "javascript.format.enable": false, + "javascript.inlayHints.enumMemberValues.enabled": true, + "javascript.inlayHints.functionLikeReturnTypes.enabled": true, + "javascript.inlayHints.parameterTypes.enabled": true, + "javascript.inlayHints.variableTypes.enabled": true, + "js/ts.implicitProjectConfig.module": "CommonJS", + "json.format.enable": false, + "json.schemaDownload.enable": true, + "npm.autoDetect": "on", + "npm.packageManager": "yarn", + "prettier.useEditorConfig": false, + "tailwindCSS.files.exclude": [ + "**/.git/**", + "**/node_modules/**", + "**/.hg/**", + "**/.svn/**", + "**/dist/**" + ], + "typescript.validate.enable": false, + "workbench.editorAssociations": { + "*.md": "vscode.markdown.preview.editor" + } + } + } + } + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cca33a8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +**/server/utils/agents/aibitat/example/** +**/server/storage/documents/** +**/server/storage/vector-cache/** +**/server/storage/*.db +**/server/storage/lancedb +**/collector/hotdir/** +**/collector/outputs/** +**/node_modules/ +**/dist/ +**/v-env/ +**/__pycache__/ +**/.env +**/.env.* +**/bundleinspector.html +**/tmp/** +**/.log +!docker/.env.example +!frontend/.env.production \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e175db5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +# Non-configurable Prettier behaviors +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# Configurable Prettier behaviors +# (change these if your Prettier config differs) +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..411f850 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: Mintplex-Labs \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/01_bug.yml b/.github/ISSUE_TEMPLATE/01_bug.yml new file mode 100644 index 0000000..9a6c75f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_bug.yml @@ -0,0 +1,60 @@ +name: 🐛 Bug Report +description: File a bug report for AnythingLLM +title: "[BUG]: " +labels: [possible bug] +body: + - type: markdown + attributes: + value: | + Use this template to file a bug report for AnythingLLM. Please be as descriptive as possible to allow everyone to replicate and solve your issue. + - type: dropdown + id: runtime + attributes: + label: How are you running AnythingLLM? + description: AnythingLLM can be run in many environments, pick the one that best represents where you encounter the bug. + options: + - Docker (local) + - Docker (remote machine) + - Local development + - AnythingLLM desktop app + - All versions + - Not listed + default: 0 + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Are there known steps to reproduce? + description: | + Let us know how to reproduce the bug and we may be able to fix it more + quickly. This is not required, but it is helpful. + validations: + required: false + + - type: textarea + id: llm-provider + attributes: + label: LLM Provider & Model (if applicable) + description: What LLM provider and model are you using? (e.g., OpenAI GPT-4, Anthropic, Ollama, etc.) + placeholder: e.g., Ollama / qwen2.5-coder-32b-instruct + validations: + required: false + + - type: textarea + id: embedder-provider + attributes: + label: Embedder Provider & Model (if applicable) + description: What embedding provider and model are you using? (e.g., OpenAI text-embedding-ada-002, Lemonade, etc.) + placeholder: e.g., Lemonade / nomic-embed-text-v1-GGUF + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/02_feature.yml b/.github/ISSUE_TEMPLATE/02_feature.yml new file mode 100644 index 0000000..89238b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_feature.yml @@ -0,0 +1,19 @@ +name: ✨ New Feature suggestion +description: Suggest a new feature for AnythingLLM! +title: "[FEAT]: " +labels: [enhancement, feature request] +body: + - type: markdown + attributes: + value: | + Share a new idea for a feature or improvement. Be sure to search existing + issues first to avoid duplicates. + + - type: textarea + id: description + attributes: + label: What would you like to see? + description: | + Describe the feature and why it would be useful to your use-case as well as others. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/03_documentation.yml b/.github/ISSUE_TEMPLATE/03_documentation.yml new file mode 100644 index 0000000..5580085 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03_documentation.yml @@ -0,0 +1,13 @@ +name: 📚 Documentation improvement +title: "[DOCS]: " +description: Report an issue or problem with the documentation. +labels: [documentation] + +body: + - type: textarea + id: description + attributes: + label: Description + description: Describe the issue with the documentation that is giving you trouble or causing confusion. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/04_integration_request.yml b/.github/ISSUE_TEMPLATE/04_integration_request.yml new file mode 100644 index 0000000..ef4dadb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/04_integration_request.yml @@ -0,0 +1,61 @@ +name: 🔌 New Integration Proposal +description: Propose a new LLM, Embedding, or Vector DB provider. +body: + - type: markdown + attributes: + value: | + Please review our Vetting Criteria in CONTRIBUTING.md before submitting. + Low-effort requests or providers without an established user base or unique technical utility will be closed immediately. + - type: input + id: provider_name + attributes: + label: Provider Name + validations: + required: true + - type: input + id: provider_website + attributes: + label: Official Website URL + placeholder: "https://example.com" + validations: + required: true + - type: input + id: provider_docs + attributes: + label: API Documentation URL + placeholder: "https://docs.example.com" + validations: + required: true + - type: input + id: provider_privacy + attributes: + label: Privacy Policy URL + placeholder: "https://example.com/privacy" + validations: + required: true + - type: input + id: userbase_proof + attributes: + label: Proof of Active User Base & Demand + description: Provide clear evidence of active community traction (e.g., Hugging Face download metrics, GitHub repo stats, or organic community discussions). + validations: + required: true + - type: dropdown + id: models_endpoint + attributes: + label: Native Models Discovery + description: Does your API expose a standard GET `/v1/models` (or native equivalent) endpoint for dynamic model listing? + options: + - "Yes, it fully supports a model enumeration endpoint." + - "No, users must manually type in the model strings." + validations: + required: true + - type: dropdown + id: technical_delta + attributes: + label: Technical Compatibility Layer + options: + - "This provider is NOT OpenAI-compatible and requires custom SDK/orchestration logic." + - "This provider IS OpenAI-compatible (Stop here, use the Generic OpenAI Connector)." + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d5485e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 🧑‍🤝‍🧑 Community Discord + url: https://discord.gg/6UyHPeGZAC + about: Interact with the Mintplex Labs community here by asking for help, discussing and more! diff --git a/.github/workflows/build-and-push-image-semver.yaml b/.github/workflows/build-and-push-image-semver.yaml new file mode 100644 index 0000000..b9305a0 --- /dev/null +++ b/.github/workflows/build-and-push-image-semver.yaml @@ -0,0 +1,114 @@ +name: Publish AnythingLLM Docker image on Release (amd64 & arm64) + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + release: + types: [published] + +jobs: + push_multi_platform_to_registries: + name: Push Docker multi-platform image to multiple registries + runs-on: ubuntu-22.04-arm + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Check if DockerHub build needed + shell: bash + run: | + # Check if the secret for USERNAME is set (don't even check for the password) + if [[ -z "${{ secrets.DOCKER_USERNAME }}" ]]; then + echo "DockerHub build not needed" + echo "enabled=false" >> $GITHUB_OUTPUT + else + echo "DockerHub build needed" + echo "enabled=true" >> $GITHUB_OUTPUT + fi + id: dockerhub + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: v0.22.0 + + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + # Only login to the Docker Hub if the repo is mintplex/anythingllm, to allow for forks to build on GHCR + if: steps.dockerhub.outputs.enabled == 'true' + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: | + ${{ steps.dockerhub.outputs.enabled == 'true' && 'mintplexlabs/anythingllm' || '' }} + ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push multi-platform Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + push: true + sbom: true + provenance: mode=max + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # For Docker scout there are some intermediary reported CVEs which exists outside + # of execution content or are unreachable by an attacker but exist in image. + # We create VEX files for these so they don't show in scout summary. + - name: Collect known and verified CVE exceptions + id: cve-list + run: | + # Collect CVEs from filenames in vex folder + CVE_NAMES="" + for file in ./docker/vex/*.vex.json; do + [ -e "$file" ] || continue + filename=$(basename "$file") + stripped_filename=${filename%.vex.json} + CVE_NAMES+=" $stripped_filename" + done + echo "CVE_EXCEPTIONS=$CVE_NAMES" >> $GITHUB_OUTPUT + shell: bash + + # About VEX attestations https://docs.docker.com/scout/explore/exceptions/ + # Justifications https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md#status-justifications + - name: Add VEX attestations + env: + CVE_EXCEPTIONS: ${{ steps.cve-list.outputs.CVE_EXCEPTIONS }} + run: | + echo $CVE_EXCEPTIONS + curl -sSfL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh -s -- + for cve in $CVE_EXCEPTIONS; do + for tag in "${{ join(fromJSON(steps.meta.outputs.json).tags, ' ') }}"; do + echo "Attaching VEX exception $cve to $tag" + docker scout attestation add \ + --file "./docker/vex/$cve.vex.json" \ + --predicate-type https://openvex.dev/ns/v0.2.0 \ + $tag + done + done + shell: bash diff --git a/.github/workflows/build-and-push-image.yaml b/.github/workflows/build-and-push-image.yaml new file mode 100644 index 0000000..2d8548c --- /dev/null +++ b/.github/workflows/build-and-push-image.yaml @@ -0,0 +1,136 @@ +# This GitHub action is for publishing of the primary image for AnythingLLM +# It will publish a linux/amd64 and linux/arm64 image at the same time +# This file should ONLY BY USED FOR `master` BRANCH. +# TODO: GitHub now has an ubuntu-24.04-arm64 runner, but we still need +# to use QEMU to build the arm64 image because Chromium is not available for Linux arm64 +# so builds will still fail, or fail much more often. Its inconsistent and frustrating. +name: Publish AnythingLLM Primary Docker image (amd64/arm64) + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: ['master'] # master branch only. Do not modify. + paths-ignore: + - '**.md' + - '.gitmodules' + - 'cloud-deployments/**/*' + - 'images/**/*' + - '.vscode/**/*' + - '**/.env.example' + - '.github/ISSUE_TEMPLATE/**/*' + - '.devcontainer/**/*' + - 'embed/**/*' # Embed is submodule + - 'browser-extension/**/*' # Chrome extension is submodule + - 'server/utils/agents/aibitat/example/**/*' # Do not push new image for local dev testing of new aibitat images. + - 'extras/**/*' # Extra is just for news and other local content. + - 'open-computer/**/*' + +jobs: + push_multi_platform_to_registries: + name: Push Docker multi-platform image to multiple registries + runs-on: ubuntu-22.04-arm + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Check if DockerHub build needed + shell: bash + run: | + # Check if the secret for USERNAME is set (don't even check for the password) + if [[ -z "${{ secrets.DOCKER_USERNAME }}" ]]; then + echo "DockerHub build not needed" + echo "enabled=false" >> $GITHUB_OUTPUT + else + echo "DockerHub build needed" + echo "enabled=true" >> $GITHUB_OUTPUT + fi + id: dockerhub + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: v0.22.0 + + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + # Only login to the Docker Hub if the repo is mintplex/anythingllm, to allow for forks to build on GHCR + if: steps.dockerhub.outputs.enabled == 'true' + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: | + ${{ steps.dockerhub.outputs.enabled == 'true' && 'mintplexlabs/anythingllm' || '' }} + ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + + - name: Build and push multi-platform Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + push: true + sbom: true + provenance: mode=max + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # For Docker scout there are some intermediary reported CVEs which exists outside + # of execution content or are unreachable by an attacker but exist in image. + # We create VEX files for these so they don't show in scout summary. + - name: Collect known and verified CVE exceptions + id: cve-list + run: | + # Collect CVEs from filenames in vex folder + CVE_NAMES="" + for file in ./docker/vex/*.vex.json; do + [ -e "$file" ] || continue + filename=$(basename "$file") + stripped_filename=${filename%.vex.json} + CVE_NAMES+=" $stripped_filename" + done + echo "CVE_EXCEPTIONS=$CVE_NAMES" >> $GITHUB_OUTPUT + shell: bash + + # About VEX attestations https://docs.docker.com/scout/explore/exceptions/ + # Justifications https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md#status-justifications + - name: Add VEX attestations + env: + CVE_EXCEPTIONS: ${{ steps.cve-list.outputs.CVE_EXCEPTIONS }} + run: | + echo $CVE_EXCEPTIONS + curl -sSfL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh -s -- + for cve in $CVE_EXCEPTIONS; do + for tag in "${{ join(fromJSON(steps.meta.outputs.json).tags, ' ') }}"; do + echo "Attaching VEX exception $cve to $tag" + docker scout attestation add \ + --file "./docker/vex/$cve.vex.json" \ + --predicate-type https://openvex.dev/ns/v0.2.0 \ + $tag + done + done + shell: bash \ No newline at end of file diff --git a/.github/workflows/build-qa-tag.yaml b/.github/workflows/build-qa-tag.yaml new file mode 100644 index 0000000..5ab24c2 --- /dev/null +++ b/.github/workflows/build-qa-tag.yaml @@ -0,0 +1,70 @@ +# Builds a QA GHCR image for a PR when the "PR: Ready for QA" label is present. +# Triggers on: +# - "PR: Ready for QA" label added to a PR +# - New commits pushed to a PR that already has the label will trigger a new build +name: Build QA GHCR Image + +on: + pull_request: + types: [labeled, synchronize] + paths-ignore: + - "**.md" + - ".gitmodules" + - "cloud-deployments/**/*" + - "images/**/*" + - ".vscode/**/*" + - "**/.env.example" + - ".github/ISSUE_TEMPLATE/**/*" + - ".devcontainer/**/*" + - "embed/**/*" + - "browser-extension/**/*" + - "extras/**/*" + - 'open-computer/**/*' + + +concurrency: + group: qa-build-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build and push QA image for PR + runs-on: ubuntu-22.04-arm + # Run when labeled with "PR: Ready for QA" + if: >- + ${{ contains(github.event.pull_request.labels.*.name, 'PR: Ready for QA') }} + permissions: + packages: write + contents: read + pull-requests: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: v0.22.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set lowercase repository owner + run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + push: true + sbom: true + provenance: mode=max + platforms: linux/arm64 + tags: ghcr.io/${{ env.REPO_OWNER_LC }}/${{ github.event.repository.name }}:pr-${{ github.event.pull_request.number }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/check-package-versions.yaml b/.github/workflows/check-package-versions.yaml new file mode 100644 index 0000000..e407a06 --- /dev/null +++ b/.github/workflows/check-package-versions.yaml @@ -0,0 +1,37 @@ +# This GitHub action is for checking the versions of the packages in the project. +# Any package that is present in both the `server` and `collector` package.json file +# is checked to ensure that they are the same version. +name: Check package versions + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "server/package.json" + - "collector/package.json" + +jobs: + run-script: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Run verifyPackageVersions.mjs script + run: | + cd extras/scripts + node verifyPackageVersions.mjs + + - name: Fail job on error + if: failure() + run: exit 1 diff --git a/.github/workflows/check-translations.yaml b/.github/workflows/check-translations.yaml new file mode 100644 index 0000000..5180db9 --- /dev/null +++ b/.github/workflows/check-translations.yaml @@ -0,0 +1,37 @@ +# This GitHub action is for validation of all languages which translations are offered for +# in the locales folder in `frontend/src`. All languages are compared to the EN translation +# schema since that is the fallback language setting. This workflow will run on all PRs that +# modify any files in the translation directory +name: Verify translations files + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "frontend/src/locales/**.js" + +jobs: + run-script: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Run verifyTranslations.mjs script + run: | + cd frontend/src/locales + node verifyTranslations.mjs + + - name: Fail job on error + if: failure() + run: exit 1 diff --git a/.github/workflows/cleanup-qa-tag.yaml b/.github/workflows/cleanup-qa-tag.yaml new file mode 100644 index 0000000..4f7dd0f --- /dev/null +++ b/.github/workflows/cleanup-qa-tag.yaml @@ -0,0 +1,84 @@ +# Cleans up the GHCR image tag when the PR is closed or the "PR: Ready for QA" label is removed. +name: Cleanup QA GHCR Image + +on: + pull_request: + types: [closed, unlabeled] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to clean up (e.g., 123)' + required: true + +jobs: + cleanup-manual: + name: Delete QA GHCR image tag (manual) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + permissions: + packages: write + steps: + - name: Delete PR tag from GHCR + env: + GH_TOKEN: ${{ secrets.ALLM_RW_PACKAGES }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + # Must use lowercase - packages are published with lowercase owner + ORG_LC="${GITHUB_REPOSITORY_OWNER,,}" + REPO_LC="${GITHUB_REPOSITORY#*/}" + REPO_LC="${REPO_LC,,}" + + echo "Looking for tag: pr-${PR_NUMBER}" + echo "Package: /orgs/${ORG_LC}/packages/container/${REPO_LC}/versions" + + VERSION_ID=$(gh api \ + -H "Accept: application/vnd.github+json" \ + --paginate \ + "/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions" \ + --jq ".[] | select(.metadata.container.tags[] == \"pr-${PR_NUMBER}\") | .id") + + if [ -n "$VERSION_ID" ]; then + echo "Deleting package version $VERSION_ID (tag: pr-${PR_NUMBER})" + gh api \ + --method DELETE \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions/$VERSION_ID" + else + echo "No package found with tag pr-${PR_NUMBER}, skipping cleanup" + fi + + cleanup-auto: + name: Delete QA GHCR image tag (auto) + runs-on: ubuntu-latest + if: >- + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR: Ready for QA')) || + (github.event.action == 'unlabeled' && github.event.label.name == 'PR: Ready for QA') + permissions: + packages: write + steps: + - name: Delete PR tag from GHCR + env: + GH_TOKEN: ${{ secrets.ALLM_RW_PACKAGES }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Must use lowercase - packages are published with lowercase owner + ORG_LC="${GITHUB_REPOSITORY_OWNER,,}" + REPO_LC="${GITHUB_REPOSITORY#*/}" + REPO_LC="${REPO_LC,,}" + + VERSION_ID=$(gh api \ + -H "Accept: application/vnd.github+json" \ + --paginate \ + "/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions" \ + --jq ".[] | select(.metadata.container.tags[] == \"pr-${PR_NUMBER}\") | .id" \ + 2>/dev/null || true) + + if [ -n "$VERSION_ID" ]; then + echo "Deleting package version $VERSION_ID (tag: pr-${PR_NUMBER})" + gh api \ + --method DELETE \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions/$VERSION_ID" + else + echo "No package found with tag pr-${PR_NUMBER}, skipping cleanup" + fi diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..ced01d0 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,75 @@ +name: Lint + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "server/**/*.js" + - "server/eslint.config.mjs" + - "collector/**/*.js" + - "collector/eslint.config.mjs" + - "frontend/src/**/*.js" + - "frontend/src/**/*.jsx" + - "frontend/eslint.config.js" + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Cache server dependencies + uses: actions/cache@v4 + with: + path: server/node_modules + key: ${{ runner.os }}-yarn-server-${{ hashFiles('server/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-server- + + - name: Cache frontend dependencies + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: ${{ runner.os }}-yarn-frontend-${{ hashFiles('frontend/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-frontend- + + - name: Cache collector dependencies + uses: actions/cache@v4 + with: + path: collector/node_modules + key: ${{ runner.os }}-yarn-collector-${{ hashFiles('collector/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-collector- + + - name: Install server dependencies + run: cd server && yarn install --frozen-lockfile + + - name: Install frontend dependencies + run: cd frontend && yarn install --frozen-lockfile + + - name: Install collector dependencies + run: cd collector && yarn install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: "true" + SHARP_IGNORE_GLOBAL_LIBVIPS: "true" + + - name: Lint server + run: cd server && yarn lint:check + + - name: Lint frontend + run: cd frontend && yarn lint:check + + - name: Lint collector + run: cd collector && yarn lint:check diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..3a1f2f8 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,80 @@ +name: Run backend tests + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "server/**.js" + - "collector/**.js" + +jobs: + run-script: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Cache root dependencies + uses: actions/cache@v3 + with: + path: | + node_modules + ~/.cache/yarn + key: ${{ runner.os }}-yarn-root-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-root- + + - name: Cache server dependencies + uses: actions/cache@v3 + with: + path: | + server/node_modules + ~/.cache/yarn + key: ${{ runner.os }}-yarn-server-${{ hashFiles('server/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-server- + + - name: Cache collector dependencies + uses: actions/cache@v3 + with: + path: | + collector/node_modules + ~/.cache/yarn + key: ${{ runner.os }}-yarn-collector-${{ hashFiles('collector/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-collector- + + - name: Install root dependencies + if: steps.cache-root.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: Install server dependencies + if: steps.cache-server.outputs.cache-hit != 'true' + run: cd server && yarn install --frozen-lockfile + + - name: Install collector dependencies + if: steps.cache-collector.outputs.cache-hit != 'true' + run: cd collector && yarn install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: "true" + SHARP_IGNORE_GLOBAL_LIBVIPS: "true" + + - name: Setup environment and Prisma + run: yarn setup:envs && yarn prisma:setup + + - name: Run test suites + run: yarn test + + - name: Fail job on error + if: failure() + run: exit 1 diff --git a/.github/workflows/sponsors.yaml b/.github/workflows/sponsors.yaml new file mode 100644 index 0000000..925b845 --- /dev/null +++ b/.github/workflows/sponsors.yaml @@ -0,0 +1,44 @@ +name: Generate Sponsors README + +on: + schedule: + - cron: "0 12 * * 3" # Run every Wednesday at 12:00 PM UTC + +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v2 + + - name: Generate All Sponsors README + id: generate-all-sponsors + uses: JamesIves/github-sponsors-readme-action@v1 + with: + token: ${{ secrets.SPONSOR_PAT }} + file: 'README.md' + organization: true + active-only: false + marker: 'all-sponsors' + + - name: Commit and Push 🚀 + uses: stefanzweifel/git-auto-commit-action@v5 + id: auto-commit-action + with: + commit_message: 'Update Sponsors README' + file_pattern: 'README.md' + + - name: Generate PR if changes detected + uses: peter-evans/create-pull-request@v7 + if: steps.auto-commit-action.outputs.files_changed == 'true' + with: + token: ${{ secrets.GITHUB_TOKEN }} + title: 'Update Sponsors README' + branch: 'chore/update-sponsors' + base: 'master' + draft: false + reviewers: 'timothycarambat' + assignees: 'timothycarambat' + maintainer-can-modify: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e4419 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +v-env +.env +!.env.example + +node_modules +__pycache__ +v-env +.DS_Store +aws_cf_deploy_anything_llm.json +yarn.lock +*.bak +.idea diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0a491b4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,7 @@ +[submodule "browser-extension"] + path = browser-extension + url = https://github.com/Mintplex-Labs/anythingllm-extension.git +[submodule "embed"] + path = embed + url = https://github.com/Mintplex-Labs/anythingllm-embed.git + branch = main diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..b76a510 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,8 @@ +failure-threshold: warning +ignored: + - DL3008 + - DL3013 +format: tty +trustedRegistries: + - docker.io + - gcr.io diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..6aab9b4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.18.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1c4725e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +# defaults +**/.git +**/.svn +**/.hg +**/node_modules + +#frontend +frontend/bundleinspector.html +**/dist + +#server +server/swagger/openapi.json +server/**/*.mjs + +#embed +**/static/** +embed/src/utils/chat/hljs.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5e2bccf --- /dev/null +++ b/.prettierrc @@ -0,0 +1,38 @@ +{ + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "semi": true, + "singleQuote": false, + "printWidth": 80, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "overrides": [ + { + "files": ["*.js", "*.mjs", "*.jsx"], + "options": { + "parser": "flow", + "arrowParens": "always" + } + }, + { + "files": ["*.config.js"], + "options": { + "semi": false, + "parser": "flow", + "trailingComma": "none" + } + }, + { + "files": "*.html", + "options": { + "bracketSameLine": true + } + }, + { + "files": ".prettierrc", + "options": { "parser": "json" } + } + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..911b0a7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,74 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Collector debug", + "request": "launch", + "cwd": "${workspaceFolder}/collector", + "env": { + "NODE_ENV": "development" + }, + "runtimeArgs": [ + "index.js" + ], + // not using yarn/nodemon because it doesn't work with breakpoints + // "runtimeExecutable": "yarn", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + { + "name": "Server debug", + "request": "launch", + "cwd": "${workspaceFolder}/server", + "env": { + "NODE_ENV": "development" + }, + "runtimeArgs": [ + "index.js" + ], + // not using yarn/nodemon because it doesn't work with breakpoints + // "runtimeExecutable": "yarn", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + { + "name": "Frontend debug", + "request": "launch", + "cwd": "${workspaceFolder}/frontend", + "env": { + "NODE_ENV": "development", + }, + "runtimeExecutable": "${workspaceFolder}/frontend/node_modules/.bin/vite", + "runtimeArgs": [ + "--debug", + "--host=0.0.0.0" + ], + // "runtimeExecutable": "yarn", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c4e7a4b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,64 @@ +{ + "cSpell.words": [ + "adoc", + "aibitat", + "AIbitat", + "allm", + "anythingllm", + "Apipie", + "Astra", + "Chartable", + "cleancss", + "comkey", + "cooldown", + "cooldowns", + "datafile", + "Deduplicator", + "Dockerized", + "docpath", + "elevenlabs", + "Embeddable", + "epub", + "fireworksai", + "GROQ", + "hljs", + "huggingface", + "inferencing", + "koboldcpp", + "Langchain", + "lmstudio", + "localai", + "mbox", + "Milvus", + "Mintplex", + "mixtral", + "moderations", + "novita", + "numpages", + "Ollama", + "Oobabooga", + "openai", + "opendocument", + "openrouter", + "pagerender", + "ppio", + "Qdrant", + "royalblue", + "SearchApi", + "searxng", + "SerpApi", + "Serper", + "Serply", + "streamable", + "textgenwebui", + "togetherai", + "Unembed", + "uuidv", + "vectordbs", + "Weaviate", + "XAILLM", + "Zilliz" + ], + "eslint.experimental.useFlatConfig": true, + "docker.languageserver.formatter.ignoreMultilineInstructions": true +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6783e17 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,94 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/collector", + "statusbar": { + "color": "#ffea00", + "detail": "Runs the collector", + "label": "Collector: $(play) run", + "running": { + "color": "#ffea00", + "label": "Collector: $(gear~spin) running" + } + } + }, + "command": "cd ${workspaceFolder}/collector/ && yarn dev", + "runOptions": { + "instanceLimit": 1, + "reevaluateOnRerun": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "label": "Collector: run" + }, + { + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/server", + "statusbar": { + "color": "#ffea00", + "detail": "Runs the server", + "label": "Server: $(play) run", + "running": { + "color": "#ffea00", + "label": "Server: $(gear~spin) running" + } + } + }, + "command": "if [ \"${CODESPACES}\" = \"true\" ]; then while ! gh codespace ports -c $CODESPACE_NAME | grep 3001; do sleep 1; done; gh codespace ports visibility 3001:public -c $CODESPACE_NAME; fi & cd ${workspaceFolder}/server/ && yarn dev", + "runOptions": { + "instanceLimit": 1, + "reevaluateOnRerun": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "label": "Server: run" + }, + { + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/frontend", + "statusbar": { + "color": "#ffea00", + "detail": "Runs the frontend", + "label": "Frontend: $(play) run", + "running": { + "color": "#ffea00", + "label": "Frontend: $(gear~spin) running" + } + } + }, + "command": "cd ${workspaceFolder}/frontend/ && yarn dev", + "runOptions": { + "instanceLimit": 1, + "reevaluateOnRerun": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "label": "Frontend: run" + } + ] +} diff --git a/BARE_METAL.md b/BARE_METAL.md new file mode 100644 index 0000000..51e48f4 --- /dev/null +++ b/BARE_METAL.md @@ -0,0 +1,147 @@ +# Run AnythingLLM in production without Docker + +> [!WARNING] +> This method of deployment is **not supported** by the core-team and is to be used as a reference for your deployment. +> You are fully responsible for securing your deployment and data in this mode. +> **Any issues** experienced from bare-metal or non-containerized deployments will be **not** answered or supported. + +Here you can find the scripts and known working process to run AnythingLLM outside of a Docker container. + +### Minimum Requirements +> [!TIP] +> You should aim for at least 2GB of RAM. Disk storage is proportional to however much data +> you will be storing (documents, vectors, models, etc). Minimum 10GB recommended. + +- NodeJS v18 +- Yarn + + +## Getting started + +1. Clone the repo into your server as the user who the application will run as. +`git clone git@github.com:Mintplex-Labs/anything-llm.git` + +2. `cd anything-llm` and run `yarn setup`. This will install all dependencies to run in production as well as debug the application. + +3. `cp server/.env.example server/.env` to create the basic ENV file for where instance settings will be read from on service start. + +4. Ensure that the `server/.env` file has _at least_ these keys to start. These values will persist and this file will be automatically written and managed after your first successful boot. +``` +STORAGE_DIR="/your/absolute/path/to/server/storage" +``` + +5. Edit the `frontend/.env` file for the `VITE_API_BASE` to now be set to `/api`. This is documented in the .env for which one you should use. +``` +# VITE_API_BASE='http://localhost:3001/api' # Use this URL when developing locally +# VITE_API_BASE="https://$CODESPACE_NAME-3001.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/api" # for GitHub Codespaces +VITE_API_BASE='/api' # Use this URL deploying on non-localhost address OR in docker. +``` + +## To start the application + +AnythingLLM is comprised of three main sections. The `frontend`, `server`, and `collector`. When running in production you will be running `server` and `collector` on two different processes, with a build step for compilation of the frontend. + +1. Build the frontend application. +`cd frontend && yarn build` - this will produce a `frontend/dist` folder that will be used later. + +2. Copy `frontend/dist` to `server/public` - `cp -R frontend/dist server/public`. +This should create a folder in `server` named `public` which contains a top level `index.html` file and various other files/folders. + +3. Migrate and prepare your database file. +``` +cd server && npx prisma generate --schema=./prisma/schema.prisma +cd server && npx prisma migrate deploy --schema=./prisma/schema.prisma +``` + +4. Boot the server in production +`cd server && NODE_ENV=production node index.js &` + +5. Boot the collection in another process +`cd collector && NODE_ENV=production node index.js &` + +AnythingLLM should now be running on `http://localhost:3001`! + +## Updating AnythingLLM + +To update AnythingLLM with future updates you can `git pull origin master` to pull in the latest code and then repeat steps 2 - 5 to deploy with all changes fully. + +_note_ You should ensure that each folder runs `yarn` again to ensure packages are up to date in case any dependencies were added, changed, or removed. + +_note_ You should `pkill node` before running an update so that you are not running multiple AnythingLLM processes on the same instance as this can cause conflicts. + + +### Example update script + +```shell +#!/bin/bash + +cd $HOME/anything-llm &&\ +git checkout . &&\ +git pull origin master &&\ +echo "HEAD pulled to commit $(git log -1 --pretty=format:"%h" | tail -n 1)" + +echo "Freezing current ENVs" +curl -I "http://localhost:3001/api/env-dump" | head -n 1|cut -d$' ' -f2 + +echo "Rebuilding Frontend" +cd $HOME/anything-llm/frontend && yarn && yarn build && cd $HOME/anything-llm + +echo "Copying to Server Public" +rm -rf server/public +cp -r frontend/dist server/public + +echo "Killing node processes" +pkill node + +echo "Installing collector dependencies" +cd $HOME/anything-llm/collector && yarn + +echo "Installing server dependencies & running migrations" +cd $HOME/anything-llm/server && yarn +cd $HOME/anything-llm/server && npx prisma migrate deploy --schema=./prisma/schema.prisma +cd $HOME/anything-llm/server && npx prisma generate + +echo "Booting up services." +truncate -s 0 /logs/server.log # Or any other log file location. +truncate -s 0 /logs/collector.log + +cd $HOME/anything-llm/server +(NODE_ENV=production node index.js) &> /logs/server.log & + +cd $HOME/anything-llm/collector +(NODE_ENV=production node index.js) &> /logs/collector.log & +``` + +## Using Nginx? + +If you are using Nginx, you can use the following example configuration to proxy the requests to the server. Chats for streaming require **websocket** connections, so you need to ensure that the Nginx configuration is set up to support websockets. You can do this with a simple reverse proxy configuration. + +```nginx +server { + # Enable websocket connections for agent protocol. + location ~* ^/api/agent-invocation/(.*) { + proxy_pass http://0.0.0.0:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + listen 80; + server_name [insert FQDN here]; + location / { + # Prevent timeouts on long-running requests. + proxy_connect_timeout 605; + proxy_send_timeout 605; + proxy_read_timeout 605; + send_timeout 605; + keepalive_timeout 605; + + # Enable readable HTTP Streaming for LLM streamed responses + proxy_buffering off; + proxy_cache off; + + # Proxy your locally running service + proxy_pass http://0.0.0.0:3001; + } +} +``` \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4ff26e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,146 @@ +# Contributing to AnythingLLM + +AnythingLLM is an open-source project and we welcome contributions from the community. + +## Reporting Issues + +If you encounter a bug or have a feature request, please open an issue on the +[GitHub issue tracker](https://github.com/mintplex-labs/anything-llm). + +## Picking an issue + +We track issues on the GitHub issue tracker. If you are looking for something to +work on, check the [good first issue](https://github.com/mintplex-labs/anything-llm/contribute) label. These issues are typically the best described and have the smallest scope. There may be issues that are not labeled as good first issue, but are still a good starting point. + +If there's an issue you are interested in working on, please leave a comment on the issue. This will help us avoid duplicate work. Additionally, if you have questions about the issue, please ask them in the issue comments. We are happy to provide guidance on how to approach the issue. + +## Before you start + +Keep in mind that we are a small team and have limited resources. We will do our best to review and merge your PRs, but please be patient. Ultimately, **we become the maintainer** of your changes. It is our responsibility to make sure that the changes are working as expected and are of high quality as well as being compatible with the rest of the project both for existing users and for future users & features. + +Before you start working on an issue, please read the following so that you don't waste time on something that is not a good fit for the project or is more suitable for a personal fork. We would rather answer a comment on an issue than close a PR after you've spent time on it. Your time is valuable and we appreciate your time and effort to make AnythingLLM better. + +0. (most important) If you are making a PR that does not have a corresponding issue, **it will not be merged.** _The only exception to this is language translations._ + +1. If you are modifying the permission system for a new role or something custom, you are likely better off forking the project and building your own version since this is a core part of the project and is only to be maintained by the AnythingLLM team. + +2. Integrations (LLM, Vector DB, etc.) are reviewed at our discretion. We will eventually get to them. Do not expect us to merge your integration PR instantly since there are often many moving parts and we want to make sure we get it right. We will get to it! + +3. It is our discretion to merge or not merge a PR. We value every contribution, but we also value the quality of the code and the user experience we envision for the project. It is a fine line to walk when running a project like this and please understand that merging or not merging a PR is not a reflection of the quality of the contribution and is not personal. We will do our best to provide feedback on the PR and help you make the changes necessary to get it merged. + +4. **Security** is always important. If you have a security concern, please do not open an issue. Instead, please open a CVE on our designated reporting platform [Huntr](https://huntr.com) or contact us at [team@mintplexlabs.com](mailto:team@mintplexlabs.com). + +## Configuring Git + +First, fork the repository on GitHub, then clone your fork: + +```bash +git clone https://github.com//anything-llm.git +cd anything-llm +``` + +Then add the main repository as a remote: + +```bash +git remote add upstream https://github.com/mintplex-labs/anything-llm.git +git fetch upstream +``` + +## Setting up your development environment + +In the root of the repository, run: + +```bash +yarn setup +``` + +This will install the dependencies, set up the proper and expected ENV files for the project, and run the prisma setup script. +Next, run: + +```bash +yarn dev +``` +This will start the server, frontend, and collector in development mode. Changes to the code will be hot reloaded. + +## Best practices for pull requests + +For the best chance of having your pull request accepted, please follow these guidelines: + +1. Unit test all bug fixes and new features. Your code will not be merged if it + doesn't have tests. +1. If you change the public API, update the documentation in the `anythingllm-docs` repository. +1. Aim to minimize the number of changes in each pull request. Keep to solving + one problem at a time, when possible. +1. Before marking a pull request ready-for-review, do a self review of your code. + Is it clear why you are making the changes? Are the changes easy to understand? +1. Use [conventional commit messages](https://www.conventionalcommits.org/en/) as pull request titles. Examples: + * New feature: `feat: adding foo API` + * Bug fix: `fix: issue with foo API` + * Documentation change: `docs: adding foo API documentation` +1. If your pull request is a work in progress, leave the pull request as a draft. + We will assume the pull request is ready for review when it is opened. +1. When writing tests, test the error cases. Make sure they have understandable + error messages. + +## Project structure + +The core library is written in Node.js. There are additional sub-repositories for the embed widget and browser extension. These are not part of the core AnythingLLM project, but are maintained by the AnythingLLM team. + +* `server`: Node.js server source code +* `frontend`: React frontend source code +* `collector`: Node.js collector source code + +## Release process + +Changes to the core AnythingLLM project are released through the `master` branch. When a PR is merged into `master`, a new version of the package is published to Docker and GitHub Container Registry under the `latest` tag. + +When a new version is released, the following steps are taken a new image is built and pushed to Docker Hub and GitHub Container Registry under the associated version tag. Version tags are of the format `v..` and are pinned code, while `latest` is the latest version of the code at any point in time. + +### Desktop propagation + +Changes to the desktop app are downstream of the core AnythingLLM project. Releases of the desktop app are published at the same time as the core AnythingLLM project. Code from the core AnythingLLM project is copied into the desktop app into an Electron wrapper. The Electron wrapper that wraps around the core AnythingLLM project is **not** part of the core AnythingLLM project, but is maintained by the AnythingLLM team. + +## 🔌 Criteria for New LLM Providers + +To ensure the long-term maintainability of AnythingLLM and prevent repository bloat, we enforce a vetting process for adding new third-party LLM provider integrations. + +With thousands of new wrapper API services launching daily, we do not accept dedicated integrations for services that lack an established user base or offer no unique technical utility over our existing generic connectors which should be sufficient for most use cases. + +While we understand everyone has to start somewhere, we cannot maintain a repository with thousands of **bespoke** LLM integrations that functionally are no different from one another. We want to keep the repository as clean and maintainable as possible since 99% of contributors for this specific integration do their integration PR and never contribute again. + +> 🤝 **Strategic Partnership Exception:** +> These guidelines apply strictly to unsolicited community or third-party startup contributions. If you are an ecosystem, silicon, or cloud hardware partner engaging directly with the Mintplex Labs core team on a co-developed integration, proof-of-concept, or native optimization project, this vetting process is not applicable. + +Before opening an unsolicited issue or submitting a Pull Request for a new provider, it **must** meet both the Technical and Market Viability thresholds below. + +### 1. Technical Threshold +We do not accept dedicated integration code for providers whose API architecture mimics existing standards. + +* **The OpenAI-Compatibility Rule:** If your service utilizes the OpenAI SDK, standard OpenAI API schema (eg: `/v1/chat/completions`, `/models`) without requiring unique orchestration logic, **it will be rejected.** Users must connect to your service using our generic **OpenAI Compatible** or **Generic API** connectors. +* **To qualify for a dedicated integration, the PR must prove:** + * **Custom Authentication:** Requires a complex, multi-step auth flow or custom request signing (e.g., AWS SigV4) that standard bearer tokens/headers cannot support. + * **Proprietary SDK/Payloads:** Relies on a distinct, widely adopted native SDK with a JSON schema that cannot be cleanly mapped to our generic layers. + * **Unique Architectural Features:** Exposes critical, native platform capabilities (e.g., custom server-side routing nodes or proprietary hyper-parameters) that are completely lost when forced through a generic wrapper. + +### 2. Market Viability Threshold +We cannot act as a discovery or marketing engine for early-stage startups. To qualify for codebase inclusion, a provider must demonstrate an active, existing user base who would benefit from AnythingLLM's functionality. Any of the following criteria are acceptable: + +* **Community Demand:** An integration issue request must accumulate a minimum of **20 organic upvotes (`+1` reactions)** from unique GitHub users before a PR will be reviewed. +* **Footprint Metrics:** The provider or core underlying model organization must possess a verifiable footprint (e.g., `50,000` aggregate downloads on Hugging Face, or `1,000` stars on its core open-source repository). +* **Operational Longevity:** The provider's production API must be publicly accessible and stable for a minimum of **90 days**. We do not accept integrations for services that launched less than 90 days ago. + +## 🤖 AI Use in Contributions + +We are an AI company — we obviously use AI tools and expect contributors do too. However, we believe in **AI-augmented engineers**, not AI-replaced engineers. There is a difference between using an LLM to help you write code and having an LLM write code for you. + +### What will get your PR closed immediately + +1. **LLM-generated tests that don't test your code.** If your tests are clearly auto-generated boilerplate that blindly asserts unrelated functionality (e.g., testing the Node.js `fs` module instead of the feature you changed), your PR will be closed. Tests should demonstrate that you understand the code you wrote and that it works. It is not our job to understand the code your LLM generated, but it becomes our responsibility to maintain it forever. + +2. **`Claude Code` or similar agent signatures in your commit history.** In our view, a commit history full of autonomous agent commits signals low-effort work and a lack of craft or care in the functionality being contributed. We take pride in what we ship and expect the same from contributors. Use an LLM to help you think, draft, and iterate — but the work should be yours, reviewed by you, and committed by you. + +We are not anti-AI. We are anti-low-effort. If it looks like you prompted an agent, accepted the output without scrutiny, and opened a PR — we will close it to preserve our time and resources. + +## License + +By contributing to AnythingLLM (this repository), you agree to license your contributions under the MIT license. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc42d1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Mintplex Labs Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dafc034 --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ + + +> [!NOTE] +> We are also working on [Open Computer](/open-computer) which gives an entire computer environment for AI Agents to use. +> +> This will bring AnythingLLM's agent capabilities to a new level and a novel UX paradigm for AI Agent use. +> +> ⭐ Star the repo to stay updated! + +

+ AnythingLLM logo +

+ +
+Mintplex-Labs%2Fanything-llm | Trendshift +
+ +

+ AnythingLLM: The all-in-one AI app you were looking for.
+ Chat with your docs, use AI Agents, hyper-configurable, multi-user, & no frustrating setup required. +

+ +

+ + Discord + | + + License + | + + Docs + | + + Hosted Instance + +

+ +

+ English · 简体中文 · 日本語 +

+ +

+👉 AnythingLLM for desktop (Mac, Windows, & Linux)! Download Now +

+ +Chat with your docs. Automate complex workflows with AI Agents. Hyper-configurable, multi-user ready, battle-tested—and runs locally by default with zero setup friction. + +![Chatting](https://github.com/Mintplex-Labs/anything-llm/releases/download/v1.11.2/AnythingLLM720p.gif) + +
+Watch the demo! + +[![Watch the video](/images/youtube.png)](https://youtu.be/f95rGD9trL0) + +
+ +### Product Overview + +AnythingLLM is the all-in-one AI application that lets you build a private, fully-featured ChatGPT—without compromises. Connect your favorite local or cloud LLM, ingest your documents, and start chatting in minutes. Out of the box you get built-in agents, multi-user support, vector databases, and document pipelines — no extra configuration required. + +AnythingLLM supports multiple users as well where you can control the access and experience per user without compromising the security or privacy of the instance or your intellectual property. + +## Cool Features of AnythingLLM + +- [Dynamic Model Routing](https://docs.anythingllm.com/model-router/overview) - Automatically route chats to the best provider & model for the conversation based on rules you define. +- [Automatic & User Managed Memories](https://docs.anythingllm.com/features/memories) - Have your LLM remember important information about you or your workspace. +- [Scheduled Tasks](https://docs.anythingllm.com/scheduled-jobs/overview) - Run recurring tasks or prompts on a cron schedule with full agent capabilities. +- [Intelligent Skill Selection](https://docs.anythingllm.com/agent/intelligent-tool-selection) Enable **unlimited** tools for your models while reducing token usage by up to 80% per query +- [No-code AI Agent builder](https://docs.anythingllm.com/agent-flows/overview) +- [MCP-compatibility](https://docs.anythingllm.com/mcp-compatibility/overview) +- [Multi-modal support (both closed and open-source LLMs!)](https://docs.anythingllm.com/features/multimodal) +- [Custom AI Agents](https://docs.anythingllm.com/agent/custom/introduction) +- 👤 Multi-user instance support and permissioning _Docker version only_ +- 🦾 Agents inside your workspace (browse the web, etc) +- 💬 [Custom Embeddable Chat widget for your website](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md) _Docker version only_ +- 📖 Multiple document type support (PDF, TXT, DOCX, etc) +- Intuitive chat UI with drag-and-drop uploads and source citations. +- Production-ready for any cloud deployment. +- Works with all popular [closed and open-source LLM providers](#supported-llms-embedder-models-speech-models-and-vector-databases). +- Built-in optimizations for large document sets—lower costs and faster responses than other chat UIs. +- Full Developer API for custom integrations! +- ...and much more—install in minutes and see for yourself. + +### Supported LLMs, Embedder Models, Speech models, and Vector Databases + +**Large Language Models (LLMs):** + +- [Any open-source llama.cpp compatible model](/server/storage/models/README.md#text-generation-llm-selection) +- [OpenAI](https://openai.com) +- [OpenAI (Generic)](https://openai.com) +- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service) +- [AWS Bedrock](https://aws.amazon.com/bedrock/) +- [Anthropic](https://www.anthropic.com/) +- [NVIDIA NIM (chat models)](https://build.nvidia.com/explore/discover) +- [Google Gemini Pro](https://ai.google.dev/) +- [Ollama (chat models)](https://ollama.ai/) +- [LM Studio (all models)](https://lmstudio.ai) +- [LocalAI (all models)](https://localai.io/) +- [Together AI (chat models)](https://www.together.ai/) +- [Fireworks AI (chat models)](https://fireworks.ai/) +- [Perplexity (chat models)](https://www.perplexity.ai/) +- [OpenRouter (chat models)](https://openrouter.ai/) +- [DeepSeek (chat models)](https://deepseek.com/) +- [Mistral](https://mistral.ai/) +- [Groq](https://groq.com/) +- [Cohere](https://cohere.com/) +- [KoboldCPP](https://github.com/LostRuins/koboldcpp) +- [LiteLLM](https://github.com/BerriAI/litellm) +- [Text Generation Web UI](https://github.com/oobabooga/text-generation-webui) +- [Apipie](https://apipie.ai/) +- [xAI](https://x.ai/) +- [Z.AI (chat models)](https://z.ai/model-api) +- [Novita AI (chat models)](https://novita.ai/model-api/product/llm-api?utm_source=github_anything-llm&utm_medium=github_readme&utm_campaign=link) +- [PPIO](https://ppinfra.com?utm_source=github_anything-llm) +- [Gitee AI](https://ai.gitee.com/) +- [Moonshot AI](https://www.moonshot.ai/) +- [Microsoft Foundry Local](https://github.com/microsoft/Foundry-Local) +- [CometAPI (chat models)](https://api.cometapi.com/) +- [Docker Model Runner](https://docs.docker.com/ai/model-runner/) +- [PrivateModeAI (chat models)](https://privatemode.ai/) +- [SambaNova Cloud (chat models)](https://cloud.sambanova.ai/) +- [Lemonade by AMD](https://lemonade-server.ai) +- [Minimax](https://platform.minimax.io) +- [Cerebras (chat models)](https://www.cerebras.ai/) + +**Embedder models:** + +- [AnythingLLM Native Embedder](/server/storage/models/README.md) (default) +- [OpenAI](https://openai.com) +- [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service) +- [Gemini](https://ai.google.dev/) +- [LocalAI (all)](https://localai.io/) +- [Ollama (all)](https://ollama.ai/) +- [LM Studio (all)](https://lmstudio.ai) +- [Lemonade](https://lemonade-server.ai) +- [OpenRouter](https://openrouter.ai/) +- [LiteLLM](https://github.com/BerriAI/litellm) +- [Cohere](https://cohere.com/) +- [Voyage AI](https://www.voyageai.com/) +- [Mistral](https://mistral.ai/) +- Generic OpenAI-compatible embedding APIs + +**Audio Transcription models:** + +- [AnythingLLM Built-in](https://github.com/Mintplex-Labs/anything-llm/tree/master/server/storage/models#audiovideo-transcription) (default) +- [OpenAI](https://openai.com/) + +**TTS (text-to-speech) support:** + +- Native Browser Built-in (default) +- [PiperTTSLocal - runs in browser](https://github.com/rhasspy/piper) +- [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options) +- [ElevenLabs](https://elevenlabs.io/) +- Any OpenAI Compatible TTS service. + +**STT (speech-to-text) support:** + +- Native Browser Built-in (default) + +**Vector Databases:** + +- [LanceDB](https://github.com/lancedb/lancedb) (default) +- [PGVector](https://github.com/pgvector/pgvector) +- [Astra DB](https://www.datastax.com/products/datastax-astra) +- [Pinecone](https://pinecone.io) +- [Chroma & ChromaCloud](https://trychroma.com) +- [Weaviate](https://weaviate.io) +- [Qdrant](https://qdrant.tech) +- [Milvus](https://milvus.io) +- [Zilliz](https://zilliz.com) + +### Technical Overview + +This monorepo consists of six main sections: + +- `frontend`: A viteJS + React frontend that you can run to easily create and manage all your content the LLM can use. +- `server`: A NodeJS express server to handle all the interactions and do all the vectorDB management and LLM interactions. +- `collector`: NodeJS express server that processes and parses documents from the UI. +- `docker`: Docker instructions and build process + information for building from source. +- `embed`: Submodule for generation & creation of the [web embed widget](https://github.com/Mintplex-Labs/anythingllm-embed). +- `browser-extension`: Submodule for the [chrome browser extension](https://github.com/Mintplex-Labs/anythingllm-extension). + +## 🛳 Self-Hosting + +Mintplex Labs & the community maintain a number of deployment methods, scripts, and templates that you can use to run AnythingLLM locally. Refer to the table below to read how to deploy on your preferred environment or to automatically deploy. +| Docker | AWS | GCP | Digital Ocean | Render.com | +|----------------------------------------|----|-----|---------------|------------| +| [![Deploy on Docker][docker-btn]][docker-deploy] | [![Deploy on AWS][aws-btn]][aws-deploy] | [![Deploy on GCP][gcp-btn]][gcp-deploy] | [![Deploy on DigitalOcean][do-btn]][do-deploy] | [![Deploy on Render.com][render-btn]][render-deploy] | + +| Railway | RepoCloud | Elestio | Northflank | +| --------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------ | +| [![Deploy on Railway][railway-btn]][railway-deploy] | [![Deploy on RepoCloud][repocloud-btn]][repocloud-deploy] | [![Deploy on Elestio][elestio-btn]][elestio-deploy] | [![Deploy on Northflank][northflank-btn]][northflank-deploy] | + +[or set up a production AnythingLLM instance without Docker →](./BARE_METAL.md) + +## How to setup for development + +- `yarn setup` To fill in the required `.env` files you'll need in each of the application sections (from root of repo). + - Go fill those out before proceeding. Ensure `server/.env.development` is filled or else things won't work right. +- `yarn dev:server` To boot the server locally (from root of repo). +- `yarn dev:frontend` To boot the frontend locally (from root of repo). +- `yarn dev:collector` To then run the document collector (from root of repo). + +[Learn about documents](./server/storage/documents/DOCUMENTS.md) + +## Telemetry & Privacy + +AnythingLLM by Mintplex Labs Inc contains a telemetry feature that collects anonymous usage information. + +
+More about Telemetry & Privacy for AnythingLLM + +### Why? + +We use this information to help us understand how AnythingLLM is used, to help us prioritize work on new features and bug fixes, and to help us improve AnythingLLM's performance and stability. + +### Opting out + +Set `DISABLE_TELEMETRY` in your server or docker .env settings to "true" to opt out of telemetry. You can also do this in-app by going to the sidebar > `Privacy` and disabling telemetry. + +### What do you explicitly track? + +We will only track usage details that help us make product and roadmap decisions, specifically: + +- Type of your installation (Docker or Desktop) + +- When a document is added or removed. No information _about_ the document. Just that the event occurred. This gives us an idea of use. + +- Type of vector database in use. This helps us prioritize changes when updates arrive for that provider. + +- Type of LLM provider & model tag in use. This helps us prioritize changes when updates arrive for that provider or model, or combination thereof. eg: reasoning vs regular, multi-modal models, etc. + +- When a chat is sent. This is the most regular "event" and gives us an idea of the daily-activity of this project across all installations. Again, only the **event** is sent - we have no information on the nature or content of the chat itself. + +You can verify these claims by finding all locations `Telemetry.sendTelemetry` is called. Additionally these events are written to the output log so you can also see the specific data which was sent - if enabled. **No IP or other identifying information is collected**. The Telemetry provider is [PostHog](https://posthog.com/) - an open-source telemetry collection service. + +We take privacy very seriously, and we hope you understand that we want to learn how our tool is used, without using annoying popup surveys, so we can build something worth using. The anonymous data is _never_ shared with third parties, ever. + +[View all telemetry events in source code](https://github.com/search?q=repo%3AMintplex-Labs%2Fanything-llm%20.sendTelemetry(&type=code) + +### Other outbound connections + +If you disable telemetry, you would still see outbound connections to the following services: + +- If using an external tool, LLM, Embedding models, or Vector databases, you will still see outbound connections to the respective service provider. +- `cdn.anythingllm.com` for pulling models from our mirror CDN. This is not tracked by telemetry and is actually useful for those in VPN restricted regions. +- `github/githubusercontent.com` There are some various flat files that are downloaded from these domains for context window caching. + +Basically, if telemetry is disabled we don't collect anything. However, depending on your setup you may still see outbound connections and would be subject to the terms of service of the respective service provider. + +
+ +## 👋 Contributing + +- [Contributing to AnythingLLM](./CONTRIBUTING.md) - How to contribute to AnythingLLM. + +## 💖 Sponsors + +### Premium Sponsors + + + + User avatar: DCS DIGITAL + + + +### All Sponsors + +User avatar: JaschaUser avatar: KickAssUser avatar: ShadowArcanistUser avatar: AtlasUser avatar: Predrag StojadinovićUser avatar: Diego SpinolaUser avatar: KyleUser avatar: Giulio De PasqualeUser avatar: User avatar: MacStadiumUser avatar: User avatar: User avatar: User avatar: User avatar: DennisUser avatar: Michael Hamilton, Ph.D.User avatar: User avatar: TernaryLabsUser avatar: Daniel CelaUser avatar: AlessoUser avatar: Rune MathisenUser avatar: User avatar: User avatar: AlanUser avatar: Damien PetersUser avatar: DCS DigitalUser avatar: Paul McilreavyUser avatar: Til WolfUser avatar: Leopoldo Crhistian Riverin GomezUser avatar: AJEsauUser avatar: Steven VanOmmerenUser avatar: Casey BoettcherUser avatar: User avatar: AvineetUser avatar: ChrisUser avatar: User avatar: Tim ChampUser avatar: Peter MathisenUser avatar: Ed di GirolamoUser avatar: Wojciech MiłkowskiUser avatar: ADS FundUser avatar: arc46 GmbHUser avatar: Li YinUser avatar: SylphAIUser avatar: User avatar: Mik + +## 🌟 Contributors + +[![anythingllm contributors](https://contrib.rocks/image?repo=mintplex-labs/anything-llm)](https://github.com/mintplex-labs/anything-llm/graphs/contributors) + +[![Star History Chart](https://api.star-history.com/svg?repos=mintplex-labs/anything-llm&type=Timeline)](https://star-history.com/#mintplex-labs/anything-llm&Date) + +## 🔗 More Products + +- **[AnythingLLM Mobile (MIT Licensed)][anythingllm-mobile]:** A mobile application that allows you to use AnythingLLM on your mobile device. +- **[AnythingLLM Browser Extension][anythingllm-extension]:** A browser extension that allows you to use AnythingLLM in your browser. +- **[AnythingLLM Embed][anythingllm-embed]:** A widget that allows you to embed AnythingLLM in your website. + +
+ +[![][back-to-top]](#readme-top) + +
+ +--- + +Copyright © 2026 [Mintplex Labs][profile-link].
+This project is [MIT](./LICENSE) licensed. + + + +[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-222628?style=flat-square +[profile-link]: https://github.com/mintplex-labs +[anythingllm-mobile]: https://github.com/Mintplex-Labs/anythingllm-mobile +[anythingllm-extension]: https://github.com/Mintplex-Labs/anythingllm-extension +[anythingllm-embed]: https://github.com/Mintplex-Labs/anythingllm-embed +[docker-btn]: ./images/deployBtns/docker.png +[docker-deploy]: ./docker/HOW_TO_USE_DOCKER.md +[aws-btn]: ./images/deployBtns/aws.png +[aws-deploy]: ./cloud-deployments/aws/cloudformation/DEPLOY.md +[gcp-btn]: https://deploy.cloud.run/button.svg +[gcp-deploy]: ./cloud-deployments/gcp/deployment/DEPLOY.md +[do-btn]: https://www.deploytodo.com/do-btn-blue.svg +[do-deploy]: ./cloud-deployments/digitalocean/terraform/DEPLOY.md +[render-btn]: https://render.com/images/deploy-to-render-button.svg +[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render +[render-btn]: https://render.com/images/deploy-to-render-button.svg +[render-deploy]: https://render.com/deploy?repo=https://github.com/Mintplex-Labs/anything-llm&branch=render +[railway-btn]: https://railway.app/button.svg +[railway-deploy]: https://railway.app/template/HNSCS1?referralCode=WFgJkn +[repocloud-btn]: https://d16t0pc4846x52.cloudfront.net/deploylobe.svg +[repocloud-deploy]: https://repocloud.io/details/?app_id=276 +[elestio-btn]: https://elest.io/images/logos/deploy-to-elestio-btn.png +[elestio-deploy]: https://elest.io/open-source/anythingllm +[northflank-btn]: https://assets.northflank.com/deploy_to_northflank_smm_36700fb050.svg +[northflank-deploy]: https://northflank.com/stacks/deploy-anythingllm diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..c07c2ef --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Mintplex-Labs/anything-llm` +- 原始仓库:https://github.com/Mintplex-Labs/anything-llm +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f55345b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + + +## Reporting a Vulnerability + +If a security concern is found that you would like to disclose you can create a PR for it or if you would like to clear this issue before posting you can email [Core Mintplex Labs Team](mailto:team@mintplexlabs.com). + +## Invalid Report Types + +Below are some common types of invalid reports that we will not accept and should not be submitted as they will be closed immediately without action. + +### SSRF Reports + +If you are about to report a SSRF about being able to call web-scraping or document collector against an internal host, [this is not a valid report](https://github.com/Mintplex-Labs/anything-llm/blob/master/collector/utils/url/index.js#L2-L13). This is a feature of the system and is intended to be used in this way given that AnythingLLM is designed to be used in this way so that it can leverage internal services for scraping and collecting content when deployed inside a VPC. + +### XXS Reports where user must right-click and paste in the URL + +If you are about to report a XXS about being able to right-click on an image (like user profile picture) and paste in the URL, this is not a valid report. User profile pictures must be uploaded by the user and cannot be set by the administrator. In fact, nobody can even see them aside from the user themselves. The same goes for any other image that is uploaded by the user or even produced by the system. If the user must right-click and paste in the URL to their browser, this is not a valid report. + +Valid XXS Reports must be zero-action - like on loading a page or a image instantly. + +### Any "Unauthenticated" actions + +If the basis of your report relies on the system not setting up a password or multi-user mode, this is not a valid report. AnythingLLM is designed to be used in this way so that it can be used in trusted and fully isolated environments for single user or internal user. There are three options for authentication: + +1. No authentication - this would have no endpoint authentication and would be accessible to anyone who knows the URL. +2. Password - this would require a password to access the system. +3. Multi-user mode - this would require a user to be logged in to the system with username and password and be given explicit access to the system by administrator. + +During onboarding, the system will prompt the user to set up a password or multi-user mode. If the user does not opt to set up a password or multi-user mode, the system will be accessible to anyone who knows the URL. This is an intentional design choice and is not a vulnerability. + +If your report is about being able to access the system via _bypassing the authentication_ or lackthereof, that **is a valid report** and will be investigated and fixed. \ No newline at end of file diff --git a/TERMS_SELF_HOSTED.md b/TERMS_SELF_HOSTED.md new file mode 100644 index 0000000..0553d0c --- /dev/null +++ b/TERMS_SELF_HOSTED.md @@ -0,0 +1,37 @@ +# AnythingLLM Self-Hosted: Data Privacy & Terms of Service + +This document outlines the privacy standards, data handling procedures, and licensing terms for the self-hosted version of AnythingLLM, developed by Mintplex Labs Inc. + +## 1. Data Sovereignty & Local-First Architecture +AnythingLLM is designed as a **local-first** application. When utilizing the self-hosted version (Docker, Desktop, or Source): +* **No External Access:** Mintplex Labs Inc. does not host, store, or have access to any documents, chat histories, workspace settings, or embeddings created within your instance. +* **On-Premise Storage:** All data resides strictly on the infrastructure provisioned and managed by the user or their organization. +* **Air-Gap Capability:** AnythingLLM can be operated in a strictly air-gapped environment with no internet connectivity, provided local LLM and Vector database providers (e.g., Ollama, LocalAI, LanceDB) are utilized. + +## 2. Telemetry and Analytics +To improve software performance and stability, AnythingLLM includes an optional telemetry feature. +* **Anonymity:** Collected data is strictly anonymous and contains no Personally Identifiable Information (PII), document content, chat logs, fingerprinting data, or any other sensitive information. Purely usage based data is collected. +* **Opt-Out:** Users may disable telemetry at any time via the **Settings** menu within the application. Once disabled, no usage data is transmitted to Mintplex Labs. + +## 3. Third-Party Integrations +AnythingLLM allows users to connect to external services (e.g., OpenAI, Anthropic, Pinecone). +* **Data Transmission:** When these services are enabled, data is transmitted directly from your instance to the third-party provider. +* **Governing Terms:** Data handled by third-party providers is subject to their respective Terms of Service and Privacy Policies. Mintplex Labs is not responsible for the data practices of these external entities. + +_by default, AnythingLLM does **everything on-device first** - so you would have to manually configure and enable these integrations to be subject to third party terms._ + +## 4. Security & Network +* **No "Phone Home":** Aside from [optional telemetry](https://github.com/Mintplex-Labs/anything-llm?tab=readme-ov-file#telemetry--privacy), the software does not require an external connection to Mintplex Labs servers to function. +* **Environment Security:** The user is responsible for securing the host environment, including network firewalls, SSL/TLS encryption, and access control for the AnythingLLM instance. +* **CDN Assets:** As a convenience to international users, we use a hosted CDN to mirror some critical path models (eg: the default embedder and reranking ONNX models) which are not available in all regions. These models are downloaded from our CDN as a fallback, and for any air-gapped installations you can either download these models manually or use another provider. Assets of this nature are downloaded once and cached in your associated local storage. + +## 5. Licensing and Liability +* **License:** The AnythingLLM core is provided under the **MIT License**. +* **No Warranty:** As per the license agreement, the software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability or fitness for a particular purpose. +* **Liability:** In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of the software. + +## 6. Support and Compatibility +While Mintplex Labs prioritizes stability and backward compatibility, the self-hosted version is used at the user's discretion. Formal Service Level Agreements (SLAs) are not provided for the standard self-hosted version unless otherwise negotiated via a separate enterprise agreement. + +--- +*Last Updated: March 2026* diff --git a/cloud-deployments/aws/cloudformation/DEPLOY.md b/cloud-deployments/aws/cloudformation/DEPLOY.md new file mode 100644 index 0000000..c7e84bc --- /dev/null +++ b/cloud-deployments/aws/cloudformation/DEPLOY.md @@ -0,0 +1,49 @@ +# How to deploy a private AnythingLLM instance on AWS + +With an AWS account you can easily deploy a private AnythingLLM instance on AWS. This will create a url that you can access from any browser over HTTP (HTTPS not supported). This single instance will run on your own keys and they will not be exposed - however if you want your instance to be protected it is highly recommend that you set a password once setup is complete. + +**Quick Launch (EASY)** +1. Log in to your AWS account +2. Open [CloudFormation](https://us-west-1.console.aws.amazon.com/cloudformation/home) +3. Ensure you are deploying in a geographic zone that is nearest to your physical location to reduce latency. +4. Click `Create Stack` + +![Create Stack](../../../images/screenshots/create_stack.png) + +5. Use the file `cloudformation_create_anythingllm.json` as your JSON template. + +![Upload Stack](../../../images/screenshots/upload.png) + +6. Click Deploy. +7. Wait for stack events to finish and be marked as `Completed` +8. View `Outputs` tab. + +![Stack Output](../../../images/screenshots/cf_outputs.png) + +9. Wait for all resources to be built. Now wait until instance is available on `[InstanceIP]:3001`. +This process may take up to 10 minutes. See **Note** below on how to visualize this process. + +The output of this cloudformation stack will be: +- 1 EC2 Instance +- 1 Security Group with 0.0.0.0/0 access on port 3001 +- 1 EC2 Instance Volume `gb2` of 10Gib minimum - customizable pre-deploy. + +**Requirements** +- An AWS account with billing information. + +## Please read this notice before submitting issues about your deployment + +**Note:** +Your instance will not be available instantly. Depending on the instance size you launched with it can take 5-10 minutes to fully boot up. + +If you want to check the instance's progress, navigate to [your deployed EC2 instances](https://us-west-1.console.aws.amazon.com/ec2/home) and connect to your instance via SSH in browser. + +Once connected run `sudo tail -f /var/log/cloud-init-output.log` and wait for the file to conclude deployment of the docker image. +You should see an output like this +``` +[+] Running 2/2 + ⠿ Network docker_anything-llm Created + ⠿ Container anything-llm Started +``` + +Additionally, your use of this deployment process means you are responsible for any costs of these AWS resources fully. \ No newline at end of file diff --git a/cloud-deployments/aws/cloudformation/aws_https_instructions.md b/cloud-deployments/aws/cloudformation/aws_https_instructions.md new file mode 100644 index 0000000..26b0a6b --- /dev/null +++ b/cloud-deployments/aws/cloudformation/aws_https_instructions.md @@ -0,0 +1,118 @@ +# How to Configure HTTPS for Anything LLM AWS private deployment +Instructions for manual https configuration after generating and running the aws cloudformation template (aws_build_from_source_no_credentials.json). Tested on following browsers: Firefox version 119, Chrome version 118, Edge 118. + +**Requirements** +- Successful deployment of Amazon Linux 2023 EC2 instance with Docker container running Anything LLM +- Admin priv to configure Elastic IP for EC2 instance via AWS Management Console UI +- Admin priv to configure DNS services (i.e. AWS Route 53) via AWS Management Console UI +- Admin priv to configure EC2 Security Group rules via AWS Management Console UI + +## Step 1: Allocate and assign Elastic IP Address to your deployed EC2 instance +1. Follow AWS instructions on allocating EIP here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html#using-instance-addressing-eips-allocating +2. Follow AWS instructions on assigning EIP to EC2 instance here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html#using-instance-addressing-eips-associating + +## Step 2: Configure DNS A record to resolve to the previously assigned EC2 instance via EIP +These instructions assume that you already have a top-level domain configured and are using a subdomain +to access AnythingLLM. +1. Follow AWS instructions on routing traffic to EC2 instance here: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-ec2-instance.html + +## Step 3: Install and enable nginx +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo yum install nginx -y +2. $sudo systemctl enable nginx && sudo systemctl start nginx + +## Step 4: Install certbot +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo yum install -y augeas-libs +2. $sudo python3 -m venv /opt/certbot/ +3. $sudo /opt/certbot/bin/pip install --upgrade pip +4. $sudo /opt/certbot/bin/pip install certbot certbot-nginx +5. $sudo ln -s /opt/certbot/bin/certbot /usr/bin/certbot + +## Step 5: Configure temporary Inbound Traffic Rule for Security Group to certbot DNS verification +1. Follow AWS instructions on creating inbound rule (http port 80 0.0.0.0/0) for EC2 security group here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/working-with-security-groups.html#adding-security-group-rule + +## Step 6: Comment out default http NGINX proxy configuration +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo vi /etc/nginx/nginx.conf +2. In the nginx.conf file, comment out the default server block configuration for http/port 80. It should look something like the following: +``` +# server { +# listen 80; +# listen [::]:80; +# server_name _; +# root /usr/share/nginx/html; +# +# # Load configuration files for the default server block. +# include /etc/nginx/default.d/*.conf; +# +# error_page 404 /404.html; +# location = /404.html { +# } +# +# error_page 500 502 503 504 /50x.html; +# location = /50x.html { +# } +# } +``` +3. Enter ':wq' to save the changes to the nginx default config + +## Step 7: Create simple http proxy configuration for AnythingLLM +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo vi /etc/nginx/conf.d/anything.conf +2. Add the following configuration ensuring that you add your FQDN:. + +``` +server { + # Enable websocket connections for agent protocol. + location ~* ^/api/agent-invocation/(.*) { + proxy_pass http://0.0.0.0:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + listen 80; + server_name [insert FQDN here]; + location / { + # Prevent timeouts on long-running requests. + proxy_connect_timeout 605; + proxy_send_timeout 605; + proxy_read_timeout 605; + send_timeout 605; + keepalive_timeout 605; + + # Enable readable HTTP Streaming for LLM streamed responses + proxy_buffering off; + proxy_cache off; + + # Proxy your locally running service + proxy_pass http://0.0.0.0:3001; + } +} +``` +3. Enter ':wq' to save the changes to the anything config file + +## Step 8: Test nginx http proxy config and restart nginx service +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo nginx -t +2. $sudo systemctl restart nginx +3. Navigate to http://FQDN in a browser and you should be proxied to the AnythingLLM web UI. + +## Step 9: Generate/install cert +These instructions are for CLI configuration and assume you are logged in to EC2 instance as the ec2-user. +1. $sudo certbot --nginx -d [Insert FQDN here] + Example command: $sudo certbot --nginx -d anythingllm.exampleorganization.org + This command will generate the appropriate certificate files, write the files to /etc/letsencrypt/live/yourFQDN, and make updates to the nginx + configuration file for anythingllm located at /etc/nginx/conf.d/anything.llm +3. Enter the email address you would like to use for updates. +4. Accept the terms of service. +5. Accept or decline to receive communication from LetsEncrypt. + +## Step 10: Test Cert installation +1. $sudo cat /etc/nginx/conf.d/anything.conf +Your should see a completely updated configuration that includes https/443 and a redirect configuration for http/80. +2. Navigate to https://FQDN in a browser and you should be proxied to the AnythingLLM web UI. + +## Step 11: (Optional) Remove temporary Inbound Traffic Rule for Security Group to certbot DNS verification +1. Follow AWS instructions on deleting inbound rule (http port 80 0.0.0.0/0) for EC2 security group here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/working-with-security-groups.html#deleting-security-group-rule diff --git a/cloud-deployments/aws/cloudformation/cloudformation_create_anythingllm.json b/cloud-deployments/aws/cloudformation/cloudformation_create_anythingllm.json new file mode 100644 index 0000000..b10bbc2 --- /dev/null +++ b/cloud-deployments/aws/cloudformation/cloudformation_create_anythingllm.json @@ -0,0 +1,234 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Create a stack that runs AnythingLLM on a single instance", + "Parameters": { + "InstanceType": { + "Description": "EC2 instance type", + "Type": "String", + "Default": "t3.small" + }, + "InstanceVolume": { + "Description": "Storage size of disk on Instance in GB", + "Type": "Number", + "Default": 10, + "MinValue": 4 + } + }, + "Resources": { + "AnythingLLMInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "Region2AMI", + { + "Ref": "AWS::Region" + }, + "AMI" + ] + }, + "InstanceType": { + "Ref": "InstanceType" + }, + "SecurityGroupIds": [ + { + "Ref": "AnythingLLMInstanceSecurityGroup" + } + ], + "BlockDeviceMappings": [ + { + "DeviceName": { + "Fn::FindInMap": [ + "Region2AMI", + { + "Ref": "AWS::Region" + }, + "RootDeviceName" + ] + }, + "Ebs": { + "VolumeSize": { + "Ref": "InstanceVolume" + } + } + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "Content-Type: multipart/mixed; boundary=\"//\"\n", + "MIME-Version: 1.0\n", + "\n", + "--//\n", + "Content-Type: text/cloud-config; charset=\"us-ascii\"\n", + "MIME-Version: 1.0\n", + "Content-Transfer-Encoding: 7bit\n", + "Content-Disposition: attachment; filename=\"cloud-config.txt\"\n", + "\n", + "\n", + "#cloud-config\n", + "cloud_final_modules:\n", + "- [scripts-user, once-per-instance]\n", + "\n", + "\n", + "--//\n", + "Content-Type: text/x-shellscript; charset=\"us-ascii\"\n", + "MIME-Version: 1.0\n", + "Content-Transfer-Encoding: 7bit\n", + "Content-Disposition: attachment; filename=\"userdata.txt\"\n", + "\n", + "\n", + "#!/bin/bash\n", + "# check output of userdata script with sudo tail -f /var/log/cloud-init-output.log\n", + "sudo yum install docker iptables -y\n", + "sudo iptables -A OUTPUT -m owner ! --uid-owner root -d 169.254.169.254 -j DROP\n", + "sudo systemctl enable docker\n", + "sudo systemctl start docker\n", + "mkdir -p /home/ec2-user/anythingllm\n", + "touch /home/ec2-user/anythingllm/.env\n", + "sudo chown ec2-user:ec2-user -R /home/ec2-user/anythingllm\n", + "docker pull mintplexlabs/anythingllm\n", + "docker run -d -p 3001:3001 --cap-add SYS_ADMIN -v /home/ec2-user/anythingllm:/app/server/storage -v /home/ec2-user/anythingllm/.env:/app/server/.env -e STORAGE_DIR=\"/app/server/storage\" mintplexlabs/anythingllm\n", + "echo \"Container ID: $(sudo docker ps --latest --quiet)\"\n", + "export ONLINE=$(curl -Is http://localhost:3001/api/ping | head -n 1|cut -d$' ' -f2)\n", + "echo \"Health check: $ONLINE\"\n", + "echo \"Setup complete! AnythingLLM instance is now online!\"\n", + "\n", + "--//--\n" + ] + ] + } + } + } + }, + "AnythingLLMInstanceSecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "AnythingLLM Instance Security Group", + "SecurityGroupIngress": [ + { + "IpProtocol": "tcp", + "FromPort": "22", + "ToPort": "22", + "CidrIp": "0.0.0.0/0" + }, + { + "IpProtocol": "tcp", + "FromPort": "3001", + "ToPort": "3001", + "CidrIp": "0.0.0.0/0" + }, + { + "IpProtocol": "tcp", + "FromPort": "3001", + "ToPort": "3001", + "CidrIpv6": "::/0" + } + ] + } + } + }, + "Outputs": { + "ServerIp": { + "Description": "IP address of the AnythingLLM instance", + "Value": { + "Fn::GetAtt": [ + "AnythingLLMInstance", + "PublicIp" + ] + } + }, + "ServerURL": { + "Description": "URL of the AnythingLLM server", + "Value": { + "Fn::Join": [ + "", + [ + "http://", + { + "Fn::GetAtt": [ + "AnythingLLMInstance", + "PublicIp" + ] + }, + ":3001" + ] + ] + } + } + }, + "Mappings": { + "Region2AMI": { + "ap-south-1": { + "AMI": "ami-0e6329e222e662a52", + "RootDeviceName": "/dev/xvda" + }, + "eu-north-1": { + "AMI": "ami-08c308b1bb265e927", + "RootDeviceName": "/dev/xvda" + }, + "eu-west-3": { + "AMI": "ami-069d1ea6bc64443f0", + "RootDeviceName": "/dev/xvda" + }, + "eu-west-2": { + "AMI": "ami-06a566ca43e14780d", + "RootDeviceName": "/dev/xvda" + }, + "eu-west-1": { + "AMI": "ami-0a8dc52684ee2fee2", + "RootDeviceName": "/dev/xvda" + }, + "ap-northeast-3": { + "AMI": "ami-0c8a89b455fae8513", + "RootDeviceName": "/dev/xvda" + }, + "ap-northeast-2": { + "AMI": "ami-0ff56409a6e8ea2a0", + "RootDeviceName": "/dev/xvda" + }, + "ap-northeast-1": { + "AMI": "ami-0ab0bbbd329f565e6", + "RootDeviceName": "/dev/xvda" + }, + "ca-central-1": { + "AMI": "ami-033c256a10931f206", + "RootDeviceName": "/dev/xvda" + }, + "sa-east-1": { + "AMI": "ami-0dabf4dab6b183eef", + "RootDeviceName": "/dev/xvda" + }, + "ap-southeast-1": { + "AMI": "ami-0dc5785603ad4ff54", + "RootDeviceName": "/dev/xvda" + }, + "ap-southeast-2": { + "AMI": "ami-0c5d61202c3b9c33e", + "RootDeviceName": "/dev/xvda" + }, + "eu-central-1": { + "AMI": "ami-004359656ecac6a95", + "RootDeviceName": "/dev/xvda" + }, + "us-east-1": { + "AMI": "ami-0cff7528ff583bf9a", + "RootDeviceName": "/dev/xvda" + }, + "us-east-2": { + "AMI": "ami-02238ac43d6385ab3", + "RootDeviceName": "/dev/xvda" + }, + "us-west-1": { + "AMI": "ami-01163e76c844a2129", + "RootDeviceName": "/dev/xvda" + }, + "us-west-2": { + "AMI": "ami-0ceecbb0f30a902a6", + "RootDeviceName": "/dev/xvda" + } + } + } +} \ No newline at end of file diff --git a/cloud-deployments/digitalocean/terraform/DEPLOY.md b/cloud-deployments/digitalocean/terraform/DEPLOY.md new file mode 100644 index 0000000..e63231a --- /dev/null +++ b/cloud-deployments/digitalocean/terraform/DEPLOY.md @@ -0,0 +1,44 @@ +# How to deploy a private AnythingLLM instance on DigitalOcean using Terraform + +With a DigitalOcean account, you can easily deploy a private AnythingLLM instance using Terraform. This will create a URL that you can access from any browser over HTTP (HTTPS not supported). This single instance will run on your own keys, and they will not be exposed. However, if you want your instance to be protected, it is highly recommended that you set a password once setup is complete. + +The output of this Terraform configuration will be: +- 1 DigitalOcean Droplet +- An IP address to access your application + +**Requirements** +- An DigitalOcean account with billing information +- Terraform installed on your local machine + - Follow the instructions in the [official Terraform documentation](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli) for your operating system. + +## How to deploy on DigitalOcean +Open your terminal and navigate to the `docker` folder +1. Create a `.env` file by cloning the `.env.example`. +2. Navigate to `digitalocean/terraform` folder. +3. Replace the token value in the provider "digitalocean" block in main.tf with your DigitalOcean API token. +4. Run the following commands to initialize Terraform, review the infrastructure changes, and apply them: + ``` + terraform init + terraform plan + terraform apply + ``` +Confirm the changes by typing yes when prompted. +5. Once the deployment is complete, Terraform will output the public IP address of your droplet. You can access your application using this IP address. + +## How to deploy on DigitalOcean +To delete the resources created by Terraform, run the following command in the terminal: +` +terraform destroy +` + +## Please read this notice before submitting issues about your deployment + +**Note:** +Your instance will not be available instantly. Depending on the instance size you launched with it can take anywhere from 5-10 minutes to fully boot up. + +If you want to check the instances progress, navigate to [your deployed instances](https://cloud.digitalocean.com/droplets) and connect to your instance via SSH in browser. + +Once connected run `sudo tail -f /var/log/cloud-init-output.log` and wait for the file to conclude deployment of the docker image. + + +Additionally, your use of this deployment process means you are responsible for any costs of these Digital Ocean resources fully. diff --git a/cloud-deployments/digitalocean/terraform/main.tf b/cloud-deployments/digitalocean/terraform/main.tf new file mode 100644 index 0000000..7a76f57 --- /dev/null +++ b/cloud-deployments/digitalocean/terraform/main.tf @@ -0,0 +1,52 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + } +} + +provider "digitalocean" { + # Add your DigitalOcean API token here + token = "DigitalOcean API token" +} + + +resource "digitalocean_droplet" "anything_llm_instance" { + image = "ubuntu-24-04-x64" + name = "anything-llm-instance" + region = "nyc3" + size = "s-2vcpu-2gb" + + user_data = templatefile("user_data.tp1", { + env_content = local.formatted_env_content + }) +} + +locals { + env_content = file("../../../docker/.env") + formatted_env_content = join("\n", [ + for line in split("\n", local.env_content) : + line + if !( + ( + substr(line, 0, 1) == "#" + ) || + ( + substr(line, 0, 3) == "UID" + ) || + ( + substr(line, 0, 3) == "GID" + ) || + ( + substr(line, 0, 11) == "CLOUD_BUILD" + ) || + ( + line == "" + ) + ) + ]) +} \ No newline at end of file diff --git a/cloud-deployments/digitalocean/terraform/outputs.tf b/cloud-deployments/digitalocean/terraform/outputs.tf new file mode 100644 index 0000000..2b6d559 --- /dev/null +++ b/cloud-deployments/digitalocean/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "ip_address" { + value = digitalocean_droplet.anything_llm_instance.ipv4_address + description = "The public IP address of your droplet application." +} \ No newline at end of file diff --git a/cloud-deployments/digitalocean/terraform/user_data.tp1 b/cloud-deployments/digitalocean/terraform/user_data.tp1 new file mode 100644 index 0000000..1853f69 --- /dev/null +++ b/cloud-deployments/digitalocean/terraform/user_data.tp1 @@ -0,0 +1,22 @@ +#!/bin/bash +# check output of userdata script with sudo tail -f /var/log/cloud-init-output.log + +sudo apt-get update +sudo apt-get install -y docker.io +sudo usermod -a -G docker ubuntu + +sudo systemctl enable docker +sudo systemctl start docker + +mkdir -p /home/anythingllm +cat </home/anythingllm/.env +${env_content} +EOF + +sudo docker pull mintplexlabs/anythingllm +sudo docker run -d -p 3001:3001 --cap-add SYS_ADMIN -v /home/anythingllm:/app/server/storage -v /home/anythingllm/.env:/app/server/.env -e STORAGE_DIR="/app/server/storage" mintplexlabs/anythingllm +echo "Container ID: $(sudo docker ps --latest --quiet)" + +export ONLINE=$(curl -Is http://localhost:3001/api/ping | head -n 1|cut -d$' ' -f2) +echo "Health check: $ONLINE" +echo "Setup complete! AnythingLLM instance is now online!" diff --git a/cloud-deployments/gcp/deployment/DEPLOY.md b/cloud-deployments/gcp/deployment/DEPLOY.md new file mode 100644 index 0000000..754309c --- /dev/null +++ b/cloud-deployments/gcp/deployment/DEPLOY.md @@ -0,0 +1,54 @@ +# How to deploy a private AnythingLLM instance on GCP + +With a GCP account you can easily deploy a private AnythingLLM instance on GCP. This will create a url that you can access from any browser over HTTP (HTTPS not supported). This single instance will run on your own keys and they will not be exposed - however if you want your instance to be protected it is highly recommend that you set a password once setup is complete. + +The output of this cloudformation stack will be: +- 1 GCP VM +- 1 Security Group with 0.0.0.0/0 access on Ports 22 & 3001 +- 1 GCP VM Volume `gb2` of 10Gib minimum + +**Requirements** +- An GCP account with billing information. + +## How to deploy on GCP +Open your terminal +1. Log in to your GCP account using the following command: + ``` + gcloud auth login + ``` + +2. After successful login, Run the following command to create a deployment using the Deployment Manager CLI: + + ``` + + gcloud deployment-manager deployments create anything-llm-deployment --config gcp/deployment/gcp_deploy_anything_llm.yaml + + ``` + +Once you execute these steps, the CLI will initiate the deployment process on GCP based on your configuration file. You can monitor the deployment status and view the outputs using the Google Cloud Console or the Deployment Manager CLI commands. + +``` +gcloud compute instances get-serial-port-output anything-llm-instance +``` + +ssh into the instance + +``` +gcloud compute ssh anything-llm-instance +``` + +Delete the deployment +``` +gcloud deployment-manager deployments delete anything-llm-deployment +``` + +## Please read this notice before submitting issues about your deployment + +**Note:** +Your instance will not be available instantly. Depending on the instance size you launched with it can take anywhere from 5-10 minutes to fully boot up. + +If you want to check the instances progress, navigate to [your deployed instances](https://console.cloud.google.com/compute/instances) and connect to your instance via SSH in browser. + +Once connected run `sudo tail -f /var/log/cloud-init-output.log` and wait for the file to conclude deployment of the docker image. + +Additionally, your use of this deployment process means you are responsible for any costs of these GCP resources fully. diff --git a/cloud-deployments/gcp/deployment/gcp_deploy_anything_llm.yaml b/cloud-deployments/gcp/deployment/gcp_deploy_anything_llm.yaml new file mode 100644 index 0000000..3f44d5a --- /dev/null +++ b/cloud-deployments/gcp/deployment/gcp_deploy_anything_llm.yaml @@ -0,0 +1,45 @@ +resources: + - name: anything-llm-instance + type: compute.v1.instance + properties: + zone: us-central1-a + machineType: zones/us-central1-a/machineTypes/n1-standard-1 + disks: + - deviceName: boot + type: PERSISTENT + boot: true + autoDelete: true + initializeParams: + sourceImage: projects/ubuntu-os-cloud/global/images/family/ubuntu-2004-lts + diskSizeGb: 10 + networkInterfaces: + - network: global/networks/default + accessConfigs: + - name: External NAT + type: ONE_TO_ONE_NAT + metadata: + items: + - key: startup-script + value: | + #!/bin/bash + # check output of userdata script with sudo tail -f /var/log/cloud-init-output.log + + sudo apt-get update + sudo apt-get install -y docker.io + sudo usermod -a -G docker ubuntu + sudo systemctl enable docker + sudo systemctl start docker + + mkdir -p /home/anythingllm + touch /home/anythingllm/.env + sudo chown -R ubuntu:ubuntu /home/anythingllm + + sudo docker pull mintplexlabs/anythingllm + sudo docker run -d -p 3001:3001 --cap-add SYS_ADMIN -v /home/anythingllm:/app/server/storage -v /home/anythingllm/.env:/app/server/.env -e STORAGE_DIR="/app/server/storage" mintplexlabs/anythingllm + echo "Container ID: $(sudo docker ps --latest --quiet)" + + export ONLINE=$(curl -Is http://localhost:3001/api/ping | head -n 1|cut -d$' ' -f2) + echo "Health check: $ONLINE" + + echo "Setup complete! AnythingLLM instance is now online!" + diff --git a/cloud-deployments/helm/charts/anythingllm/.helmignore b/cloud-deployments/helm/charts/anythingllm/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/cloud-deployments/helm/charts/anythingllm/Chart.yaml b/cloud-deployments/helm/charts/anythingllm/Chart.yaml new file mode 100644 index 0000000..8a55f31 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: anythingllm +description: The all-in-one Desktop & Docker AI application with built-in RAG, AI agents, No-code agent builder, MCP compatibility, and more. +type: application +version: 1.0.0 +appVersion: "1.85.0" +icon: https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/refs/heads/master/frontend/public/favicon.png \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/README.md b/cloud-deployments/helm/charts/anythingllm/README.md new file mode 100644 index 0000000..29c15e8 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/README.md @@ -0,0 +1,149 @@ +# anythingllm + +![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.85.0](https://img.shields.io/badge/AppVersion-1.85.0-informational?style=flat-square) + +![AnythingLLM](https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/wordmark.png) + +[AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) + +The all-in-one Desktop & Docker AI application with built-in RAG, AI agents, No-code agent builder, MCP compatibility, and more. + +**Configuration & Usage** + +- **Config vs Secrets:** This chart exposes application configuration via two mechanisms: + - `config` (in `values.yaml`) — rendered into a `ConfigMap` and injected using `envFrom` in the pod. Do NOT place sensitive values (API keys, secrets) in `config` because `ConfigMap`s are not encrypted. + - `env` / `envFrom` — the preferred way to inject secrets. Use Kubernetes `Secret` objects and reference them from `env` (with `valueFrom.secretKeyRef`) or `envFrom.secretRef`. + +- **Storage & STORAGE_DIR mapping:** The chart creates (or mounts) a `PersistentVolumeClaim` using the `persistentVolume.*` settings. The container mount path is set from `persistentVolume.mountPath`. Ensure the container `STORAGE_DIR` config key matches that path (defaults are set in `values.yaml`). + +**Providing API keys & secrets (recommended)** + +Use Kubernetes Secrets. Below are example workflows and `values.yaml` snippets. + +1) Create a Kubernetes Secret with API keys: + +``` +kubectl create secret generic openai-secret --from-literal=OPENAI_KEY="sk-..." +# or from a file +# kubectl create secret generic openai-secret --from-file=OPENAI_KEY=/path/to/keyfile +``` + +2) Reference the Secret from `values.yaml` using `envFrom` (recommended when your secret contains multiple env keys): + +```yaml +envFrom: + - secretRef: + name: openai-secret +``` + +This will inject all key/value pairs from the `openai-secret` Secret as environment variables in the container. + +3) Or reference a single secret key via `env` (explicit mapping): + +```yaml +env: + - name: OPENAI_KEY + valueFrom: + secretKeyRef: + name: openai-secret + key: OPENAI_KEY +``` + +Notes: +- Avoid placing secret values into `config:` (the chart's `ConfigMap`) — `ConfigMap`s are visible to anyone who can read the namespace. Use `Secret` objects for any credentials/tokens. +- If you use a GitOps workflow, consider integrating an external secret operator (ExternalSecrets, SealedSecrets, etc.) so you don't store raw secrets in Git. + +**Example `values-secret.yaml` to pass during `helm install`** + +```yaml +image: + repository: mintplexlabs/anythingllm + tag: "1.15.0" + +service: + type: ClusterIP + port: 3001 + +# Reference secret containing API keys +envFrom: + - secretRef: + name: openai-secret + +# Optionally override other values +persistentVolume: + size: 16Gi + mountPath: /storage +``` + +Install with: + +``` +helm install my-anythingllm ./anythingllm -f values-secret.yaml +``` + +**Best practices & tips** + +- Use `envFrom` for convenience when many environment variables are stored in a single `Secret` and use `env`/`valueFrom` for explicit single-key mappings. +- Use `kubectl create secret generic` or your secrets management solution. If you need to reference multiple different provider keys (OpenAI, Anthropic, etc.), create a single `Secret` with multiple keys or multiple Secrets and add multiple `envFrom` entries. +- Keep probe paths and `service.port` aligned. If your probes fail after deployment, check that the probe `port` matches the container port (or named port `http`) and that the `path` is valid. +- For storage, if you have a pre-existing PVC set `persistentVolume.existingClaim` to the PVC name; the chart will mount that claim (and will not attempt to create a new PVC). +- For production, provide resource `requests` and `limits` in `values.yaml` to prevent scheduler starvation and to control cost. + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| config.DISABLE_TELEMETRY | string | `"true"` | | +| config.GID | string | `"1000"` | | +| config.NODE_ENV | string | `"production"` | | +| config.STORAGE_DIR | string | `"/storage"` | | +| config.UID | string | `"1000"` | | +| env | object | `{}` | | +| envFrom | object | `{}` | | +| fullnameOverride | string | `""` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.repository | string | `"mintplexlabs/anythingllm"` | | +| image.tag | string | `"1.15.0"` | | +| imagePullSecrets | list | `[]` | | +| ingress.annotations | object | `{}` | | +| ingress.className | string | `""` | | +| ingress.enabled | bool | `false` | | +| ingress.hosts[0].host | string | `"chart-example.local"` | | +| ingress.hosts[0].paths[0].path | string | `"/"` | | +| ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | +| ingress.tls | list | `[]` | | +| initContainers | list | `[]` | | +| livenessProbe.failureThreshold | int | `3` | | +| livenessProbe.httpGet.path | string | `"/v1/api/health"` | | +| livenessProbe.httpGet.port | int | `8888` | | +| livenessProbe.initialDelaySeconds | int | `15` | | +| livenessProbe.periodSeconds | int | `5` | | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| persistentVolume.accessModes[0] | string | `"ReadWriteOnce"` | | +| persistentVolume.annotations | object | `{}` | | +| persistentVolume.existingClaim | string | `""` | | +| persistentVolume.labels | object | `{}` | | +| persistentVolume.mountPath | string | `"/storage"` | | +| persistentVolume.size | string | `"8Gi"` | | +| podAnnotations | object | `{}` | | +| podLabels | object | `{}` | | +| podSecurityContext.fsGroup | int | `1000` | | +| readinessProbe.httpGet.path | string | `"/v1/api/health"` | | +| readinessProbe.httpGet.port | int | `8888` | | +| readinessProbe.initialDelaySeconds | int | `15` | | +| readinessProbe.periodSeconds | int | `5` | | +| readinessProbe.successThreshold | int | `2` | | +| replicaCount | int | `1` | | +| resources | object | `{}` | | +| securityContext | object | `{}` | | +| service.port | int | `3001` | | +| service.type | string | `"ClusterIP"` | | +| serviceAccount.annotations | object | `{}` | | +| serviceAccount.automount | bool | `true` | | +| serviceAccount.create | bool | `true` | | +| serviceAccount.name | string | `""` | | +| tolerations | list | `[]` | | +| volumeMounts | list | `[]` | | +| volumes | list | `[]` | | \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl b/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl new file mode 100644 index 0000000..a1c83a6 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl @@ -0,0 +1,103 @@ +{{ template "chart.header" . }} +{{ template "chart.deprecationWarning" . }} + +{{ template "chart.badgesSection" . }} + +![AnythingLLM](https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/wordmark.png) + +[AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) + +{{ template "chart.description" . }} + +{{ template "chart.homepageLine" . }} + +{{ template "chart.maintainersSection" . }} + +{{ template "chart.sourcesSection" . }} + +{{ template "chart.requirementsSection" . }} + +**Configuration & Usage** + +- **Config vs Secrets:** This chart exposes application configuration via two mechanisms: + - `config` (in `values.yaml`) — rendered into a `ConfigMap` and injected using `envFrom` in the pod. Do NOT place sensitive values (API keys, secrets) in `config` because `ConfigMap`s are not encrypted. + - `env` / `envFrom` — the preferred way to inject secrets. Use Kubernetes `Secret` objects and reference them from `env` (with `valueFrom.secretKeyRef`) or `envFrom.secretRef`. + +- **Storage & STORAGE_DIR mapping:** The chart creates (or mounts) a `PersistentVolumeClaim` using the `persistentVolume.*` settings. The container mount path is set from `persistentVolume.mountPath`. Ensure the container `STORAGE_DIR` config key matches that path (defaults are set in `values.yaml`). + + +**Providing API keys & secrets (recommended)** + +Use Kubernetes Secrets. Below are example workflows and `values.yaml` snippets. + +1) Create a Kubernetes Secret with API keys: + +``` +kubectl create secret generic openai-secret --from-literal=OPENAI_KEY="sk-..." +# or from a file +# kubectl create secret generic openai-secret --from-file=OPENAI_KEY=/path/to/keyfile +``` + +2) Reference the Secret from `values.yaml` using `envFrom` (recommended when your secret contains multiple env keys): + +```yaml +envFrom: + - secretRef: + name: openai-secret +``` + +This will inject all key/value pairs from the `openai-secret` Secret as environment variables in the container. + +3) Or reference a single secret key via `env` (explicit mapping): + +```yaml +env: + - name: OPENAI_KEY + valueFrom: + secretKeyRef: + name: openai-secret + key: OPENAI_KEY +``` + +Notes: +- Avoid placing secret values into `config:` (the chart's `ConfigMap`) — `ConfigMap`s are visible to anyone who can read the namespace. Use `Secret` objects for any credentials/tokens. +- If you use a GitOps workflow, consider integrating an external secret operator (ExternalSecrets, SealedSecrets, etc.) so you don't store raw secrets in Git. + + +**Example `values-secret.yaml` to pass during `helm install`** + +```yaml +image: + repository: mintplexlabs/anythingllm + tag: "1.15.0" + +service: + type: ClusterIP + port: 3001 + +# Reference secret containing API keys +envFrom: + - secretRef: + name: openai-secret + +# Optionally override other values +persistentVolume: + size: 16Gi + mountPath: /storage +``` + +Install with: + +``` +helm install my-anythingllm ./anythingllm -f values-secret.yaml +``` + +**Best practices & tips** + +- Use `envFrom` for convenience when many environment variables are stored in a single `Secret` and use `env`/`valueFrom` for explicit single-key mappings. +- Use `kubectl create secret generic` or your secrets management solution. If you need to reference multiple different provider keys (OpenAI, Anthropic, etc.), create a single `Secret` with multiple keys or multiple Secrets and add multiple `envFrom` entries. +- Keep probe paths and `service.port` aligned. If your probes fail after deployment, check that the probe `port` matches the container port (or named port `http`) and that the `path` is valid. +- For storage, if you have a pre-existing PVC set `persistentVolume.existingClaim` to the PVC name; the chart will mount that claim (and will not attempt to create a new PVC). +- For production, provide resource `requests` and `limits` in `values.yaml` to prevent scheduler starvation and to control cost. + +{{ template "chart.valuesSection" . }} \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/templates/NOTES.txt b/cloud-deployments/helm/charts/anythingllm/templates/NOTES.txt new file mode 100644 index 0000000..f3530a3 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/NOTES.txt @@ -0,0 +1,28 @@ +1. Get the application URL by running these commands: + +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} + +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "anythingllm.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT + +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "anythingllm.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "anythingllm.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} + +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "anythingllm.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "To access locally, run:" + echo " kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT" + echo "Then visit http://127.0.0.1:8080" + +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/_helpers.tpl b/cloud-deployments/helm/charts/anythingllm/templates/_helpers.tpl new file mode 100644 index 0000000..5db3e92 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "anythingllm.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "anythingllm.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "anythingllm.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "anythingllm.labels" -}} +helm.sh/chart: {{ include "anythingllm.chart" . }} +{{ include "anythingllm.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "anythingllm.selectorLabels" -}} +app.kubernetes.io/name: {{ include "anythingllm.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "anythingllm.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "anythingllm.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/configmap.yaml b/cloud-deployments/helm/charts/anythingllm/templates/configmap.yaml new file mode 100644 index 0000000..cc4a9d6 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + name: {{ include "anythingllm.fullname" . }}-config +data: +{{- range $key, $value := .Values.config }} + {{ $key }}: "{{ $value }}" +{{- end }} \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml b/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml new file mode 100644 index 0000000..6a9e0b7 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml @@ -0,0 +1,83 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "anythingllm.fullname" . }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "anythingllm.selectorLabels" . | nindent 6 }} + {{- with .Values.strategy }} + strategy: + {{- toYaml . | nindent 8 }} + {{- end }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "anythingllm.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "anythingllm.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.initContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "anythingllm.fullname" . }}-config + {{- with .Values.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: storage + mountPath: {{ .Values.persistentVolume.mountPath }} + volumes: + - name: storage + persistentVolumeClaim: + claimName: {{ include "anythingllm.fullname" . }}-storage-claim + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/extra-objects.yaml b/cloud-deployments/helm/charts/anythingllm/templates/extra-objects.yaml new file mode 100644 index 0000000..a9bb3b6 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/extra-objects.yaml @@ -0,0 +1,4 @@ +{{ range .Values.extraObjects }} +--- +{{ tpl (toYaml .) $ }} +{{ end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml b/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml new file mode 100644 index 0000000..ca3b3b2 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml @@ -0,0 +1,38 @@ +{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include "anythingllm.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- with .Values.httpRoute.parentRefs }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + {{- with .matches }} + - matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- end }} +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/ingress.yaml b/cloud-deployments/helm/charts/anythingllm/templates/ingress.yaml new file mode 100644 index 0000000..5377d79 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "anythingllm.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/pvc.yaml b/cloud-deployments/helm/charts/anythingllm/templates/pvc.yaml new file mode 100644 index 0000000..973d6a9 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/pvc.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + {{- if .Values.persistentVolume.annotations }} + annotations: +{{ toYaml .Values.persistentVolume.annotations | indent 4 }} + {{- end }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + {{- with .Values.persistentVolume.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + name: {{ include "anythingllm.fullname" . }}-storage-claim +spec: + accessModes: + {{- toYaml .Values.persistentVolume.accessModes | nindent 4 }} +{{- if .Values.persistentVolume.storageClass }} +{{- if (eq "-" .Values.persistentVolume.storageClass) }} + storageClassName: "" +{{- else }} + storageClassName: "{{ .Values.persistentVolume.storageClass }}" +{{- end }} +{{- end }} + resources: + requests: + storage: {{ .Values.persistentVolume.size }} +{{- if .Values.persistentVolume.volumeName }} + volumeName: "{{ .Values.persistentVolume.volumeName }}" +{{- end -}} + {{- with .Values.persistentVolume.selector }} + selector: + {{- toYaml . | nindent 4 }} + {{- end }} \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/templates/service.yaml b/cloud-deployments/helm/charts/anythingllm/templates/service.yaml new file mode 100644 index 0000000..b795b19 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "anythingllm.fullname" . }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "anythingllm.selectorLabels" . | nindent 4 }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/serviceaccount.yaml b/cloud-deployments/helm/charts/anythingllm/templates/serviceaccount.yaml new file mode 100644 index 0000000..ef02bb4 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "anythingllm.serviceAccountName" . }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/templates/tests/test-connection.yaml b/cloud-deployments/helm/charts/anythingllm/templates/tests/test-connection.yaml new file mode 100644 index 0000000..1120792 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/tests/test-connection.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "anythingllm.fullname" . }}-test-connection" + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: healthcheck + image: curlimages/curl:8.1.2 + command: ["sh", "-c"] + args: + - "curl -fsS -o /dev/null http://{{ include "anythingllm.fullname" . }}:{{ .Values.service.port }}|| exit 1" + restartPolicy: Never \ No newline at end of file diff --git a/cloud-deployments/helm/charts/anythingllm/values.yaml b/cloud-deployments/helm/charts/anythingllm/values.yaml new file mode 100644 index 0000000..27a8a01 --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/values.yaml @@ -0,0 +1,269 @@ +replicaCount: 1 + +initContainers: [] + # - name: init-myservice + # image: busybox + # command: ['sh', '-c', 'chown -R 1000:1000 /storage'] + +image: + repository: mintplexlabs/anythingllm + pullPolicy: IfNotPresent + tag: "1.15.0" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +persistentVolume: + # AnythingLLM storage data Persistent Volume access modes + # Must match those of existing PV or dynamic provisioner + # Ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ + # + accessModes: + - ReadWriteOnce + + # AnythingLLM storage data Persistent Volume labels + # + labels: {} + + # AnythingLLM storage data Persistent Volume annotations + # + annotations: {} + + # AnythingLLM storage data Persistent Volume existing claim name + # If defined, PVC must be created manually before volume will be bound + # + existingClaim: "" + + # AnythingLLM storage data Persistent Volume size + # + size: 8Gi + + # AnythingLLM storage data Persistent Volume mount path + # Must match the STORAGE_DIR config value + # + mountPath: /app/server/storage + + # AnythingLLM storage data Persistent Volume Storage Class + # If defined, storageClassName: + # If set to "-", storageClassName: "", which disables dynamic provisioning + # If undefined (the default) or set to null, no storageClassName spec is + # set, choosing the default provisioner. (gp2 on AWS, standard on + # GKE, AWS & OpenStack) + # + storageClass: "" + + # AnythingLLM storage data Persistent Volume Claim Selector + # Useful if Persistent Volumes have been provisioned in advance + # Ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/#selector + # + selector: {} + # selector: + # matchLabels: + # release: "stable" + # matchExpressions: + # - { key: environment, operator: In, values: [ dev ] } + + # AnythingLLM storage data Persistent Volume Name + # Useful if Persistent Volumes have been provisioned in advance and you want to use a specific one + # + volumeName: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# The Anything LLM application deployment strategy +# This is set to "Recreate" by default as AnythingLLM support is not yet +# production ready. Once it is, this can be changed to "RollingUpdate" +# Ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy +# +strategy: + # Type of deployment. Can be "Recreate" or "RollingUpdate". Default is "Recreate" + type: Recreate + # If type is "RollingUpdate", the following values can be set: + # rollingUpdate: + # maxUnavailable: 1 + # maxSurge: 1 + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + # fsGroup needs to be set as the same as the uid/gid used to run the application + # in order to have the right permissions on mounted volumes + fsGroup: 1000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# AnythingLLM configuration options, these are stored in a ConfigMap and passed +# to the container as environment variables. +# See https://github.com/Mintplex-Labs/anything-llm/blob/render/docker/.env.example +# for all available environment variables to use as configuration options +# +config: + DISABLE_TELEMETRY: "true" + NODE_ENV: production + STORAGE_DIR: /app/server/storage + UID: "1000" + GID: "1000" + +# The preferred method for setting secret environment variables +# Ref: https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/#define-a-container-environment-variable-with-data-from-a-single-secret +# +env: {} +# - name: OPEN_AI_KEY +# valueFrom: +# secretKeyRef: +# name: openai-secret +# key: openai_key + +# Typically used to reference a pre existing Secret containing multiple environment variables +# Ref: https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/#define-a-container-environment-variable-with-data-from-a-single-secret +# +envFrom: {} + # - secretRef: + # name: mysecret + +service: + type: ClusterIP + port: 3001 + +# -- Expose the service via gateway-api HTTPRoute +# Requires Gateway API resources and suitable controller installed within the cluster +# (see: https://gateway-api.sigs.k8s.io/guides/) +httpRoute: + # HTTPRoute enabled. + enabled: false + # HTTPRoute annotations. + annotations: {} + # Which Gateways this Route is attached to. + parentRefs: + - name: gateway + sectionName: http + # namespace: default + # Hostnames matching HTTP header. + hostnames: + - chart-example.local + # List of rules and filters applied. + rules: + - matches: + - path: + type: PathPrefix + value: /headers + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # set: + # - name: My-Overwrite-Header + # value: this-is-the-only-value + # remove: + # - User-Agent + # - matches: + # - path: + # type: PathPrefix + # value: /echo + # headers: + # - name: version + # value: v2 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +readinessProbe: + httpGet: + path: /v1/api/health + port: 8888 + initialDelaySeconds: 15 + periodSeconds: 5 + successThreshold: 2 +livenessProbe: + httpGet: + path: /v1/api/health + port: 8888 + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 3 + +# Additional volumes on the output Deployment definition. +# +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +# +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +## Array of extra manifests/obhects to create +# +extraObjects: [] +# - apiVersion: external-secrets.io/v1beta1 +# kind: ExternalSecret +# metadata: +# name: open-ai-api-key-external-secret +# namespace: default +# spec: +# refreshInterval: 1h +# secretStoreRef: +# name: vault +# kind: ClusterSecretStore +# target: +# name: open-ai-api-key-secret +# template: +# type: Opaque +# data: +# - secretKey: open_ai_key +# remoteRef: +# key: secret/data/anything-llm +# property: open_ai_key + diff --git a/cloud-deployments/huggingface-spaces/Dockerfile b/cloud-deployments/huggingface-spaces/Dockerfile new file mode 100644 index 0000000..def87bf --- /dev/null +++ b/cloud-deployments/huggingface-spaces/Dockerfile @@ -0,0 +1,31 @@ +# With this dockerfile in a Huggingface space you will get an entire AnythingLLM instance running +# in your space with all features you would normally get from the docker based version of AnythingLLM. +# +# How to use +# - Login to https://huggingface.co/spaces +# - Click on "Create new Space" +# - Name the space and select "Docker" as the SDK w/ a blank template +# - The default 2vCPU/16GB machine is OK. The more the merrier. +# - Decide if you want your AnythingLLM Space public or private. +# **You might want to stay private until you at least set a password or enable multi-user mode** +# - Click "Create Space" +# - Click on "Settings" on top of page (https://huggingface.co/spaces///settings) +# - Scroll to "Persistent Storage" and select the lowest tier of now - you can upgrade if you run out. +# - Confirm and continue storage upgrade +# - Go to "Files" Tab (https://huggingface.co/spaces///tree/main) +# - Click "Add Files" +# - Upload this file or create a file named `Dockerfile` and copy-paste this content into it. "Commit to main" and save. +# - Your container will build and boot. You now have AnythingLLM on HuggingFace. Your data is stored in the persistent storage attached. +# Have Fun 🤗 +# Have issues? Check the logs on HuggingFace for clues. +FROM mintplexlabs/anythingllm:render + +USER root +RUN mkdir -p /data/storage +RUN ln -s /data/storage /storage +USER anythingllm + +ENV STORAGE_DIR="/data/storage" +ENV SERVER_PORT=7860 + +ENTRYPOINT ["/bin/bash", "/usr/local/bin/render-entrypoint.sh"] \ No newline at end of file diff --git a/cloud-deployments/k8/manifest.yaml b/cloud-deployments/k8/manifest.yaml new file mode 100644 index 0000000..9aeef6a --- /dev/null +++ b/cloud-deployments/k8/manifest.yaml @@ -0,0 +1,214 @@ +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: anything-llm-volume + annotations: + pv.beta.kubernetes.io/uid: "1000" + pv.beta.kubernetes.io/gid: "1000" +spec: + storageClassName: gp2 + capacity: + storage: 5Gi + accessModes: + - ReadWriteOnce + awsElasticBlockStore: + # This is the volume UUID from AWS EC2 EBS Volumes list. + volumeID: "{{ anythingllm_awsElasticBlockStore_volumeID }}" + fsType: ext4 + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: topology.kubernetes.io/zone + operator: In + values: + - us-east-1c +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: anything-llm-volume-claim + namespace: "{{ namespace }}" +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anything-llm + namespace: "{{ namespace }}" + labels: + anything-llm: "true" +spec: + selector: + matchLabels: + k8s-app: anything-llm + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0% + maxUnavailable: 100% + template: + metadata: + labels: + anything-llm: "true" + k8s-app: anything-llm + app.kubernetes.io/name: anything-llm + app.kubernetes.io/part-of: anything-llm + annotations: + prometheus.io/scrape: "true" + prometheus.io/path: /metrics + prometheus.io/port: "9090" + spec: + serviceAccountName: "default" + terminationGracePeriodSeconds: 10 + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: topology.kubernetes.io/zone + operator: In + values: + - us-east-1c + containers: + - name: anything-llm + resources: + limits: + memory: "1Gi" + cpu: "500m" + requests: + memory: "512Mi" + cpu: "250m" + imagePullPolicy: IfNotPresent + image: "mintplexlabs/anythingllm:render" + securityContext: + allowPrivilegeEscalation: true + capabilities: + add: + - SYS_ADMIN + runAsNonRoot: true + runAsGroup: 1000 + runAsUser: 1000 + command: + # Specify a command to override the Dockerfile's ENTRYPOINT. + - /bin/bash + - -c + - | + set -x -e + sleep 3 + echo "AWS_REGION: $AWS_REGION" + echo "SERVER_PORT: $SERVER_PORT" + echo "NODE_ENV: $NODE_ENV" + echo "STORAGE_DIR: $STORAGE_DIR" + { + cd /app/server/ && + npx prisma generate --schema=./prisma/schema.prisma && + npx prisma migrate deploy --schema=./prisma/schema.prisma && + node /app/server/index.js + echo "Server process exited with status $?" + } & + { + node /app/collector/index.js + echo "Collector process exited with status $?" + } & + wait -n + exit $? + readinessProbe: + httpGet: + path: /v1/api/health + port: 8888 + initialDelaySeconds: 15 + periodSeconds: 5 + successThreshold: 2 + livenessProbe: + httpGet: + path: /v1/api/health + port: 8888 + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 3 + env: + - name: AWS_REGION + value: "{{ aws_region }}" + - name: AWS_ACCESS_KEY_ID + value: "{{ aws_access_id }}" + - name: AWS_SECRET_ACCESS_KEY + value: "{{ aws_access_secret }}" + - name: SERVER_PORT + value: "3001" + - name: JWT_SECRET + value: "my-random-string-for-seeding" # Please generate random string at least 12 chars long. + - name: STORAGE_DIR + value: "/storage" + - name: NODE_ENV + value: "production" + - name: UID + value: "1000" + - name: GID + value: "1000" + volumeMounts: + - name: anything-llm-server-storage-volume-mount + mountPath: /storage + volumes: + - name: anything-llm-server-storage-volume-mount + persistentVolumeClaim: + claimName: anything-llm-volume-claim +--- +# This serves the UI and the backend. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: anything-llm-ingress + namespace: "{{ namespace }}" + annotations: + external-dns.alpha.kubernetes.io/hostname: "{{ namespace }}-chat.{{ base_domain }}" + kubernetes.io/ingress.class: "internal-ingress" + nginx.ingress.kubernetes.io/rewrite-target: / + ingress.kubernetes.io/ssl-redirect: "false" +spec: + rules: + - host: "{{ namespace }}-chat.{{ base_domain }}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: anything-llm-svc + port: + number: 3001 + tls: # < placing a host in the TLS config will indicate a cert should be created + - hosts: + - "{{ namespace }}-chat.{{ base_domain }}" + secretName: letsencrypt-prod +--- +apiVersion: v1 +kind: Service +metadata: + labels: + kubernetes.io/name: anything-llm + name: anything-llm-svc + namespace: "{{ namespace }}" +spec: + ports: + # "port" is external port, and "targetPort" is internal. + - port: 3301 + targetPort: 3001 + name: traffic + - port: 9090 + targetPort: 9090 + name: metrics + selector: + k8s-app: anything-llm \ No newline at end of file diff --git a/cloud-deployments/openshift/Dockerfile b/cloud-deployments/openshift/Dockerfile new file mode 100644 index 0000000..7768693 --- /dev/null +++ b/cloud-deployments/openshift/Dockerfile @@ -0,0 +1,225 @@ +# OpenShift-compatible Dockerfile for AnythingLLM +# +# This Dockerfile is specifically designed for OpenShift deployments which use +# arbitrary UIDs with GID 0 (root group). Do NOT use this for standard Docker +# or Docker Compose deployments - use the main docker/Dockerfile instead. +# +# Key differences from the standard Dockerfile: +# - User is added to supplementary group 0 (root) for OpenShift compatibility +# - All files are owned by group 0 and are group-writable (chmod g+w) +# - /etc/passwd is made group-writable for dynamic UID injection +# - Uses a modified entrypoint that handles arbitrary UID scenarios + +# Setup base image +FROM ubuntu:noble-20251013 AS base + +# Build arguments +ARG ARG_UID=1000 +ARG ARG_GID=1000 + +FROM base AS build-arm64 +RUN echo "Preparing build of AnythingLLM image for arm64 architecture" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install system dependencies +# hadolint ignore=DL3008,DL3013 +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + unzip curl gnupg libgfortran5 libgbm1 tzdata netcat-openbsd \ + libasound2t64 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ + libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ + libxss1 libxtst6 ca-certificates fonts-liberation libappindicator3-1 libnss3 lsb-release \ + xdg-utils git build-essential ffmpeg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + # Install node and yarn + apt-get install -yq --no-install-recommends nodejs && \ + curl -LO https://github.com/yarnpkg/yarn/releases/download/v1.22.19/yarn_1.22.19_all.deb \ + && dpkg -i yarn_1.22.19_all.deb \ + && rm yarn_1.22.19_all.deb && \ + # Install uvx (pinned to 0.6.10) for MCP support + curl -LsSf https://astral.sh/uv/0.6.10/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + echo "Installed uvx! $(uv --version)" && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create a group and user with specific UID and GID. +# The user's primary group is ARG_GID (default 1000) for Docker/Compose backward compatibility. +# The user is also added to the root group (GID 0) as a supplementary group so that +# OpenShift's arbitrary-UID-with-GID-0 model works via group-writable permissions. +RUN (getent passwd "$ARG_UID" && userdel -f "$(getent passwd "$ARG_UID" | cut -d: -f1)") || true && \ + if [ "$ARG_GID" != "0" ]; then \ + (getent group "$ARG_GID" && groupdel "$(getent group "$ARG_GID" | cut -d: -f1)") || true && \ + groupadd -g "$ARG_GID" anythingllm && \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g anythingllm -G 0 anythingllm; \ + else \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g 0 anythingllm; \ + fi && \ + mkdir -p /app/frontend/ /app/server/ /app/collector/ /app/.cache /app/.yarn && \ + chown -R anythingllm:0 /app && \ + chmod -R g+w /app && \ + chmod g=u /etc/passwd + +# Copy docker helper scripts (use OpenShift-specific entrypoint) +COPY ./cloud-deployments/openshift/docker-entrypoint.sh /usr/local/bin/ +COPY ./docker/docker-healthcheck.sh /usr/local/bin/ +COPY --chown=anythingllm:0 ./docker/.env.example /app/server/.env + +# Ensure the scripts are executable +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-healthcheck.sh + +USER anythingllm +WORKDIR /app + +# Puppeteer does not ship with an ARM86 compatible build for Chromium +# so web-scraping would be broken in arm docker containers unless we patch it +# by manually installing a compatible chromedriver. +RUN echo "Need to patch Puppeteer x Chromium support for ARM86 - installing dep!" && \ + curl -fSL https://webassets.anythingllm.com/chromium-1088-linux-arm64.zip -o chrome-linux.zip && \ + unzip chrome-linux.zip && \ + rm -rf chrome-linux.zip + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV CHROME_PATH=/app/chrome-linux/chrome +ENV PUPPETEER_EXECUTABLE_PATH=/app/chrome-linux/chrome + +RUN echo "Done running arm64 specific installation steps" + +############################################# + +# amd64-specific stage +FROM base AS build-amd64 +RUN echo "Preparing build of AnythingLLM image for non-ARM architecture" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install system dependencies +# hadolint ignore=DL3008,DL3013 +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + curl gnupg libgfortran5 libgbm1 tzdata netcat-openbsd \ + libasound2t64 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ + libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ + libxss1 libxtst6 ca-certificates fonts-liberation libappindicator3-1 libnss3 lsb-release \ + xdg-utils git build-essential ffmpeg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + # Install node and yarn + apt-get install -yq --no-install-recommends nodejs && \ + curl -LO https://github.com/yarnpkg/yarn/releases/download/v1.22.19/yarn_1.22.19_all.deb \ + && dpkg -i yarn_1.22.19_all.deb \ + && rm yarn_1.22.19_all.deb && \ + # Install uvx (pinned to 0.6.10) for MCP support + curl -LsSf https://astral.sh/uv/0.6.10/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + echo "Installed uvx! $(uv --version)" && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create a group and user with specific UID and GID. +# The user's primary group is ARG_GID (default 1000) for Docker/Compose backward compatibility. +# The user is also added to the root group (GID 0) as a supplementary group so that +# OpenShift's arbitrary-UID-with-GID-0 model works via group-writable permissions. +RUN (getent passwd "$ARG_UID" && userdel -f "$(getent passwd "$ARG_UID" | cut -d: -f1)") || true && \ + if [ "$ARG_GID" != "0" ]; then \ + (getent group "$ARG_GID" && groupdel "$(getent group "$ARG_GID" | cut -d: -f1)") || true && \ + groupadd -g "$ARG_GID" anythingllm && \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g anythingllm -G 0 anythingllm; \ + else \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g 0 anythingllm; \ + fi && \ + mkdir -p /app/frontend/ /app/server/ /app/collector/ /app/.cache /app/.yarn && \ + chown -R anythingllm:0 /app && \ + chmod -R g+w /app && \ + chmod g=u /etc/passwd + +# Copy docker helper scripts (use OpenShift-specific entrypoint) +COPY ./cloud-deployments/openshift/docker-entrypoint.sh /usr/local/bin/ +COPY ./docker/docker-healthcheck.sh /usr/local/bin/ +COPY --chown=anythingllm:0 ./docker/.env.example /app/server/.env + +# Ensure the scripts are executable +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-healthcheck.sh + +############################################# +# COMMON BUILD FLOW FOR ALL ARCHS +############################################# + +# hadolint ignore=DL3006 +FROM build-${TARGETARCH} AS build +RUN echo "Running common build flow of AnythingLLM image for all architectures" + +USER anythingllm +WORKDIR /app + +# Install & Build frontend layer +# Use BUILDPLATFORM to run on the native host architecture (not emulated). +# This avoids esbuild crashing under QEMU when cross-compiling. +# The output (static HTML/CSS/JS) is platform-independent. +FROM --platform=$BUILDPLATFORM node:18-slim AS frontend-build +WORKDIR /app/frontend +COPY ./frontend/package.json ./frontend/yarn.lock ./ +RUN yarn install --network-timeout 100000 && yarn cache clean +COPY ./frontend/ ./ +RUN yarn build +WORKDIR /app + +# Install server layer +# Also pull and build collector deps (chromium issues prevent bad bindings) +FROM build AS backend-build +COPY --chown=anythingllm:0 ./server /app/server/ +WORKDIR /app/server +RUN yarn install --production --network-timeout 100000 && yarn cache clean +WORKDIR /app + +# Install collector dependencies +COPY --chown=anythingllm:0 ./collector/ ./collector/ +WORKDIR /app/collector +ENV PUPPETEER_DOWNLOAD_BASE_URL=https://storage.googleapis.com/chrome-for-testing-public +RUN yarn install --production --network-timeout 100000 && yarn cache clean + +WORKDIR /app +USER anythingllm + +# Since we are building from backend-build we just need to move built frontend into server/public +FROM backend-build AS production-build +WORKDIR /app +COPY --chown=anythingllm:0 --from=frontend-build /app/frontend/dist /app/server/public + +# Ensure all app files are owned by group 0 (root) and group-writable so that: +# - Docker/Compose: access works via user ownership (UID match) +# - OpenShift: access works via group ownership (GID 0 match + g+w) +# - Generic Kubernetes: access works via user ownership or fsGroup +# This must run after all COPY and yarn install steps to cover node_modules, etc. +USER root +RUN chown -R anythingllm:0 /app && \ + chmod -R g+w /app && \ + mkdir -p /app/server/storage + +# Setup the environment +ENV NODE_ENV=production +ENV ANYTHING_LLM_RUNTIME=docker +ENV DEPLOYMENT_VERSION=1.15.0 +ENV HOME=/app + +# Setup the healthcheck +HEALTHCHECK --interval=1m --timeout=10s --start-period=1m \ + CMD /bin/bash /usr/local/bin/docker-healthcheck.sh || exit 1 + +USER anythingllm + +# Run the server +# CMD ["sh", "-c", "tail -f /dev/null"] # For development: keep container open +ENTRYPOINT ["/bin/bash", "/usr/local/bin/docker-entrypoint.sh"] diff --git a/cloud-deployments/openshift/README.md b/cloud-deployments/openshift/README.md new file mode 100644 index 0000000..b0be768 --- /dev/null +++ b/cloud-deployments/openshift/README.md @@ -0,0 +1,146 @@ +> [!IMPORTANT] +> This is a community-maintained template and is not officially supported by the AnythingLLM team. You could encounter issues or even deployment failures in future versions of AnythingLLM. We do our best to keep this template and all community contributions backwards compatible, but we cannot guarantee it. + +# OpenShift Deployment Template for AnythingLLM + +This directory contains a specialized Dockerfile and entrypoint script for deploying AnythingLLM on **Red Hat OpenShift** clusters. + +## Why This Template Exists + +OpenShift has a unique security model that differs from standard Docker/Kubernetes deployments: + +1. **Arbitrary UIDs**: OpenShift runs containers with randomly assigned user IDs (UIDs) that don't exist in `/etc/passwd` +2. **GID 0 Requirement**: All containers run with GID 0 (root group) as the primary group +3. **Restricted SCCs**: The default Security Context Constraints (SCCs) prevent containers from running as specific users + +These requirements are incompatible with the standard AnythingLLM Docker image, which uses a fixed `anythingllm` user with UID/GID 1000. + +## Key Differences from Standard Dockerfile + +| Feature | Standard Docker | OpenShift Template | +|---------|-----------------|-------------------| +| File ownership | `anythingllm:anythingllm` | `anythingllm:0` (root group) | +| File permissions | Standard | Group-writable (`g+w`) | +| `/etc/passwd` | Read-only | Group-writable for UID injection | +| Supplementary groups | None | Added to group 0 | +| Entrypoint | Standard | Handles arbitrary UID scenarios | + +## When to Use This Template + +Use this template **only** if you are deploying to: +- Red Hat OpenShift (any version) +- OKD (OpenShift Origin) +- Any Kubernetes cluster with OpenShift-style restricted SCCs + +**Do NOT use this for:** +- Standard Docker deployments +- Docker Compose +- Generic Kubernetes (use the standard image with appropriate `securityContext`) +- Cloud container services (AWS ECS, Google Cloud Run, Azure Container Instances) + +## Building the Image + +From the repository root: + +```bash +docker build -f cloud-deployments/openshift/Dockerfile -t anythingllm:openshift . +``` + +For multi-architecture builds: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -f cloud-deployments/openshift/Dockerfile \ + -t your-registry/anythingllm:openshift \ + --push . +``` + +## Deploying to OpenShift + +### Using `oc` CLI + +```bash +# Create a new project (namespace) +oc new-project anythingllm + +# Create a deployment +oc new-app your-registry/anythingllm:openshift + +# Expose the service +oc expose svc/anythingllm --port=3001 + +# Set required environment variables +oc set env deployment/anythingllm \ + STORAGE_DIR=/app/server/storage \ + JWT_SECRET=$(openssl rand -hex 32) +``` + +### Using a DeploymentConfig YAML + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anythingllm +spec: + replicas: 1 + selector: + matchLabels: + app: anythingllm + template: + metadata: + labels: + app: anythingllm + spec: + containers: + - name: anythingllm + image: your-registry/anythingllm:openshift + ports: + - containerPort: 3001 + env: + - name: STORAGE_DIR + value: /app/server/storage + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: anythingllm-secrets + key: jwt-secret + volumeMounts: + - name: storage + mountPath: /app/server/storage + volumes: + - name: storage + persistentVolumeClaim: + claimName: anythingllm-storage +``` + +## Persistent Storage + +OpenShift PersistentVolumeClaims work with this image. Ensure the PVC is created before deployment: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: anythingllm-storage +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi +``` + +## Troubleshooting + +### Permission Denied Errors + +If you see permission errors, verify: +1. You're using this OpenShift-specific image, not the standard one +2. The PVC has correct access modes +3. No custom SCCs are overriding the default behavior + +### User Not Found in passwd + +The entrypoint script automatically handles this by injecting a passwd entry at runtime. If issues persist, check that `/etc/passwd` is group-writable in your image. diff --git a/cloud-deployments/openshift/docker-entrypoint.sh b/cloud-deployments/openshift/docker-entrypoint.sh new file mode 100644 index 0000000..3b6b5fd --- /dev/null +++ b/cloud-deployments/openshift/docker-entrypoint.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# OpenShift runs containers with an arbitrary UID that may not exist in /etc/passwd. +# Many tools (npm, prisma, git, etc.) expect a passwd entry for the running user. +# If the current UID has no entry, dynamically add one using nss_wrapper-style injection. +if ! whoami &> /dev/null 2>&1; then + if [ -w /etc/passwd ]; then + echo "anythingllm:x:$(id -u):0:AnythingLLM User:/app:/bin/bash" >> /etc/passwd + fi +fi +export HOME=/app + +# Check if STORAGE_DIR is set +if [ -z "$STORAGE_DIR" ]; then + echo "================================================================" + echo "⚠️ ⚠️ ⚠️ WARNING: STORAGE_DIR environment variable is not set! ⚠️ ⚠️ ⚠️" + echo "" + echo "Not setting this will result in data loss on container restart since" + echo "the application will not have a persistent storage location." + echo "It can also result in weird errors in various parts of the application." + echo "" + echo "Please run the container with the official docker command at" + echo "https://docs.anythingllm.com/installation-docker/quickstart" + echo "" + echo "⚠️ ⚠️ ⚠️ WARNING: STORAGE_DIR environment variable is not set! ⚠️ ⚠️ ⚠️" + echo "================================================================" +fi + +{ + cd /app/server/ && + # Disable Prisma CLI telemetry (https://www.prisma.io/docs/orm/tools/prisma-cli#how-to-opt-out-of-data-collection) + export CHECKPOINT_DISABLE=1 && + npx prisma generate --schema=./prisma/schema.prisma && + npx prisma migrate deploy --schema=./prisma/schema.prisma && + node /app/server/index.js +} & +{ node /app/collector/index.js; } & +wait -n +exit $? diff --git a/collector/.env.example b/collector/.env.example new file mode 100644 index 0000000..259cb72 --- /dev/null +++ b/collector/.env.example @@ -0,0 +1,9 @@ +# Placeholder .env file for collector runtime + +# Port the collector listens on. The server must use the same COLLECTOR_PORT value when running services separately. +# COLLECTOR_PORT=8888 + +# This enables HTTP request/response logging in development. Set value to truthy string to enable, leave empty value or comment out to disable +# ENABLE_HTTP_LOGGER="" +# This enables timestamps for the HTTP Logger. Set value to true to enable, leave empty or comment out to disable +# ENABLE_HTTP_LOGGER_TIMESTAMPS="" diff --git a/collector/.gitignore b/collector/.gitignore new file mode 100644 index 0000000..86ced2a --- /dev/null +++ b/collector/.gitignore @@ -0,0 +1,9 @@ +hotdir/* +!hotdir/__HOTDIR__.md +yarn-error.log +!yarn.lock +outputs +scripts +.env.development +.env.production +.env.test diff --git a/collector/.nvmrc b/collector/.nvmrc new file mode 100644 index 0000000..b714151 --- /dev/null +++ b/collector/.nvmrc @@ -0,0 +1 @@ +v18.18.0 \ No newline at end of file diff --git a/collector/__tests__/utils/WhisperProviders/ffmpeg/index.test.js b/collector/__tests__/utils/WhisperProviders/ffmpeg/index.test.js new file mode 100644 index 0000000..f659370 --- /dev/null +++ b/collector/__tests__/utils/WhisperProviders/ffmpeg/index.test.js @@ -0,0 +1,77 @@ +process.env.STORAGE_DIR = "test-storage"; +const fs = require("fs"); +const path = require("path"); + +// Mock fix-path as a noop to prevent SIGSEGV (segfault) +// Returns ESM-style default export for dynamic import() +jest.mock("fix-path", () => ({ default: jest.fn() })); + +const { FFMPEGWrapper } = require("../../../../utils/WhisperProviders/ffmpeg"); + +const describeRunner = process.env.GITHUB_ACTIONS ? describe.skip : describe; + +describeRunner("FFMPEGWrapper", () => { + /** @type { import("../../../../utils/WhisperProviders/ffmpeg/index").FFMPEGWrapper } */ + let ffmpeg; + const testDir = path.resolve(__dirname, "../../../../storage/tmp"); + const inputPath = path.resolve(testDir, "test-input.wav"); + const outputPath = path.resolve(testDir, "test-output.wav"); + + beforeEach(() => { + ffmpeg = new FFMPEGWrapper(); + }); + + afterEach(() => { + if (fs.existsSync(inputPath)) fs.rmSync(inputPath); + if (fs.existsSync(outputPath)) fs.rmSync(outputPath); + }); + + it("should find ffmpeg executable", async () => { + const knownPath = await ffmpeg.ffmpegPath(); + expect(knownPath).toBeDefined(); + expect(typeof knownPath).toBe("string"); + expect(knownPath.length).toBeGreaterThan(0); + }); + + it("should validate ffmpeg executable", async () => { + const knownPath = await ffmpeg.ffmpegPath(); + expect(ffmpeg.isValidFFMPEG(knownPath)).toBe(true); + }); + + it("should return false for invalid ffmpeg path", () => { + expect(ffmpeg.isValidFFMPEG("/invalid/path/to/ffmpeg")).toBe(false); + }); + + it("should convert audio file to wav format", async () => { + if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); + + const sampleUrl = + "https://github.com/ringcentral/ringcentral-api-docs/blob/main/resources/sample1.wav?raw=true"; + + const response = await fetch(sampleUrl); + if (!response.ok) + throw new Error( + `Failed to download sample file: ${response.statusText}` + ); + + const buffer = await response.arrayBuffer(); + fs.writeFileSync(inputPath, Buffer.from(buffer)); + + const result = await ffmpeg.convertAudioToWav(inputPath, outputPath); + + expect(result).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); + + const stats = fs.statSync(outputPath); + expect(stats.size).toBeGreaterThan(0); + }, 30000); + + it("should throw error when conversion fails", () => { + const nonExistentFile = path.resolve(testDir, "non-existent-file.wav"); + const outputPath = path.resolve(testDir, "test-output-fail.wav"); + + expect(async () => { + return await ffmpeg.convertAudioToWav(nonExistentFile, outputPath); + }).rejects.toThrow(`Input file ${nonExistentFile} does not exist.`); + }); +}); diff --git a/collector/__tests__/utils/downloadURIToFile/index.test.js b/collector/__tests__/utils/downloadURIToFile/index.test.js new file mode 100644 index 0000000..167afb9 --- /dev/null +++ b/collector/__tests__/utils/downloadURIToFile/index.test.js @@ -0,0 +1,96 @@ +const path = require("path"); +const { SUPPORTED_FILETYPE_CONVERTERS } = require("../../../utils/constants"); +const { mimeToExtension } = require("../../../utils/downloadURIToFile"); + +/** + * Simulates the filename-building logic from downloadURIToFile + * to verify extension inference works correctly. + */ +function buildFilenameWithExtension(sluggedFilename, contentType) { + const existingExt = path.extname(sluggedFilename).toLowerCase(); + if (!SUPPORTED_FILETYPE_CONVERTERS.hasOwnProperty(existingExt)) { + const mimeType = contentType?.toLowerCase()?.split(";")[0]?.trim(); + const inferredExt = mimeToExtension(mimeType); + if (inferredExt) { + return sluggedFilename + inferredExt; + } + } + return sluggedFilename; +} + +describe("mimeToExtension", () => { + test("returns null for invalid or unknown input", () => { + expect(mimeToExtension(null)).toBeNull(); + expect(mimeToExtension(undefined)).toBeNull(); + expect(mimeToExtension("application/octet-stream")).toBeNull(); + }); + + test("returns first extension from ACCEPTED_MIMES for known types", () => { + expect(mimeToExtension("application/pdf")).toBe(".pdf"); + }); +}); + +describe("buildFilenameWithExtension", () => { + test("appends .pdf when URL path has no recognized extension (arxiv case)", () => { + // Simulates: https://arxiv.org/pdf/2307.10265 + // slugify produces something like "arxiv.org-pdf-230710265" + const filename = "arxiv.org-pdf-230710265"; + const result = buildFilenameWithExtension(filename, "application/pdf"); + expect(result).toBe("arxiv.org-pdf-230710265.pdf"); + }); + + test("appends .pdf when URL has numeric-looking extension", () => { + // path.extname("arxiv.org-pdf-2307.10265") => ".10265" which is not in SUPPORTED_FILETYPE_CONVERTERS + const filename = "arxiv.org-pdf-2307.10265"; + const result = buildFilenameWithExtension( + filename, + "application/pdf; charset=utf-8" + ); + expect(result).toBe("arxiv.org-pdf-2307.10265.pdf"); + }); + + test("does NOT append extension when file already has a supported extension", () => { + const filename = "example.com-document.pdf"; + const result = buildFilenameWithExtension(filename, "application/pdf"); + expect(result).toBe("example.com-document.pdf"); + }); + + test("does NOT append extension when file has .txt extension", () => { + const filename = "example.com-readme.txt"; + const result = buildFilenameWithExtension(filename, "text/plain"); + expect(result).toBe("example.com-readme.txt"); + }); + + test("does not append extension for unknown content type", () => { + const filename = "example.com-binary-blob"; + const result = buildFilenameWithExtension( + filename, + "application/octet-stream" + ); + expect(result).toBe("example.com-binary-blob"); + }); + + test("does not append extension when content type is null", () => { + const filename = "example.com-unknown"; + const result = buildFilenameWithExtension(filename, null); + expect(result).toBe("example.com-unknown"); + }); + + test("appends .docx for word document MIME type", () => { + const filename = "sharepoint.com-documents-report"; + const result = buildFilenameWithExtension( + filename, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + expect(result).toBe("sharepoint.com-documents-report.docx"); + }); + + test("handles content type with charset parameter correctly", () => { + const filename = "api.example.com-export-data"; + const result = buildFilenameWithExtension( + filename, + "text/csv; charset=utf-8" + ); + expect(result).toBe("api.example.com-export-data.csv"); + }); +}); diff --git a/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js b/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js new file mode 100644 index 0000000..6633f0c --- /dev/null +++ b/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js @@ -0,0 +1,125 @@ +/* eslint-env jest, node */ +process.env.STORAGE_DIR = "test-storage"; + +const { resolveConfluenceBaseUrl } = require("../../../../utils/extensions/Confluence"); +const { + ConfluencePagesLoader, +} = require("../../../../utils/extensions/Confluence/ConfluenceLoader"); + +describe("resolveConfluenceBaseUrl", () => { + test("cloud: strips path and returns origin only", () => { + expect( + resolveConfluenceBaseUrl("https://example.atlassian.net/wiki/spaces/SP", true) + ).toBe("https://example.atlassian.net"); + }); + + test("self-hosted: preserves context path, strips trailing slash", () => { + expect( + resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false) + ).toBe("https://my.domain.com/confluence"); + }); + + test("self-hosted: returns origin when no context path", () => { + expect( + resolveConfluenceBaseUrl("https://my.domain.com/", false) + ).toBe("https://my.domain.com"); + }); +}); + +describe("ConfluencePagesLoader", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("cloud mode", () => { + test("API requests include /wiki prefix", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ size: 0, results: [] }), + }); + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://example.atlassian.net/wiki/spaces/SP", true), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: true, + }); + + await loader.fetchAllPagesInSpace(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.atlassian.net/wiki/rest/api/content?spaceKey=SP&limit=25&start=0&expand=body.storage,version", + expect.any(Object) + ); + }); + + test("page URLs include /wiki prefix", () => { + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://example.atlassian.net/wiki", true), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: true, + }); + + const document = loader.createDocumentFromPage({ + id: "123", + status: "current", + title: "Cloud page", + type: "page", + body: { storage: { value: "

Hello

" } }, + version: { number: 1, by: { displayName: "User" }, when: "2026-01-01T00:00:00.000Z" }, + }); + + expect(document.metadata.url).toBe( + "https://example.atlassian.net/wiki/spaces/SP/pages/123" + ); + }); + }); + + describe("self-hosted mode", () => { + test("API requests use context path without /wiki", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ size: 0, results: [] }), + }); + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: false, + }); + + await loader.fetchAllPagesInSpace(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://my.domain.com/confluence/rest/api/content?spaceKey=SP&limit=25&start=0&expand=body.storage,version", + expect.any(Object) + ); + }); + + test("page URLs use context path without /wiki", () => { + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: false, + }); + + const document = loader.createDocumentFromPage({ + id: "123", + status: "current", + title: "Self-hosted page", + type: "page", + body: { storage: { value: "

Hello

" } }, + version: { number: 1, by: { displayName: "User" }, when: "2026-01-01T00:00:00.000Z" }, + }); + + expect(document.metadata.url).toBe( + "https://my.domain.com/confluence/spaces/SP/pages/123" + ); + }); + }); +}); diff --git a/collector/__tests__/utils/url/index.test.js b/collector/__tests__/utils/url/index.test.js new file mode 100644 index 0000000..6ded845 --- /dev/null +++ b/collector/__tests__/utils/url/index.test.js @@ -0,0 +1,197 @@ +process.env.STORAGE_DIR = "test-storage"; // needed for tests to run +const { validURL, validateURL, validYoutubeVideoUrl } = require("../../../utils/url"); + +// Mock the RuntimeSettings module +jest.mock("../../../utils/runtimeSettings", () => { + const mockInstance = { + get: jest.fn(), + set: jest.fn(), + }; + return jest.fn().mockImplementation(() => mockInstance); +}); + +describe("validURL", () => { + let mockRuntimeSettings; + + beforeEach(() => { + const RuntimeSettings = require("../../../utils/runtimeSettings"); + mockRuntimeSettings = new RuntimeSettings(); + jest.clearAllMocks(); + }); + + it("should validate a valid URL", () => { + mockRuntimeSettings.get.mockImplementation((key) => { + if (key === "allowAnyIp") return false; + if (key === "seenAnyIpWarning") return true; // silence the warning for tests + return false; + }); + + expect(validURL("https://www.google.com")).toBe(true); + expect(validURL("http://www.google.com")).toBe(true); + + // JS URL does not require extensions, so in theory + // these should be valid + expect(validURL("https://random")).toBe(true); + expect(validURL("http://123")).toBe(true); + + // missing protocols + expect(validURL("www.google.com")).toBe(false); + expect(validURL("google.com")).toBe(false); + + // invalid protocols + expect(validURL("ftp://www.google.com")).toBe(false); + expect(validURL("mailto://www.google.com")).toBe(false); + expect(validURL("tel://www.google.com")).toBe(false); + expect(validURL("data://www.google.com")).toBe(false); + }); + + it("should block private/local IPs when allowAnyIp is false (default behavior)", () => { + mockRuntimeSettings.get.mockImplementation((key) => { + if (key === "allowAnyIp") return false; + if (key === "seenAnyIpWarning") return true; // silence the warning for tests + return false; + }); + + expect(validURL("http://192.168.1.1")).toBe(false); + expect(validURL("http://10.0.0.1")).toBe(false); + expect(validURL("http://172.16.0.1")).toBe(false); + + // But localhost should still be allowed + expect(validURL("http://127.0.0.1")).toBe(true); + expect(validURL("http://0.0.0.0")).toBe(true); + }); + + it("should allow any IP when allowAnyIp is true", () => { + mockRuntimeSettings.get.mockImplementation((key) => { + if (key === "allowAnyIp") return true; + if (key === "seenAnyIpWarning") return true; // silence the warning for tests + return false; + }); + + expect(validURL("http://192.168.1.1")).toBe(true); + expect(validURL("http://10.0.0.1")).toBe(true); + expect(validURL("http://172.16.0.1")).toBe(true); + }); +}); + +describe("validateURL", () => { + it("should return the same URL if it's already valid", () => { + expect(validateURL("https://www.google.com")).toBe( + "https://www.google.com" + ); + expect(validateURL("http://www.google.com")).toBe("http://www.google.com"); + expect(validateURL("https://random")).toBe("https://random"); + + // With numbers as a url this will turn into an ip + expect(validateURL("123")).toBe("https://0.0.0.123"); + expect(validateURL("123.123.123.123")).toBe("https://123.123.123.123"); + expect(validateURL("http://127.0.123.45")).toBe("http://127.0.123.45"); + }); + + it("should assume https:// if the URL doesn't have a protocol", () => { + expect(validateURL("www.google.com")).toBe("https://www.google.com"); + expect(validateURL("google.com")).toBe("https://google.com"); + expect(validateURL("EXAMPLE.com/ABCDEF/q1=UPPER")).toBe("https://example.com/ABCDEF/q1=UPPER"); + expect(validateURL("ftp://www.google.com")).toBe("ftp://www.google.com"); + expect(validateURL("mailto://www.google.com")).toBe( + "mailto://www.google.com" + ); + expect(validateURL("tel://www.google.com")).toBe("tel://www.google.com"); + expect(validateURL("data://www.google.com")).toBe("data://www.google.com"); + }); + + it("should remove trailing slashes post-validation", () => { + expect(validateURL("https://www.google.com/")).toBe( + "https://www.google.com" + ); + expect(validateURL("http://www.google.com/")).toBe("http://www.google.com"); + expect(validateURL("https://random/")).toBe("https://random"); + expect(validateURL("https://example.com/ABCDEF/")).toBe("https://example.com/ABCDEF"); + }); + + it("should handle edge cases and bad data inputs", () => { + expect(validateURL({})).toBe(""); + expect(validateURL(null)).toBe(""); + expect(validateURL(undefined)).toBe(""); + expect(validateURL(124512)).toBe(""); + expect(validateURL("")).toBe(""); + expect(validateURL(" ")).toBe(""); + expect(validateURL(" look here! ")).toBe("look here!"); + }); + + it("should preserve case of characters in URL pathname", () => { + expect(validateURL("https://example.com/To/ResOURce?q1=Value&qZ22=UPPE!R")) + .toBe("https://example.com/To/ResOURce?q1=Value&qZ22=UPPE!R"); + expect(validateURL("https://sample.com/uPeRCaSe")) + .toBe("https://sample.com/uPeRCaSe"); + expect(validateURL("Example.com/PATH/To/Resource?q2=Value&q1=UPPER")) + .toBe("https://example.com/PATH/To/Resource?q2=Value&q1=UPPER"); + }); +}); + + +describe("validYoutubeVideoUrl", () => { + const ID = "dQw4w9WgXcQ"; // 11-char valid video id + + it("returns true for youtube watch URLs with v param", () => { + expect(validYoutubeVideoUrl(`https://www.youtube.com/watch?v=${ID}`)).toBe( + true + ); + expect(validYoutubeVideoUrl(`https://youtube.com/watch?v=${ID}&t=10s`)).toBe( + true + ); + expect(validYoutubeVideoUrl(`https://m.youtube.com/watch?v=${ID}`)).toBe(true); + expect(validYoutubeVideoUrl(`youtube.com/watch?v=${ID}`)).toBe(true); + }); + + it("returns true for youtu.be short URLs", () => { + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}`)).toBe(true); + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}?si=abc`)).toBe(true); + // extra path segments after id should still validate the id component + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}/extra`)).toBe(true); + }); + + it("returns true for embed and v path formats", () => { + expect(validYoutubeVideoUrl(`https://www.youtube.com/embed/${ID}`)).toBe(true); + expect(validYoutubeVideoUrl(`https://youtube.com/v/${ID}`)).toBe(true); + }); + + it("returns false for non-YouTube hosts", () => { + expect(validYoutubeVideoUrl("https://example.com/watch?v=dQw4w9WgXcQ")).toBe( + false + ); + expect(validYoutubeVideoUrl("https://vimeo.com/123456")).toBe(false); + }); + + it("returns false for unrelated YouTube paths without a video id", () => { + expect(validYoutubeVideoUrl("https://www.youtube.com/user/somechannel")).toBe( + false + ); + expect(validYoutubeVideoUrl("https://www.youtube.com/")).toBe(false); + }); + + it("returns false for empty or bad inputs", () => { + expect(validYoutubeVideoUrl("")).toBe(false); + expect(validYoutubeVideoUrl(null)).toBe(false); + expect(validYoutubeVideoUrl(undefined)).toBe(false); + }); + + it("returns the video ID for valid YouTube video URLs", () => { + expect(validYoutubeVideoUrl(`https://www.youtube.com/watch?v=${ID}`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://youtube.com/watch?v=${ID}&t=10s`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://m.youtube.com/watch?v=${ID}`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`youtube.com/watch?v=${ID}`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}?si=abc`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://youtu.be/${ID}/extra`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://www.youtube.com/embed/${ID}`, true)).toBe(ID); + expect(validYoutubeVideoUrl(`https://youtube.com/v/${ID}`, true)).toBe(ID); + // invalid video IDs + expect(validYoutubeVideoUrl(`https://www.youtube.com/watch?v=invalid`, true)).toBe(null); + expect(validYoutubeVideoUrl(`https://youtube.com/watch?v=invalid`, true)).toBe(null); + expect(validYoutubeVideoUrl(`https://m.youtube.com/watch?v=invalid`, true)).toBe(null); + expect(validYoutubeVideoUrl(`youtube.com/watch`, true)).toBe(null); + expect(validYoutubeVideoUrl(`https://youtu.be/invalid`, true)).toBe(null); + expect(validYoutubeVideoUrl(`https://youtu.be/invalid?si=abc`, true)).toBe(null); + }); +}); diff --git a/collector/convertAudioToWav/index.js b/collector/convertAudioToWav/index.js new file mode 100644 index 0000000..8db1759 --- /dev/null +++ b/collector/convertAudioToWav/index.js @@ -0,0 +1,54 @@ +const fs = require("fs/promises"); +const path = require("path"); +const { WATCH_DIRECTORY } = require("../utils/constants"); +const { isWithin, trashFile, normalizePath } = require("../utils/files"); +const { FFMPEGWrapper } = require("../utils/WhisperProviders/ffmpeg"); + +/** + * Convert an audio file in the hotdir to a 16kHz mono WAV in the same directory. + * The source file is trashed; caller must read and trash the resulting wav. + * @param {string} filename - The filename of the source audio in the hotdir + * @returns {Promise<{success: boolean, reason: string, wavFilename: string|null}>} + */ +async function convertAudioToWav(filename) { + if (!filename) + return { + success: false, + reason: "No filename provided.", + wavFilename: null, + }; + + const inputPath = normalizePath(path.resolve(WATCH_DIRECTORY, filename)); + if (!isWithin(path.resolve(WATCH_DIRECTORY), inputPath)) + return { + success: false, + reason: "Filename is outside the hotdir.", + wavFilename: null, + }; + + try { + await fs.access(inputPath); + } catch { + return { + success: false, + reason: `${filename} does not exist in hotdir.`, + wavFilename: null, + }; + } + + const wavFilename = `${path.parse(filename).name}.wav`; + const outputPath = path.resolve(WATCH_DIRECTORY, wavFilename); + + try { + const ffmpeg = new FFMPEGWrapper(); + await ffmpeg.convertAudioToWav(inputPath, outputPath); + return { success: true, reason: null, wavFilename }; + } catch (e) { + console.error(e); + return { success: false, reason: e.message, wavFilename: null }; + } finally { + trashFile(inputPath); + } +} + +module.exports = { convertAudioToWav }; diff --git a/collector/eslint.config.mjs b/collector/eslint.config.mjs new file mode 100644 index 0000000..39dfec1 --- /dev/null +++ b/collector/eslint.config.mjs @@ -0,0 +1,38 @@ +import js from "@eslint/js"; +import globals from "globals"; +import { defineConfig } from "eslint/config"; +import pluginPrettier from "eslint-plugin-prettier"; +import configPrettier from "eslint-config-prettier"; +import unusedImports from "eslint-plugin-unused-imports"; + +export default defineConfig([ + { ignores: ["__tests__/**"] }, + { + files: ["**/*.{js,mjs,cjs}"], + plugins: { js, prettier: pluginPrettier, "unused-imports": unusedImports }, + extends: ["js/recommended"], + languageOptions: { globals: { ...globals.node, ...globals.browser } }, + rules: { + ...configPrettier.rules, + "prettier/prettier": "error", + "no-case-declarations": "off", + "no-prototype-builtins": "off", + "no-async-promise-executor": "off", + "no-extra-boolean-cast": "off", + "no-empty": "off", + "no-unused-private-class-members": "warn", + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + }, + }, + { files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } }, +]); diff --git a/collector/extensions/index.js b/collector/extensions/index.js new file mode 100644 index 0000000..0be27db --- /dev/null +++ b/collector/extensions/index.js @@ -0,0 +1,239 @@ +const { setDataSigner } = require("../middleware/setDataSigner"); +const { verifyPayloadIntegrity } = require("../middleware/verifyIntegrity"); +const { + resolveRepoLoader, + resolveRepoLoaderFunction, +} = require("../utils/extensions/RepoLoader"); +const { reqBody } = require("../utils/http"); +const { validURL, validateURL } = require("../utils/url"); +const RESYNC_METHODS = require("./resync"); +const { loadObsidianVault } = require("../utils/extensions/ObsidianVault"); + +function extensions(app) { + if (!app) return; + + app.post( + "/ext/resync-source-document", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { type, options } = reqBody(request); + if (!RESYNC_METHODS.hasOwnProperty(type)) + throw new Error(`Type "${type}" is not a valid type to sync.`); + return await RESYNC_METHODS[type](options, response); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + reason: e.message || "A processing error occurred.", + }); + } + return; + } + ); + + app.post( + "/ext/:repo_platform-repo", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const loadRepo = resolveRepoLoaderFunction( + request.params.repo_platform + ); + const { success, reason, data } = await loadRepo( + reqBody(request), + response + ); + response.status(200).json({ + success, + reason, + data, + }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + reason: e.message || "A processing error occurred.", + data: {}, + }); + } + return; + } + ); + + // gets all branches for a specific repo + app.post( + "/ext/:repo_platform-repo/branches", + [verifyPayloadIntegrity], + async function (request, response) { + try { + const RepoLoader = resolveRepoLoader(request.params.repo_platform); + const allBranches = await new RepoLoader( + reqBody(request) + ).getRepoBranches(); + response.status(200).json({ + success: true, + reason: null, + data: { + branches: allBranches, + }, + }); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: { + branches: [], + }, + }); + } + return; + } + ); + + app.post( + "/ext/youtube-transcript", + [verifyPayloadIntegrity], + async function (request, response) { + try { + const { + loadYouTubeTranscript, + } = require("../utils/extensions/YoutubeTranscript"); + const { success, reason, data } = await loadYouTubeTranscript( + reqBody(request) + ); + response.status(200).json({ success, reason, data }); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: { + title: null, + author: null, + }, + }); + } + return; + } + ); + + app.post( + "/ext/website-depth", + [verifyPayloadIntegrity], + async function (request, response) { + try { + const websiteDepth = require("../utils/extensions/WebsiteDepth"); + const { url, depth = 1, maxLinks = 20 } = reqBody(request); + const validatedUrl = validateURL(url); + if (!validURL(validatedUrl)) throw new Error("Not a valid URL."); + const scrapedData = await websiteDepth(validatedUrl, depth, maxLinks); + response.status(200).json({ success: true, data: scrapedData }); + } catch (e) { + console.error(e); + response.status(400).json({ success: false, reason: e.message }); + } + return; + } + ); + + app.post( + "/ext/confluence", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { loadConfluence } = require("../utils/extensions/Confluence"); + const { success, reason, data } = await loadConfluence( + reqBody(request), + response + ); + response.status(200).json({ success, reason, data }); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: { + title: null, + author: null, + }, + }); + } + return; + } + ); + + app.post( + "/ext/drupalwiki", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { + loadAndStoreSpaces, + } = require("../utils/extensions/DrupalWiki"); + const { success, reason, data } = await loadAndStoreSpaces( + reqBody(request), + response + ); + response.status(200).json({ success, reason, data }); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: { + title: null, + author: null, + }, + }); + } + return; + } + ); + + app.post( + "/ext/obsidian/vault", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { files } = reqBody(request); + const result = await loadObsidianVault({ files }); + response.status(200).json(result); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: null, + }); + } + return; + } + ); + + app.post( + "/ext/paperless-ngx", + [verifyPayloadIntegrity, setDataSigner], + async function (request, response) { + try { + const { + loadPaperlessNgx, + } = require("../utils/extensions/PaperlessNgx"); + const result = await loadPaperlessNgx(reqBody(request), response); + response.status(200).json(result); + } catch (e) { + console.error(e); + response.status(400).json({ + success: false, + reason: e.message, + data: null, + }); + } + return; + } + ); +} + +module.exports = extensions; diff --git a/collector/extensions/resync/index.js b/collector/extensions/resync/index.js new file mode 100644 index 0000000..2ceb78e --- /dev/null +++ b/collector/extensions/resync/index.js @@ -0,0 +1,197 @@ +const { getLinkText } = require("../../processLink"); + +/** + * Fetches the content of a raw link. Returns the content as a text string of the link in question. + * @param {object} data - metadata from document (eg: link) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncLink({ link }, response) { + if (!link) throw new Error("Invalid link provided"); + try { + const { success, content = null, reason } = await getLinkText(link); + if (!success) throw new Error(`Failed to sync link content. ${reason}`); + response.status(200).json({ success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +/** + * Fetches the content of a YouTube link. Returns the content as a text string of the video in question. + * We offer this as there may be some videos where a transcription could be manually edited after initial scraping + * but in general - transcriptions often never change. + * @param {object} data - metadata from document (eg: link) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncYouTube({ link }, response) { + if (!link) throw new Error("Invalid link provided"); + try { + const { + fetchVideoTranscriptContent, + } = require("../../utils/extensions/YoutubeTranscript"); + const { success, reason, content } = await fetchVideoTranscriptContent({ + url: link, + }); + if (!success) + throw new Error(`Failed to sync YouTube video transcript. ${reason}`); + response.status(200).json({ success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +/** + * Fetches the content of a specific confluence page via its chunkSource. + * Returns the content as a text string of the page in question and only that page. + * @param {object} data - metadata from document (eg: chunkSource) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncConfluence({ chunkSource }, response) { + if (!chunkSource) throw new Error("Invalid source property provided"); + try { + // Confluence data is `payload` encrypted. So we need to expand its + // encrypted payload back into query params so we can reFetch the page with same access token/params. + const source = response.locals.encryptionWorker.expandPayload(chunkSource); + const { + fetchConfluencePage, + } = require("../../utils/extensions/Confluence"); + const { success, reason, content } = await fetchConfluencePage({ + pageUrl: `https:${source.pathname}`, // need to add back the real protocol + baseUrl: source.searchParams.get("baseUrl"), + spaceKey: source.searchParams.get("spaceKey"), + accessToken: source.searchParams.get("token"), + username: source.searchParams.get("username"), + cloud: source.searchParams.get("cloud") === "true", + bypassSSL: source.searchParams.get("bypassSSL") === "true", + }); + + if (!success) + throw new Error(`Failed to sync Confluence page content. ${reason}`); + response.status(200).json({ success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +/** + * Fetches the content of a specific confluence page via its chunkSource. + * Returns the content as a text string of the page in question and only that page. + * @param {object} data - metadata from document (eg: chunkSource) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncGithub({ chunkSource }, response) { + if (!chunkSource) throw new Error("Invalid source property provided"); + try { + // Github file data is `payload` encrypted (might contain PAT). So we need to expand its + // encrypted payload back into query params so we can reFetch the page with same access token/params. + const source = response.locals.encryptionWorker.expandPayload(chunkSource); + const { + fetchGithubFile, + } = require("../../utils/extensions/RepoLoader/GithubRepo"); + const { success, reason, content } = await fetchGithubFile({ + repoUrl: `https:${source.pathname}`, // need to add back the real protocol + branch: source.searchParams.get("branch"), + accessToken: source.searchParams.get("pat"), + sourceFilePath: source.searchParams.get("path"), + }); + + if (!success) + throw new Error(`Failed to sync GitHub file content. ${reason}`); + response.status(200).json({ success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +/** + * Fetches the content of a specific DrupalWiki page via its chunkSource. + * Returns the content as a text string of the page in question and only that page. + * @param {object} data - metadata from document (eg: chunkSource) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncDrupalWiki({ chunkSource }, response) { + if (!chunkSource) throw new Error("Invalid source property provided"); + try { + // DrupalWiki data is `payload` encrypted. So we need to expand its + // encrypted payload back into query params so we can reFetch the page with same access token/params. + const source = response.locals.encryptionWorker.expandPayload(chunkSource); + const { loadPage } = require("../../utils/extensions/DrupalWiki"); + const { success, reason, content } = await loadPage({ + baseUrl: source.searchParams.get("baseUrl"), + pageId: source.searchParams.get("pageId"), + accessToken: source.searchParams.get("accessToken"), + }); + + if (!success) { + console.error(`Failed to sync DrupalWiki page content. ${reason}`); + response.status(200).json({ + success: false, + content: null, + }); + } else { + response.status(200).json({ success, content }); + } + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +/** + * Fetches the content of a specific Paperless-ngx document via its chunkSource. + * Returns the content as a text string of the document. + * @param {object} data - metadata from document (eg: chunkSource) + * @param {import("../../middleware/setDataSigner").ResponseWithSigner} response + */ +async function resyncPaperlessNgx({ chunkSource }, response) { + if (!chunkSource) throw new Error("Invalid source property provided"); + try { + const source = response.locals.encryptionWorker.expandPayload(chunkSource); + const { + PaperlessNgxLoader, + } = require("../../utils/extensions/PaperlessNgx/PaperlessNgxLoader"); + const loader = new PaperlessNgxLoader({ + baseUrl: source.searchParams.get("baseUrl"), + apiToken: source.searchParams.get("token"), + }); + const documentId = source.pathname.split("//")[1]; + const content = await loader.fetchDocumentContent(documentId); + + if (!content) throw new Error("Failed to fetch document content"); + response.status(200).json({ success: true, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + success: false, + content: null, + }); + } +} + +module.exports = { + link: resyncLink, + youtube: resyncYouTube, + confluence: resyncConfluence, + github: resyncGithub, + drupalwiki: resyncDrupalWiki, + "paperless-ngx": resyncPaperlessNgx, +}; diff --git a/collector/hotdir/__HOTDIR__.md b/collector/hotdir/__HOTDIR__.md new file mode 100644 index 0000000..8f20efc --- /dev/null +++ b/collector/hotdir/__HOTDIR__.md @@ -0,0 +1,3 @@ +### What is the "Hot directory" + +This is a pre-set file location that documents will be written to when uploaded by AnythingLLM. There is really no need to touch it. \ No newline at end of file diff --git a/collector/index.js b/collector/index.js new file mode 100644 index 0000000..b9707c7 --- /dev/null +++ b/collector/index.js @@ -0,0 +1,228 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); + +require("./utils/logger")(); +const express = require("express"); +const bodyParser = require("body-parser"); +const cors = require("cors"); +const path = require("path"); +const { ACCEPTED_MIMES } = require("./utils/constants"); +const { reqBody, getCollectorPort } = require("./utils/http"); +const { processSingleFile } = require("./processSingleFile"); +const { processLink, getLinkText } = require("./processLink"); +const { wipeCollectorStorage } = require("./utils/files"); +const extensions = require("./extensions"); +const { processRawText } = require("./processRawText"); +const { convertAudioToWav } = require("./convertAudioToWav"); +const { verifyPayloadIntegrity } = require("./middleware/verifyIntegrity"); +const { httpLogger } = require("./middleware/httpLogger"); +const app = express(); +const FILE_LIMIT = "3GB"; +const COLLECTOR_PORT = getCollectorPort(); + +// Only log HTTP requests in development mode and if the ENABLE_HTTP_LOGGER environment variable is set to true +if ( + process.env.NODE_ENV === "development" && + !!process.env.ENABLE_HTTP_LOGGER +) { + app.use( + httpLogger({ + enableTimestamps: !!process.env.ENABLE_HTTP_LOGGER_TIMESTAMPS, + }) + ); +} +app.use(cors({ origin: true })); +app.use( + bodyParser.text({ limit: FILE_LIMIT }), + bodyParser.json({ limit: FILE_LIMIT }), + bodyParser.urlencoded({ + limit: FILE_LIMIT, + extended: true, + }) +); + +app.post( + "/process", + [verifyPayloadIntegrity], + async function (request, response) { + const { filename, options = {}, metadata = {} } = reqBody(request); + try { + const targetFilename = path + .normalize(filename) + .replace(/^(\.\.(\/|\\|$))+/, ""); + const { + success, + reason, + documents = [], + } = await processSingleFile(targetFilename, options, metadata); + response + .status(200) + .json({ filename: targetFilename, success, reason, documents }); + } catch (e) { + console.error(e); + response.status(200).json({ + filename: filename, + success: false, + reason: "A processing error occurred.", + documents: [], + }); + } + return; + } +); + +app.post( + "/parse", + [verifyPayloadIntegrity], + async function (request, response) { + const { filename, options = {} } = reqBody(request); + try { + const targetFilename = path + .normalize(filename) + .replace(/^(\.\.(\/|\\|$))+/, ""); + const { + success, + reason, + documents = [], + } = await processSingleFile(targetFilename, { + ...options, + parseOnly: true, + absolutePath: options.absolutePath || null, + }); + response + .status(200) + .json({ filename: targetFilename, success, reason, documents }); + } catch (e) { + console.error(e); + response.status(200).json({ + filename: filename, + success: false, + reason: "A processing error occurred.", + documents: [], + }); + } + return; + } +); + +app.post( + "/process-link", + [verifyPayloadIntegrity], + async function (request, response) { + const { link, scraperHeaders = {}, metadata = {} } = reqBody(request); + try { + const { + success, + reason, + documents = [], + } = await processLink(link, scraperHeaders, metadata); + response.status(200).json({ url: link, success, reason, documents }); + } catch (e) { + console.error(e); + response.status(200).json({ + url: link, + success: false, + reason: "A processing error occurred.", + documents: [], + }); + } + return; + } +); + +app.post( + "/util/get-link", + [verifyPayloadIntegrity], + async function (request, response) { + const { link, captureAs = "text" } = reqBody(request); + try { + const { success, content = null } = await getLinkText(link, captureAs); + response.status(200).json({ url: link, success, content }); + } catch (e) { + console.error(e); + response.status(200).json({ + url: link, + success: false, + content: null, + }); + } + return; + } +); + +app.post( + "/util/convert-audio-to-wav", + [verifyPayloadIntegrity], + async function (request, response) { + const { filename } = reqBody(request); + try { + const { + success, + reason, + wavFilename = null, + } = await convertAudioToWav(filename); + response.status(200).json({ filename, success, reason, wavFilename }); + } catch (e) { + console.error(e); + response.status(200).json({ + filename, + success: false, + reason: "An audio conversion error occurred.", + wavFilename: null, + }); + } + return; + } +); + +app.post( + "/process-raw-text", + [verifyPayloadIntegrity], + async function (request, response) { + const { textContent, metadata } = reqBody(request); + try { + const { + success, + reason, + documents = [], + } = await processRawText(textContent, metadata); + response + .status(200) + .json({ filename: metadata.title, success, reason, documents }); + } catch (e) { + console.error(e); + response.status(200).json({ + filename: metadata?.title || "Unknown-doc.txt", + success: false, + reason: "A processing error occurred.", + documents: [], + }); + } + return; + } +); + +extensions(app); + +app.get("/accepts", function (_, response) { + response.status(200).json(ACCEPTED_MIMES); +}); + +app.all("*", function (_, response) { + response.sendStatus(200); +}); + +app + .listen(COLLECTOR_PORT, async () => { + await wipeCollectorStorage(); + console.log(`Document processor app listening on port ${COLLECTOR_PORT}`); + }) + .on("error", function (_) { + process.once("SIGUSR2", function () { + process.kill(process.pid, "SIGUSR2"); + }); + process.on("SIGINT", function () { + process.kill(process.pid, "SIGINT"); + }); + }); diff --git a/collector/middleware/httpLogger.js b/collector/middleware/httpLogger.js new file mode 100644 index 0000000..6fa20be --- /dev/null +++ b/collector/middleware/httpLogger.js @@ -0,0 +1,29 @@ +const httpLogger = + ({ enableTimestamps = false }) => + (req, res, next) => { + // Capture the original res.end to log response status + const originalEnd = res.end; + + res.end = function (chunk, encoding) { + // Log the request method, status code, and path + const statusColor = res.statusCode >= 400 ? "\x1b[31m" : "\x1b[32m"; // Red for errors, green for success + console.log( + `\x1b[32m[HTTP]\x1b[0m ${statusColor}${res.statusCode}\x1b[0m ${ + req.method + } -> ${req.path} ${ + enableTimestamps + ? `@ ${new Date().toLocaleTimeString("en-US", { hour12: true })}` + : "" + }`.trim() + ); + + // Call the original end method + return originalEnd.call(this, chunk, encoding); + }; + + next(); + }; + +module.exports = { + httpLogger, +}; diff --git a/collector/middleware/setDataSigner.js b/collector/middleware/setDataSigner.js new file mode 100644 index 0000000..16fca8a --- /dev/null +++ b/collector/middleware/setDataSigner.js @@ -0,0 +1,44 @@ +const { EncryptionWorker } = require("../utils/EncryptionWorker"); +const { CommunicationKey } = require("../utils/comKey"); + +/** + * Express Response Object interface with defined encryptionWorker attached to locals property. + * @typedef {import("express").Response & import("express").Response['locals'] & {encryptionWorker: EncryptionWorker} } ResponseWithSigner + */ + +// You can use this middleware to assign the EncryptionWorker to the response locals +// property so that if can be used to encrypt/decrypt arbitrary data via response object. +// eg: Encrypting API keys in chunk sources. + +// The way this functions is that the rolling RSA Communication Key is used server-side to private-key encrypt the raw +// key of the persistent EncryptionManager credentials. Since EncryptionManager credentials do _not_ roll, we should not send them +// even between server<>collector in plaintext because if the user configured the server/collector to be public they could technically +// be exposing the key in transit via the X-Payload-Signer header. Even if this risk is minimal we should not do this. + +// This middleware uses the CommunicationKey public key to first decrypt the base64 representation of the EncryptionManager credentials +// and then loads that in to the EncryptionWorker as a buffer so we can use the same credentials across the system. Should we ever break the +// collector out into its own service this would still work without SSL/TLS. + +/** + * + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + */ +function setDataSigner(request, response, next) { + const comKey = new CommunicationKey(); + const encryptedPayloadSigner = request.header("X-Payload-Signer"); + if (!encryptedPayloadSigner) + console.log( + "Failed to find signed-payload to set encryption worker! Encryption calls will fail." + ); + + const decryptedPayloadSignerKey = comKey.decrypt(encryptedPayloadSigner); + const encryptionWorker = new EncryptionWorker(decryptedPayloadSignerKey); + response.locals.encryptionWorker = encryptionWorker; + next(); +} + +module.exports = { + setDataSigner, +}; diff --git a/collector/middleware/verifyIntegrity.js b/collector/middleware/verifyIntegrity.js new file mode 100644 index 0000000..f4d5556 --- /dev/null +++ b/collector/middleware/verifyIntegrity.js @@ -0,0 +1,32 @@ +const { CommunicationKey } = require("../utils/comKey"); +const RuntimeSettings = require("../utils/runtimeSettings"); +const runtimeSettings = new RuntimeSettings(); + +function verifyPayloadIntegrity(request, response, next) { + const comKey = new CommunicationKey(); + if (process.env.NODE_ENV === "development") { + comKey.log("verifyPayloadIntegrity is skipped in development."); + runtimeSettings.parseOptionsFromRequest(request); + next(); + return; + } + + const signature = request.header("X-Integrity"); + if (!signature) + return response + .status(400) + .json({ msg: "Failed integrity signature check." }); + + const validSignedPayload = comKey.verify(signature, request.body); + if (!validSignedPayload) + return response + .status(400) + .json({ msg: "Failed integrity signature check." }); + + runtimeSettings.parseOptionsFromRequest(request); + next(); +} + +module.exports = { + verifyPayloadIntegrity, +}; diff --git a/collector/nodemon.json b/collector/nodemon.json new file mode 100644 index 0000000..23e9d55 --- /dev/null +++ b/collector/nodemon.json @@ -0,0 +1,3 @@ +{ + "events": {} +} \ No newline at end of file diff --git a/collector/package.json b/collector/package.json new file mode 100644 index 0000000..a180514 --- /dev/null +++ b/collector/package.json @@ -0,0 +1,68 @@ +{ + "name": "anything-llm-document-collector", + "version": "1.15.0", + "description": "Document collector server endpoints", + "main": "index.js", + "author": "Timothy Carambat (Mintplex Labs)", + "license": "MIT", + "private": false, + "engines": { + "node": ">=18.12.1" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development nodemon --ignore hotdir --ignore storage --trace-warnings index.js", + "start": "cross-env NODE_ENV=production node index.js", + "lint": "eslint --fix .", + "lint:check": "eslint ." + }, + "dependencies": { + "@langchain/community": "^0.2.23", + "@xenova/transformers": "^2.14.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "epub2": "git+https://github.com/Mintplex-Labs/epub2-static.git#main", + "express": "^4.21.2", + "fix-path": "^4.0.0", + "html-to-text": "^9.0.5", + "ignore": "^5.3.0", + "js-tiktoken": "^1.0.8", + "langchain": "0.1.36", + "mammoth": "^1.6.0", + "mbox-parser": "^1.0.1", + "mime": "^3.0.0", + "moment": "^2.29.4", + "node-html-parser": "^6.1.13", + "node-xlsx": "^0.24.0", + "officeparser": "^4.0.5", + "openai": "4.95.1", + "pdf-parse": "^1.1.1", + "puppeteer": "~21.5.2", + "sharp": "^0.33.5", + "slugify": "^1.6.6", + "strip-ansi": "^7.1.2", + "tesseract.js": "^6.0.0", + "turndown": "7.2.4", + "url-pattern": "^1.0.3", + "uuid": "^9.0.0", + "wavefile": "^11.0.0", + "winston": "^3.13.0", + "youtube-transcript-plus": "^1.1.2", + "youtubei.js": "^9.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "cross-env": "^7.0.3", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unused-imports": "^4.0.0", + "globals": "^17.4.0", + "nodemon": "^2.0.22", + "prettier": "^2.4.1" + }, + "resolutions": { + "string-width": "^4.2.3", + "wrap-ansi": "^7.0.0" + } +} diff --git a/collector/processLink/convert/generic.js b/collector/processLink/convert/generic.js new file mode 100644 index 0000000..6848ade --- /dev/null +++ b/collector/processLink/convert/generic.js @@ -0,0 +1,240 @@ +const { v4 } = require("uuid"); +const { + PuppeteerWebBaseLoader, +} = require("langchain/document_loaders/web/puppeteer"); +const { writeToServerDocuments } = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); +const { + returnResult, + determineContentType, + processAsFile, +} = require("../helpers"); +const { + loadYouTubeTranscript, +} = require("../../utils/extensions/YoutubeTranscript"); +const RuntimeSettings = require("../../utils/runtimeSettings"); +const { htmlToMarkdown } = require("../helpers/htmlToMarkdown"); + +/** + * Scrape a generic URL and return the content in the specified format + * @param {Object} config - The configuration object + * @param {string} config.link - The URL to scrape + * @param {('html' | 'text')} config.captureAs - The format to capture the page content as. Default is 'text' + * @param {{[key: string]: string}} config.scraperHeaders - Custom headers to use when making the request + * @param {{[key: string]: string}} config.metadata - Metadata to use when creating the document + * @param {boolean} config.saveAsDocument - Whether to save the content as a document. Default is true + * @returns {Promise} - The content of the page + */ +async function scrapeGenericUrl({ + link, + captureAs = "text", + scraperHeaders = {}, + metadata = {}, + saveAsDocument = true, +}) { + /** @type {'web' | 'file' | 'youtube'} */ + console.log(`-- Working URL ${link} => (captureAs: ${captureAs}) --`); + let { contentType, processVia } = await determineContentType(link); + console.log(`-- URL determined to be ${contentType} (${processVia}) --`); + + /** + * When the content is a file or a YouTube video, we can use the existing processing functions + * These are self-contained and will return the correct response based on the saveAsDocument flag already + * so we can return the content immediately. + */ + if (processVia === "file") + return await processAsFile({ uri: link, saveAsDocument }); + else if (processVia === "youtube") + return await loadYouTubeTranscript( + { url: link }, + { parseOnly: saveAsDocument === false } + ); + + // Otherwise, assume the content is a webpage and scrape the content from the webpage + const content = await getPageContent({ + link, + captureAs, + headers: scraperHeaders, + }); + if (!content || !content.length) { + console.error(`Resulting URL content was empty at ${link}.`); + return returnResult({ + success: false, + reason: `No URL content found at ${link}.`, + documents: [], + content: null, + saveAsDocument, + }); + } + + // If the captureAs is text, return the content as a string immediately + // so that we dont save the content as a document + if (!saveAsDocument) + return returnResult({ + success: true, + content, + saveAsDocument, + }); + + // Save the content as a document from the URL + const url = new URL(link); + const decodedPathname = decodeURIComponent(url.pathname); + const filename = `${url.hostname}${decodedPathname.replace(/\//g, "_")}`; + const data = { + id: v4(), + url: "file://" + slugify(filename) + ".html", + title: metadata.title || slugify(filename) + ".html", + docAuthor: metadata.docAuthor || "no author found", + description: metadata.description || "No description found.", + docSource: metadata.docSource || "URL link uploaded by the user.", + chunkSource: `link://${link}`, + published: new Date().toLocaleString(), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `url-${slugify(filename)}-${data.id}`, + }); + console.log(`[SUCCESS]: URL ${link} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +/** + * Validate the headers object + * - Keys & Values must be strings and not empty + * - Assemble a new object with only the valid keys and values + * @param {{[key: string]: string}} headers - The headers object to validate + * @returns {{[key: string]: string}} - The validated headers object + */ +function validatedHeaders(headers = {}) { + try { + if (Object.keys(headers).length === 0) return {}; + let validHeaders = {}; + for (const key of Object.keys(headers)) { + if (!key?.trim()) continue; + if (typeof headers[key] !== "string" || !headers[key]?.trim()) continue; + validHeaders[key] = headers[key].trim(); + } + return validHeaders; + } catch (error) { + console.error("Error validating headers", error); + return {}; + } +} + +/** + * Get the content of a page + * @param {Object} config - The configuration object + * @param {string} config.link - The URL to get the content of + * @param {('html' | 'text')} config.captureAs - The format to capture the page content as. Default is 'text' + * @param {{[key: string]: string}} config.headers - Custom headers to use when making the request + * @returns {Promise} - The content of the page + */ +async function getPageContent({ link, captureAs = "text", headers = {} }) { + try { + let pageContents = []; + const runtimeSettings = new RuntimeSettings(); + + /** @type {import('puppeteer').PuppeteerLaunchOptions} */ + let launchConfig = { headless: "new" }; + + /* On MacOS 15.1, the headless=new option causes the browser to crash immediately. + * It is not clear why this is the case, but it is reproducible. Since AnythinglLM + * in production runs in a container, we can disable headless mode to workaround the issue for development purposes. + * + * This may show a popup window when scraping a page in development mode. + * This is expected behavior if seen in development mode on MacOS 15+ + */ + if ( + process.platform === "darwin" && + process.env.NODE_ENV === "development" + ) { + console.log( + "Darwin Development Mode: Disabling headless mode to prevent Chromium from crashing." + ); + launchConfig.headless = "false"; + } + + const loader = new PuppeteerWebBaseLoader(link, { + launchOptions: { + headless: launchConfig.headless, + ignoreHTTPSErrors: true, + args: runtimeSettings.get("browserLaunchArgs"), + }, + gotoOptions: { + waitUntil: "networkidle2", + }, + async evaluate(page, browser) { + const innerHTML = await page.evaluate( + () => document.documentElement.innerHTML + ); + await browser.close(); + if (captureAs === "html") return innerHTML; + return htmlToMarkdown(innerHTML, link); + }, + }); + + // Override scrape method if headers are available + let overrideHeaders = validatedHeaders(headers); + if (Object.keys(overrideHeaders).length > 0) { + loader.scrape = async function () { + const { launch } = await PuppeteerWebBaseLoader.imports(); + const browser = await launch({ + headless: "new", + defaultViewport: null, + ignoreDefaultArgs: ["--disable-extensions"], + ...this.options?.launchOptions, + }); + const page = await browser.newPage(); + await page.setExtraHTTPHeaders(overrideHeaders); + + await page.goto(this.webPath, { + timeout: 180000, + waitUntil: "networkidle2", + ...this.options?.gotoOptions, + }); + + const bodyHTML = this.options?.evaluate + ? await this.options.evaluate(page, browser) + : await page.evaluate(() => document.body.innerHTML); + + await browser.close(); + return bodyHTML; + }; + } + + const docs = await loader.load(); + for (const doc of docs) pageContents.push(doc.pageContent); + return pageContents.join(" "); + } catch (error) { + console.error( + "getPageContent failed to be fetched by puppeteer - falling back to fetch!", + error + ); + } + + try { + const pageText = await fetch(link, { + method: "GET", + headers: { + "Content-Type": "text/plain", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36,gzip(gfe)", + ...validatedHeaders(headers), + }, + }).then((res) => res.text()); + return htmlToMarkdown(pageText, link); + } catch (error) { + console.error("getPageContent failed to be fetched by any method.", error); + } + + return null; +} + +module.exports = { + scrapeGenericUrl, +}; diff --git a/collector/processLink/helpers/htmlToMarkdown.js b/collector/processLink/helpers/htmlToMarkdown.js new file mode 100644 index 0000000..4daefb8 --- /dev/null +++ b/collector/processLink/helpers/htmlToMarkdown.js @@ -0,0 +1,203 @@ +const TurndownService = require("turndown"); +const { parse } = require("node-html-parser"); + +// Image URL base paths to strip entirely - these are typically proxied badges, +// tracking pixels, avatars, or other non-content images that bloat token counts. +const IGNORED_IMG_BASEPATHS = [ + "https://camo.githubusercontent.com", + "https://avatars.githubusercontent.com", + "https://img.shields.io", + "https://badge.fury.io", + "https://badges.gitter.im", + "https://coveralls.io/repos", + "https://travis-ci.org", + "https://circleci.com", + "https://github.com/favicon", +]; + +/** + * Convert raw page HTML into clean markdown using Turndown. + * Strips non-content elements (nav, footer, ads, etc.), hidden elements, + * base64 images, scripts, styles, and resolves relative URLs before converting. + * @param {string} html - Raw HTML string + * @param {string} baseUrl - The URL the page was scraped from + * @returns {string} Cleaned markdown string + */ +function htmlToMarkdown(html, baseUrl) { + if (!html || typeof html !== "string") return ""; + try { + let root = parse(html); + + // Prefer the narrowest content container to avoid page chrome. + // article > main because sites like GitHub put the file tree inside
+ // but scope the README to
. + const content = + root.querySelector("article") || + root.querySelector("main") || + root.querySelector('[role="main"]'); + if (content) root = content; + + const junkSelectors = [ + "script", + "style", + "noscript", + "nav", + "footer", + "header", + "aside", + "iframe", + "svg", + '[role="navigation"]', + '[role="banner"]', + '[role="contentinfo"]', + '[aria-hidden="true"]', + "[hidden]", + ]; + for (const sel of junkSelectors) { + root.querySelectorAll(sel).forEach((el) => el.remove()); + } + + for (const el of root.querySelectorAll("[style]")) { + const style = el.getAttribute("style") || ""; + if ( + /display\s*:\s*none/i.test(style) || + /visibility\s*:\s*hidden/i.test(style) + ) { + el.remove(); + } + } + + if (baseUrl) { + resolveUrls(root, baseUrl); + } + + stripCitations(root); + + const cleanedHtml = root.toString(); + + const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + }); + turndown.remove(["script", "style", "noscript", "iframe", "svg"]); + + turndown.addRule("compactLinks", { + filter: "a", + replacement: function (content, node) { + const href = node.getAttribute("href"); + if (!href) return content; + const text = content.replace(/\s+/g, " ").trim(); + if (!text) return ""; + return `[${text}](${href})`; + }, + }); + + let markdown = turndown.turndown(cleanedHtml); + + // Strip links and images with excessively long URLs (200+ chars) + markdown = markdown.replace(/!\[[^\]]*\]\([^)]{200,}\)\s*/g, ""); + markdown = markdown.replace(/\[[^\]]*\]\([^)]{200,}\)/g, (match) => { + const textMatch = match.match(/\[([^\]]*)\]/); + return textMatch ? textMatch[1] : ""; + }); + + markdown = markdown.replace(/\[[\d]+\]/g, ""); + markdown = markdown.replace(/\[#cite[^\]]*\]/g, ""); + markdown = markdown.replace(/\[edit\]/gi, ""); + + markdown = markdown.replace(/\n{4,}/g, "\n\n\n").trim(); + return markdown; + } catch (error) { + console.error("htmlToMarkdown failed:", error); + try { + return parse(html).text.trim(); + } catch { + return ""; + } + } +} + +/** + * Resolve relative URLs for links and images. Strips base64 and + * ignored-basepath images entirely. Gives alt-less images a filename-based alt. + * @param {import('node-html-parser').HTMLElement} root + * @param {string} baseUrl + */ +function resolveUrls(root, baseUrl) { + for (const a of root.querySelectorAll("a[href]")) { + const href = a.getAttribute("href"); + if (!href || /^(https?:|mailto:|tel:|javascript:|#)/i.test(href)) continue; + try { + a.setAttribute("href", new URL(href, baseUrl).toString()); + } catch {} + } + + for (const img of root.querySelectorAll("img[src]")) { + const src = img.getAttribute("src"); + if (!src) { + img.remove(); + continue; + } + if (src.startsWith("data:")) { + img.remove(); + continue; + } + + const resolvedSrc = /^https?:/i.test(src) + ? src + : (() => { + try { + return new URL(src, baseUrl).toString(); + } catch { + return src; + } + })(); + + if (IGNORED_IMG_BASEPATHS.some((base) => resolvedSrc.startsWith(base))) { + img.remove(); + continue; + } + + if (!/^https?:/i.test(src)) { + try { + img.setAttribute("src", new URL(src, baseUrl).toString()); + } catch {} + } + + const alt = (img.getAttribute("alt") || "").trim(); + if (!alt) { + try { + const pathname = new URL(img.getAttribute("src")).pathname; + const filename = pathname.split("/").pop() || "image"; + img.setAttribute("alt", filename); + } catch { + img.setAttribute("alt", "image"); + } + } + } +} + +/** + * Strip citation/reference superscripts and reference sections + * commonly found on Wikipedia and similar sites. + * @param {import('node-html-parser').HTMLElement} root + */ +function stripCitations(root) { + for (const sup of root.querySelectorAll("sup.reference, sup.noprint")) { + sup.remove(); + } + + for (const sel of [ + ".reflist", + ".references", + ".refbegin", + "#References", + ".catlinks", + ".mw-authority-control", + ]) { + root.querySelectorAll(sel).forEach((el) => el.remove()); + } +} + +module.exports = { htmlToMarkdown }; diff --git a/collector/processLink/helpers/index.js b/collector/processLink/helpers/index.js new file mode 100644 index 0000000..dc00f8f --- /dev/null +++ b/collector/processLink/helpers/index.js @@ -0,0 +1,190 @@ +const path = require("path"); +const { validURL } = require("../../utils/url"); +const { processSingleFile } = require("../../processSingleFile"); +const { downloadURIToFile } = require("../../utils/downloadURIToFile"); +const { ACCEPTED_MIMES } = require("../../utils/constants"); +const { validYoutubeVideoUrl } = require("../../utils/url"); + +/** + * Parse a Content-Type header value and return the MIME type without charset or other parameters. + * @param {string|null} contentTypeHeader - The raw Content-Type header value + * @returns {string|null} - The MIME type (e.g., "application/pdf") or null + */ +function parseContentType(contentTypeHeader) { + if (!contentTypeHeader) return null; + return contentTypeHeader.toLowerCase().split(";")[0].trim() || null; +} + +/** + * Get the content type of a resource + * - Sends a HEAD request to the URL and returns the Content-Type header with a 5 second timeout + * @param {string} url - The URL to get the content type of + * @returns {Promise<{success: boolean, reason: string|null, contentType: string|null}>} - The content type of the resource + */ +async function getContentTypeFromURL(url) { + try { + if (!url || typeof url !== "string" || !validURL(url)) + return { success: false, reason: "Not a valid URL.", contentType: null }; + + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + console.error("Timeout fetching content type for URL:", url.toString()); + }, 5_000); + + const res = await fetch(url, { + method: "HEAD", + signal: abortController.signal, + }).finally(() => clearTimeout(timeout)); + + if (!res.ok) + return { + success: false, + reason: `HTTP ${res.status}: ${res.statusText}`, + contentType: null, + }; + + const contentTypeWithoutCharset = parseContentType( + res.headers.get("Content-Type") + ); + if (!contentTypeWithoutCharset) + return { + success: false, + reason: "No Content-Type found.", + contentType: null, + }; + return { + success: true, + reason: null, + contentType: contentTypeWithoutCharset, + }; + } catch (error) { + return { + success: false, + reason: `Error: ${error.message}`, + contentType: null, + }; + } +} + +/** + * Normalize the result object based on the saveAsDocument flag + * @param {Object} result - The result object to normalize + * @param {boolean} result.success - Whether the result is successful + * @param {string|null} result.reason - The reason for the result + * @param {Object[]} result.documents - The documents from the result + * @param {string|null} result.content - The content of the result + * @param {boolean} result.saveAsDocument - Whether to save the content as a document. Default is true + * @returns {{success: boolean, reason: string|null, documents: Object[], content: string|null}} - The normalized result object + */ +function returnResult({ + success, + reason, + documents, + content, + saveAsDocument = true, +} = {}) { + if (!saveAsDocument) { + return { + success, + content, + }; + } else return { success, reason, documents }; +} + +/** + * Determine the content type of a link - should be a URL + * @param {string} uri - The link to determine the content type of + * @returns {Promise<{contentType: string|null, processVia: 'web' | 'file' | 'youtube'}>} - The content type of the link + */ +async function determineContentType(uri) { + let processVia = "web"; + + // Dont check for content type if it is a YouTube video URL + if (validYoutubeVideoUrl(uri)) + return { contentType: "text/html", processVia: "youtube" }; + + return await getContentTypeFromURL(uri) + .then((result) => { + if (!!result.reason) console.error(result.reason); + + // If the content type is not text/html or text/plain, and it is in the ACCEPTED_MIMES, + // then we can process it as a file + if ( + !!result.contentType && + !["text/html", "text/plain"].includes(result.contentType) && + result.contentType in ACCEPTED_MIMES + ) + processVia = "file"; + + return { contentType: result.contentType, processVia }; + }) + .catch((error) => { + console.error("Error getting content type from URL", error); + return { contentType: null, processVia }; + }); +} + +/** + * Process a link as a file + * @param {string} uri - The link to process as a file + * @param {boolean} saveAsDocument - Whether to save the content as a document. Default is true + * @returns {Promise<{success: boolean, reason: string|null, documents: Object[], content: string|null, saveAsDocument: boolean}>} - The content of the file + */ +async function processAsFile({ uri, saveAsDocument = true }) { + const fileContentResult = await downloadURIToFile(uri); + if (!fileContentResult.success) + return returnResult({ + success: false, + reason: fileContentResult.reason, + documents: [], + content: null, + saveAsDocument, + }); + + const fileFilePath = fileContentResult.fileLocation; + const targetFilename = path.basename(fileFilePath); + + /** + * If the saveAsDocument is false, we are only interested in the text content + * and can ignore the file as a document by using `parseOnly` in the options. + * This will send the file to the Direct Uploads folder instead of the Documents folder. + * that will be deleted by the cleanup-orphan-documents job that runs frequently. The trade off + * is that since it still is in FS we can debug its output or even potentially reuse it for other purposes. + * + * TODO: Improve this process via a new option that will instantly delete the file after processing + * if we find we dont need this file ever after processing. + */ + const processSingleFileResult = await processSingleFile(targetFilename, { + parseOnly: saveAsDocument === false, + }); + if (!processSingleFileResult.success) { + return returnResult({ + success: false, + reason: processSingleFileResult.reason, + documents: [], + content: null, + saveAsDocument, + }); + } + + // If we intend to return only the text content, return the content from the file + // and then delete the file - otherwise it will be saved as a document + if (!saveAsDocument) { + return returnResult({ + success: true, + content: processSingleFileResult.documents[0].pageContent, + saveAsDocument, + }); + } + + return processSingleFileResult; +} + +module.exports = { + parseContentType, + returnResult, + getContentTypeFromURL, + determineContentType, + processAsFile, +}; diff --git a/collector/processLink/index.js b/collector/processLink/index.js new file mode 100644 index 0000000..ad71ca5 --- /dev/null +++ b/collector/processLink/index.js @@ -0,0 +1,47 @@ +const { validURL } = require("../utils/url"); +const { scrapeGenericUrl } = require("./convert/generic"); +const { validateURL } = require("../utils/url"); + +/** + * Process a link and return the text content. This util will save the link as a document + * so it can be used for embedding later. + * @param {string} link - The link to process + * @param {{[key: string]: string}} scraperHeaders - Custom headers to apply when scraping the link + * @param {Object} metadata - Optional metadata to attach to the document + * @returns {Promise<{success: boolean, content: string}>} - Response from collector + */ +async function processLink(link, scraperHeaders = {}, metadata = {}) { + const validatedLink = validateURL(link); + if (!validURL(validatedLink)) + return { success: false, reason: "Not a valid URL." }; + return await scrapeGenericUrl({ + link: validatedLink, + captureAs: "text", + scraperHeaders, + metadata, + saveAsDocument: true, + }); +} + +/** + * Get the text content of a link - does not save the link as a document + * Mostly used in agentic flows/tools calls to get the text content of a link + * @param {string} link - The link to get the text content of + * @param {('html' | 'text' | 'json')} captureAs - The format to capture the page content as + * @returns {Promise<{success: boolean, content: string}>} - Response from collector + */ +async function getLinkText(link, captureAs = "text") { + const validatedLink = validateURL(link); + if (!validURL(validatedLink)) + return { success: false, reason: "Not a valid URL." }; + return await scrapeGenericUrl({ + link: validatedLink, + captureAs, + saveAsDocument: false, + }); +} + +module.exports = { + processLink, + getLinkText, +}; diff --git a/collector/processRawText/index.js b/collector/processRawText/index.js new file mode 100644 index 0000000..f035ed9 --- /dev/null +++ b/collector/processRawText/index.js @@ -0,0 +1,83 @@ +const { v4 } = require("uuid"); +const { writeToServerDocuments } = require("../utils/files"); +const { tokenizeString } = require("../utils/tokenizer"); +const { default: slugify } = require("slugify"); + +// Will remove the last .extension from the input +// and stringify the input + move to lowercase. +function stripAndSlug(input) { + if (!input.includes(".")) return slugify(input, { lower: true }); + return slugify(input.split(".").slice(0, -1).join("-"), { lower: true }); +} + +const METADATA_KEYS = { + possible: { + url: ({ url, title }) => { + let validUrl; + try { + const u = new URL(url); + validUrl = ["https:", "http:"].includes(u.protocol); + } catch {} + + if (validUrl) return `web://${url.toLowerCase()}.website`; + return `file://${stripAndSlug(title)}.txt`; + }, + title: ({ title }) => `${stripAndSlug(title)}.txt`, + docAuthor: ({ docAuthor }) => { + return typeof docAuthor === "string" ? docAuthor : "no author specified"; + }, + description: ({ description }) => { + return typeof description === "string" + ? description + : "no description found"; + }, + docSource: ({ docSource }) => { + return typeof docSource === "string" ? docSource : "no source set"; + }, + chunkSource: ({ chunkSource, title }) => { + return typeof chunkSource === "string" + ? chunkSource + : `${stripAndSlug(title)}.txt`; + }, + published: ({ published }) => { + if (isNaN(Number(published))) return new Date().toLocaleString(); + return new Date(Number(published)).toLocaleString(); + }, + }, +}; + +async function processRawText(textContent, metadata) { + console.log(`-- Working Raw Text doc ${metadata.title} --`); + if (!textContent || textContent.length === 0) { + return { + success: false, + reason: "textContent was empty - nothing to process.", + documents: [], + }; + } + + const data = { + id: v4(), + url: METADATA_KEYS.possible.url(metadata), + title: METADATA_KEYS.possible.title(metadata), + docAuthor: METADATA_KEYS.possible.docAuthor(metadata), + description: METADATA_KEYS.possible.description(metadata), + docSource: METADATA_KEYS.possible.docSource(metadata), + chunkSource: METADATA_KEYS.possible.chunkSource(metadata), + published: METADATA_KEYS.possible.published(metadata), + wordCount: textContent.split(" ").length, + pageContent: textContent, + token_count_estimate: tokenizeString(textContent), + }; + + const document = writeToServerDocuments({ + data, + filename: `raw-${stripAndSlug(metadata.title)}-${data.id}`, + }); + console.log( + `[SUCCESS]: Raw text and metadata saved & ready for embedding.\n` + ); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = { processRawText }; diff --git a/collector/processSingleFile/convert/asAudio.js b/collector/processSingleFile/convert/asAudio.js new file mode 100644 index 0000000..5106106 --- /dev/null +++ b/collector/processSingleFile/convert/asAudio.js @@ -0,0 +1,83 @@ +const { v4 } = require("uuid"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); +const { LocalWhisper } = require("../../utils/WhisperProviders/localWhisper"); +const { OpenAiWhisper } = require("../../utils/WhisperProviders/OpenAiWhisper"); +const { + GenericOpenAiWhisper, +} = require("../../utils/WhisperProviders/GenericOpenAiWhisper"); + +const WHISPER_PROVIDERS = { + openai: OpenAiWhisper, + "generic-openai": GenericOpenAiWhisper, + local: LocalWhisper, +}; + +async function asAudio({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + const WhisperProvider = WHISPER_PROVIDERS.hasOwnProperty( + options?.whisperProvider + ) + ? WHISPER_PROVIDERS[options?.whisperProvider] + : WHISPER_PROVIDERS.local; + + console.log(`-- Working ${filename} --`); + const whisper = new WhisperProvider({ options }); + const { content, error } = await whisper.processFile(fullFilePath, filename); + + if (!!error) { + console.error(`Error encountered for parsing of ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: error, + documents: [], + }; + } + + if (!content?.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "no author found", + description: metadata.description || "No description found.", + docSource: metadata.docSource || "audio file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log( + `[SUCCESS]: ${filename} transcribed, converted & ready for embedding.\n` + ); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asAudio; diff --git a/collector/processSingleFile/convert/asDocx.js b/collector/processSingleFile/convert/asDocx.js new file mode 100644 index 0000000..2080756 --- /dev/null +++ b/collector/processSingleFile/convert/asDocx.js @@ -0,0 +1,63 @@ +const { v4 } = require("uuid"); +const { DocxLoader } = require("langchain/document_loaders/fs/docx"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); + +async function asDocX({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + const loader = new DocxLoader(fullFilePath); + + console.log(`-- Working ${filename} --`); + let pageContent = []; + const docs = await loader.load(); + for (const doc of docs) { + console.log(`-- Parsing content from docx page --`); + if (!doc.pageContent.length) continue; + pageContent.push(doc.pageContent); + } + + if (!pageContent.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + const content = pageContent.join(""); + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "no author found", + description: metadata.description || "No description found.", + docSource: metadata.docSource || "docx file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asDocX; diff --git a/collector/processSingleFile/convert/asEPub.js b/collector/processSingleFile/convert/asEPub.js new file mode 100644 index 0000000..49d3945 --- /dev/null +++ b/collector/processSingleFile/convert/asEPub.js @@ -0,0 +1,61 @@ +const { v4 } = require("uuid"); +const { EPubLoader } = require("langchain/document_loaders/fs/epub"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { default: slugify } = require("slugify"); + +async function asEPub({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + let content = ""; + try { + const loader = new EPubLoader(fullFilePath, { splitChapters: false }); + const docs = await loader.load(); + docs.forEach((doc) => (content += doc.pageContent)); + } catch (err) { + console.error("Could not read epub file!", err); + } + + if (!content?.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + console.log(`-- Working ${filename} --`); + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "Unknown", + description: metadata.description || "Unknown", + docSource: metadata.docSource || "epub file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asEPub; diff --git a/collector/processSingleFile/convert/asImage.js b/collector/processSingleFile/convert/asImage.js new file mode 100644 index 0000000..85e941c --- /dev/null +++ b/collector/processSingleFile/convert/asImage.js @@ -0,0 +1,56 @@ +const { v4 } = require("uuid"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const OCRLoader = require("../../utils/OCRLoader"); +const { default: slugify } = require("slugify"); + +async function asImage({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + let content = await new OCRLoader({ + targetLanguages: options?.ocr?.langList, + }).ocrImage(fullFilePath); + + if (!content?.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + console.log(`-- Working ${filename} --`); + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "Unknown", + description: metadata.description || "Unknown", + docSource: metadata.docSource || "image file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asImage; diff --git a/collector/processSingleFile/convert/asMbox.js b/collector/processSingleFile/convert/asMbox.js new file mode 100644 index 0000000..2954e68 --- /dev/null +++ b/collector/processSingleFile/convert/asMbox.js @@ -0,0 +1,83 @@ +const { v4 } = require("uuid"); +const fs = require("fs"); +const { mboxParser } = require("mbox-parser"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); + +async function asMbox({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + console.log(`-- Working ${filename} --`); + + const mails = await mboxParser(fs.createReadStream(fullFilePath)) + .then((mails) => mails) + .catch((error) => { + console.log(`Could not parse mail items`, error); + return []; + }); + + if (!mails.length) { + console.error(`Resulting mail items was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No mail items found in ${filename}.`, + documents: [], + }; + } + + let item = 1; + const documents = []; + for (const mail of mails) { + if (!mail.hasOwnProperty("text")) continue; + + const content = mail.text; + if (!content) continue; + console.log( + `-- Working on message "${mail.subject || "Unknown subject"}" --` + ); + + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: + metadata.title || + (mail?.subject + ? slugify(mail?.subject?.replace(".", "")) + ".mbox" + : `msg_${item}-${filename}`), + docAuthor: metadata.docAuthor || mail?.from?.text, + description: metadata.description || "No description found.", + docSource: + metadata.docSource || "Mbox message file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + item++; + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}-msg-${item}`, + options: { parseOnly: options.parseOnly }, + }); + documents.push(document); + } + + if (!options.absolutePath) trashFile(fullFilePath); + console.log( + `[SUCCESS]: ${filename} messages converted & ready for embedding.\n` + ); + return { success: true, reason: null, documents }; +} + +module.exports = asMbox; diff --git a/collector/processSingleFile/convert/asOfficeMime.js b/collector/processSingleFile/convert/asOfficeMime.js new file mode 100644 index 0000000..00895ba --- /dev/null +++ b/collector/processSingleFile/convert/asOfficeMime.js @@ -0,0 +1,59 @@ +const { v4 } = require("uuid"); +const officeParser = require("officeparser"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); + +async function asOfficeMime({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + console.log(`-- Working ${filename} --`); + let content = ""; + try { + content = await officeParser.parseOfficeAsync(fullFilePath); + } catch (error) { + console.error(`Could not parse office or office-like file`, error); + } + + if (!content.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "no author found", + description: metadata.description || "No description found.", + docSource: metadata.docSource || "Office file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asOfficeMime; diff --git a/collector/processSingleFile/convert/asPDF/PDFLoader/index.js b/collector/processSingleFile/convert/asPDF/PDFLoader/index.js new file mode 100644 index 0000000..26bcf2b --- /dev/null +++ b/collector/processSingleFile/convert/asPDF/PDFLoader/index.js @@ -0,0 +1,97 @@ +const fs = require("fs").promises; + +class PDFLoader { + constructor(filePath, { splitPages = true } = {}) { + this.filePath = filePath; + this.splitPages = splitPages; + } + + async load() { + const buffer = await fs.readFile(this.filePath); + const { getDocument, version } = await this.getPdfJS(); + + const pdf = await getDocument({ + data: new Uint8Array(buffer), + useWorkerFetch: false, + isEvalSupported: false, + useSystemFonts: true, + }).promise; + + const meta = await pdf.getMetadata().catch(() => null); + const documents = []; + + for (let i = 1; i <= pdf.numPages; i += 1) { + const page = await pdf.getPage(i); + const content = await page.getTextContent(); + + if (content.items.length === 0) { + continue; + } + + let lastY; + const textItems = []; + for (const item of content.items) { + if ("str" in item) { + if (lastY === item.transform[5] || !lastY) { + textItems.push(item.str); + } else { + textItems.push(`\n${item.str}`); + } + lastY = item.transform[5]; + } + } + + const text = textItems.join(""); + documents.push({ + pageContent: text.trim(), + metadata: { + source: this.filePath, + pdf: { + version, + info: meta?.info, + metadata: meta?.metadata, + totalPages: pdf.numPages, + }, + loc: { pageNumber: i }, + }, + }); + } + + if (this.splitPages) { + return documents; + } + + if (documents.length === 0) { + return []; + } + + return [ + { + pageContent: documents.map((doc) => doc.pageContent).join("\n\n"), + metadata: { + source: this.filePath, + pdf: { + version, + info: meta?.info, + metadata: meta?.metadata, + totalPages: pdf.numPages, + }, + }, + }, + ]; + } + + async getPdfJS() { + try { + const pdfjs = await import("pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js"); + return { getDocument: pdfjs.getDocument, version: pdfjs.version }; + } catch (e) { + console.error(e); + throw new Error( + "Failed to load pdf-parse. Please install it with eg. `npm install pdf-parse`." + ); + } + } +} + +module.exports = PDFLoader; diff --git a/collector/processSingleFile/convert/asPDF/index.js b/collector/processSingleFile/convert/asPDF/index.js new file mode 100644 index 0000000..59d26f5 --- /dev/null +++ b/collector/processSingleFile/convert/asPDF/index.js @@ -0,0 +1,86 @@ +const { v4 } = require("uuid"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../../utils/files"); +const { tokenizeString } = require("../../../utils/tokenizer"); +const { default: slugify } = require("slugify"); +const PDFLoader = require("./PDFLoader"); +const OCRLoader = require("../../../utils/OCRLoader"); + +async function asPdf({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + const pdfLoader = new PDFLoader(fullFilePath, { + splitPages: true, + }); + + console.log(`-- Working ${filename} --`); + const pageContent = []; + let docs = await pdfLoader.load(); + + if (docs.length === 0) { + console.log( + `[asPDF] No text content found for ${filename}. Will attempt OCR parse.` + ); + docs = await new OCRLoader({ + targetLanguages: options?.ocr?.langList, + }).ocrPDF(fullFilePath); + } + + for (const doc of docs) { + console.log( + `-- Parsing content from pg ${ + doc.metadata?.loc?.pageNumber || "unknown" + } --` + ); + if (!doc.pageContent || !doc.pageContent.length) continue; + pageContent.push(doc.pageContent); + } + + if (!pageContent.length) { + console.error(`[asPDF] Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + const content = pageContent.join(""); + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: + metadata.docAuthor || + docs[0]?.metadata?.pdf?.info?.Creator || + "no author found", + description: + metadata.description || + docs[0]?.metadata?.pdf?.info?.Title || + "No description found.", + docSource: metadata.docSource || "pdf file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asPdf; diff --git a/collector/processSingleFile/convert/asTxt.js b/collector/processSingleFile/convert/asTxt.js new file mode 100644 index 0000000..62e8e40 --- /dev/null +++ b/collector/processSingleFile/convert/asTxt.js @@ -0,0 +1,59 @@ +const { v4 } = require("uuid"); +const fs = require("fs"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { + createdDate, + trashFile, + writeToServerDocuments, +} = require("../../utils/files"); +const { default: slugify } = require("slugify"); + +async function asTxt({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + let content = ""; + try { + content = fs.readFileSync(fullFilePath, "utf8"); + } catch (err) { + console.error("Could not read file!", err); + } + + if (!content?.length) { + console.error(`Resulting text content was empty for ${filename}.`); + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `No text content found in ${filename}.`, + documents: [], + }; + } + + console.log(`-- Working ${filename} --`); + const data = { + id: v4(), + url: "file://" + fullFilePath, + title: metadata.title || filename, + docAuthor: metadata.docAuthor || "Unknown", + description: metadata.description || "Unknown", + docSource: metadata.docSource || "a text file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data, + filename: `${slugify(filename)}-${data.id}`, + options: { parseOnly: options.parseOnly }, + }); + if (!options.absolutePath) trashFile(fullFilePath); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`); + return { success: true, reason: null, documents: [document] }; +} + +module.exports = asTxt; diff --git a/collector/processSingleFile/convert/asXlsx.js b/collector/processSingleFile/convert/asXlsx.js new file mode 100644 index 0000000..a316d1e --- /dev/null +++ b/collector/processSingleFile/convert/asXlsx.js @@ -0,0 +1,193 @@ +const { v4 } = require("uuid"); +const xlsx = require("node-xlsx").default; +const path = require("path"); +const fs = require("fs"); +const { + createdDate, + trashFile, + writeToServerDocuments, + documentsFolder, +} = require("../../utils/files"); +const { tokenizeString } = require("../../utils/tokenizer"); +const { default: slugify } = require("slugify"); + +function convertToCSV(data) { + return data + .map((row) => + row + .map((cell) => { + if (cell === null || cell === undefined) return ""; + if (typeof cell === "string" && cell.includes(",")) + return `"${cell}"`; + return cell; + }) + .join(",") + ) + .join("\n"); +} + +async function asXlsx({ + fullFilePath = "", + filename = "", + options = {}, + metadata = {}, +}) { + const documents = []; + + try { + const workSheetsFromFile = xlsx.parse(fullFilePath); + + if (options.parseOnly) { + const allSheetContents = []; + let totalWordCount = 0; + const sheetNames = []; + + for (const sheet of workSheetsFromFile) { + const processed = processSheet(sheet); + if (!processed) continue; + + const { name, content, wordCount } = processed; + sheetNames.push(name); + allSheetContents.push(`\nSheet: ${name}\n${content}`); + totalWordCount += wordCount; + } + + if (allSheetContents.length === 0) { + console.log(`No valid sheets found in ${filename}.`); + return { + success: false, + reason: `No valid sheets found in ${filename}.`, + documents: [], + }; + } + + const combinedContent = allSheetContents.join("\n"); + const sheetListText = + sheetNames.length > 1 + ? ` (Sheets: ${sheetNames.join(", ")})` + : ` (Sheet: ${sheetNames[0]})`; + + const combinedData = { + id: v4(), + url: `file://${fullFilePath}`, + title: metadata.title || `${filename}${sheetListText}`, + docAuthor: metadata.docAuthor || "Unknown", + description: + metadata.description || + `Spreadsheet data from ${filename} containing ${sheetNames.length} ${ + sheetNames.length === 1 ? "sheet" : "sheets" + }`, + docSource: metadata.docSource || "an xlsx file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: totalWordCount, + pageContent: combinedContent, + token_count_estimate: tokenizeString(combinedContent), + }; + + const document = writeToServerDocuments({ + data: combinedData, + filename: `${slugify(path.basename(filename))}-${combinedData.id}`, + destinationOverride: null, + options: { parseOnly: true }, + }); + documents.push(document); + console.log(`[SUCCESS]: ${filename} converted & ready for embedding.`); + } else { + const folderName = slugify( + `${path.basename(filename)}-${v4().slice(0, 4)}`, + { + lower: true, + trim: true, + } + ); + const outFolderPath = path.resolve(documentsFolder, folderName); + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + for (const sheet of workSheetsFromFile) { + const processed = processSheet(sheet); + if (!processed) continue; + + const { name, content, wordCount } = processed; + const sheetData = { + id: v4(), + url: `file://${path.join(outFolderPath, `${slugify(name)}.csv`)}`, + title: metadata.title || `${filename} - Sheet:${name}`, + docAuthor: metadata.docAuthor || "Unknown", + description: + metadata.description || `Spreadsheet data from sheet: ${name}`, + docSource: metadata.docSource || "an xlsx file uploaded by the user.", + chunkSource: metadata.chunkSource || "", + published: createdDate(fullFilePath), + wordCount: wordCount, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + const document = writeToServerDocuments({ + data: sheetData, + filename: `sheet-${slugify(name)}`, + destinationOverride: outFolderPath, + options: { parseOnly: options.parseOnly }, + }); + documents.push(document); + console.log( + `[SUCCESS]: Sheet "${name}" converted & ready for embedding.` + ); + } + } + } catch (err) { + console.error("Could not process xlsx file!", err); + return { + success: false, + reason: `Error processing ${filename}: ${err.message}`, + documents: [], + }; + } finally { + if (!options.absolutePath) trashFile(fullFilePath); + } + + if (documents.length === 0) { + console.error(`No valid sheets found in ${filename}.`); + return { + success: false, + reason: `No valid sheets found in ${filename}.`, + documents: [], + }; + } + + console.log( + `[SUCCESS]: ${filename} fully processed. Created ${documents.length} document(s).\n` + ); + return { success: true, reason: null, documents }; +} + +/** + * Processes a single sheet and returns its content and metadata + * @param {{name: string, data: Array>}} sheet - Parsed sheet with name and 2D array of cell values + * @returns {{name: string, content: string, wordCount: number}|null} - Object with name, CSV content, and word count, or null if sheet is empty + */ +function processSheet(sheet) { + try { + const { name, data } = sheet; + const content = convertToCSV(data); + + if (!content?.length) { + console.log(`Sheet "${name}" is empty. Skipping.`); + return null; + } + + console.log(`-- Processing sheet: ${name} --`); + return { + name, + content, + wordCount: content.split(/\s+/).length, + }; + } catch (err) { + console.error(`Error processing sheet "${sheet.name}":`, err); + return null; + } +} + +module.exports = asXlsx; diff --git a/collector/processSingleFile/index.js b/collector/processSingleFile/index.js new file mode 100644 index 0000000..4f2defb --- /dev/null +++ b/collector/processSingleFile/index.js @@ -0,0 +1,95 @@ +const path = require("path"); +const fs = require("fs"); +const { + WATCH_DIRECTORY, + SUPPORTED_FILETYPE_CONVERTERS, +} = require("../utils/constants"); +const { + trashFile, + isTextType, + normalizePath, + isWithin, +} = require("../utils/files"); +const RESERVED_FILES = ["__HOTDIR__.md"]; + +/** + * Process a single file and return the documents + * @param {string} targetFilename - The filename to process + * @param {Object} options - The options for the file processing + * @param {boolean} options.parseOnly - If true, the file will not be saved as a document even when `writeToServerDocuments` is called in the handler. Must be explicitly set to true to use. + * @param {string} options.absolutePath - If provided, use this absolute path instead of resolving relative to WATCH_DIRECTORY. For internal use only. + * @param {Object} metadata - The metadata for the file processing + * @returns {Promise<{success: boolean, reason: string, documents: Object[]}>} - The documents from the file processing + */ +async function processSingleFile(targetFilename, options = {}, metadata = {}) { + const fullFilePath = normalizePath( + options.absolutePath || path.resolve(WATCH_DIRECTORY, targetFilename) + ); + + // If absolute path is not provided, check if the file is within the watch directory + // to prevent unauthorized paths from being processed. + if ( + !options.absolutePath && + !isWithin(path.resolve(WATCH_DIRECTORY), fullFilePath) + ) + return { + success: false, + reason: "Filename is a not a valid path to process.", + documents: [], + }; + + if (RESERVED_FILES.includes(targetFilename)) + return { + success: false, + reason: "Filename is a reserved filename and cannot be processed.", + documents: [], + }; + + if (!fs.existsSync(fullFilePath)) + return { + success: false, + reason: "File does not exist in upload directory.", + documents: [], + }; + + const fileExtension = path.extname(fullFilePath).toLowerCase(); + if (fullFilePath.includes(".") && !fileExtension) { + return { + success: false, + reason: `No file extension found. This file cannot be processed.`, + documents: [], + }; + } + + let processFileAs = fileExtension; + if (!SUPPORTED_FILETYPE_CONVERTERS.hasOwnProperty(fileExtension)) { + if (isTextType(fullFilePath)) { + console.log( + `\x1b[33m[Collector]\x1b[0m The provided filetype of ${fileExtension} does not have a preset and will be processed as .txt.` + ); + processFileAs = ".txt"; + } else { + // If absolute path is provided, do NOT trash the file since it is a user provided path. + if (!options.absolutePath) trashFile(fullFilePath); + return { + success: false, + reason: `File extension ${fileExtension} not supported for parsing and cannot be assumed as text file type.`, + documents: [], + }; + } + } + + const FileTypeProcessor = require(SUPPORTED_FILETYPE_CONVERTERS[ + processFileAs + ]); + return await FileTypeProcessor({ + fullFilePath, + filename: targetFilename, + options, + metadata, + }); +} + +module.exports = { + processSingleFile, +}; diff --git a/collector/storage/.gitignore b/collector/storage/.gitignore new file mode 100644 index 0000000..845482b --- /dev/null +++ b/collector/storage/.gitignore @@ -0,0 +1,2 @@ +tmp/* +!tmp/.placeholder \ No newline at end of file diff --git a/collector/storage/tmp/.placeholder b/collector/storage/tmp/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/collector/utils/EncryptionWorker/index.js b/collector/utils/EncryptionWorker/index.js new file mode 100644 index 0000000..ddc2773 --- /dev/null +++ b/collector/utils/EncryptionWorker/index.js @@ -0,0 +1,77 @@ +const crypto = require("crypto"); + +// Differs from EncryptionManager in that is does not set or define the keys that will be used +// to encrypt or read data and it must be told the key (as base64 string) explicitly that will be used and is provided to +// the class on creation. This key should be the same `key` that is used by the EncryptionManager class. +class EncryptionWorker { + constructor(presetKeyBase64 = "") { + this.key = Buffer.from(presetKeyBase64, "base64"); + this.algorithm = "aes-256-cbc"; + this.separator = ":"; + } + + log(text, ...args) { + console.log(`\x1b[36m[EncryptionManager]\x1b[0m ${text}`, ...args); + } + + /** + * Give a chunk source, parse its payload query param and expand that object back into the URL + * as additional query params + * @param {string} chunkSource + * @returns {URL} Javascript URL object with query params decrypted from payload query param. + */ + expandPayload(chunkSource = "") { + try { + const url = new URL(chunkSource); + if (!url.searchParams.has("payload")) return url; + + const decryptedPayload = this.decrypt(url.searchParams.get("payload")); + const encodedParams = JSON.parse(decryptedPayload); + url.searchParams.delete("payload"); // remove payload prop + + // Add all query params needed to replay as query params + Object.entries(encodedParams).forEach(([key, value]) => + url.searchParams.append(key, value) + ); + return url; + } catch (e) { + console.error(e); + } + return new URL(chunkSource); + } + + encrypt(plainTextString = null) { + try { + if (!plainTextString) + throw new Error("Empty string is not valid for this method."); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + const encrypted = cipher.update(plainTextString, "utf8", "hex"); + return [ + encrypted + cipher.final("hex"), + Buffer.from(iv).toString("hex"), + ].join(this.separator); + } catch (e) { + this.log(e); + return null; + } + } + + decrypt(encryptedString) { + try { + const [encrypted, iv] = encryptedString.split(this.separator); + if (!iv) throw new Error("IV not found"); + const decipher = crypto.createDecipheriv( + this.algorithm, + this.key, + Buffer.from(iv, "hex") + ); + return decipher.update(encrypted, "hex", "utf8") + decipher.final("utf8"); + } catch (e) { + this.log(e); + return null; + } + } +} + +module.exports = { EncryptionWorker }; diff --git a/collector/utils/OCRLoader/index.js b/collector/utils/OCRLoader/index.js new file mode 100644 index 0000000..8f5ae99 --- /dev/null +++ b/collector/utils/OCRLoader/index.js @@ -0,0 +1,355 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { VALID_LANGUAGE_CODES } = require("./validLangs"); + +class OCRLoader { + /** + * The language code(s) to use for the OCR. + * @type {string[]} + */ + language; + /** + * The cache directory for the OCR. + * @type {string} + */ + cacheDir; + + /** + * The constructor for the OCRLoader. + * @param {Object} options - The options for the OCRLoader. + * @param {string} options.targetLanguages - The target languages to use for the OCR as a comma separated string. eg: "eng,deu,..." + */ + constructor({ targetLanguages = "eng" } = {}) { + this.language = this.parseLanguages(targetLanguages); + this.cacheDir = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, `models`, `tesseract`) + : path.resolve(__dirname, `../../../server/storage/models/tesseract`) + ); + + // Ensure the cache directory exists or else Tesseract will persist the cache in the default location. + if (!fs.existsSync(this.cacheDir)) + fs.mkdirSync(this.cacheDir, { recursive: true }); + this.log( + `OCRLoader initialized with language support for:`, + this.language.map((lang) => VALID_LANGUAGE_CODES[lang]).join(", ") + ); + } + + /** + * Parses the language code from a provided comma separated string of language codes. + * @param {string} language - The language code to parse. + * @returns {string[]} The parsed language code. + */ + parseLanguages(language = null) { + try { + if (!language || typeof language !== "string") return ["eng"]; + const langList = language + .split(",") + .map((lang) => (lang.trim() !== "" ? lang.trim() : null)) + .filter(Boolean) + .filter((lang) => VALID_LANGUAGE_CODES.hasOwnProperty(lang)); + if (langList.length === 0) return ["eng"]; + return langList; + } catch (e) { + this.log(`Error parsing languages: ${e.message}`, e.stack); + return ["eng"]; + } + } + + log(text, ...args) { + console.log(`\x1b[36m[OCRLoader]\x1b[0m ${text}`, ...args); + } + + /** + * Loads a PDF file and returns an array of documents. + * This function is reserved to parsing for SCANNED documents - digital documents are not supported in this function + * @returns {Promise<{pageContent: string, metadata: object}[]>} An array of documents with page content and metadata. + */ + async ocrPDF( + filePath, + { maxExecutionTime = 300_000, batchSize = 10, maxWorkers = null } = {} + ) { + if ( + !filePath || + !fs.existsSync(filePath) || + !fs.statSync(filePath).isFile() + ) { + this.log(`File ${filePath} does not exist. Skipping OCR.`); + return []; + } + + const documentTitle = path.basename(filePath); + this.log(`Starting OCR of ${documentTitle}`); + const pdfjs = await import("pdf-parse/lib/pdf.js/v2.0.550/build/pdf.js"); + let buffer = fs.readFileSync(filePath); + + const pdfDocument = await pdfjs.getDocument({ data: buffer }); + + const documents = []; + const meta = await pdfDocument.getMetadata().catch(() => null); + const metadata = { + source: filePath, + pdf: { + version: "v2.0.550", + info: meta?.info, + metadata: meta?.metadata, + totalPages: pdfDocument.numPages, + }, + }; + + const pdfSharp = new PDFSharp({ + validOps: [ + pdfjs.OPS.paintJpegXObject, + pdfjs.OPS.paintImageXObject, + pdfjs.OPS.paintInlineImageXObject, + ], + }); + await pdfSharp.init(); + + const { createWorker, OEM } = require("tesseract.js"); + const BATCH_SIZE = batchSize; + const MAX_EXECUTION_TIME = maxExecutionTime; + const NUM_WORKERS = maxWorkers ?? Math.min(os.cpus().length, 4); + const totalPages = pdfDocument.numPages; + const workerPool = await Promise.all( + Array(NUM_WORKERS) + .fill(0) + .map(() => + createWorker(this.language, OEM.LSTM_ONLY, { + cachePath: this.cacheDir, + }) + ) + ); + + const startTime = Date.now(); + try { + this.log("Bootstrapping OCR completed successfully!", { + MAX_EXECUTION_TIME_MS: MAX_EXECUTION_TIME, + BATCH_SIZE, + MAX_CONCURRENT_WORKERS: NUM_WORKERS, + TOTAL_PAGES: totalPages, + }); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `OCR job took too long to complete (${ + MAX_EXECUTION_TIME / 1000 + } seconds)` + ) + ); + }, MAX_EXECUTION_TIME); + }); + + const processPages = async () => { + for ( + let startPage = 1; + startPage <= totalPages; + startPage += BATCH_SIZE + ) { + const endPage = Math.min(startPage + BATCH_SIZE - 1, totalPages); + const pageNumbers = Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i + ); + this.log(`Working on pages ${startPage} - ${endPage}`); + + const pageQueue = [...pageNumbers]; + const results = []; + const workerPromises = workerPool.map(async (worker, workerIndex) => { + while (pageQueue.length > 0) { + const pageNum = pageQueue.shift(); + this.log( + `\x1b[34m[Worker ${ + workerIndex + 1 + }]\x1b[0m assigned pg${pageNum}` + ); + const page = await pdfDocument.getPage(pageNum); + const imageBuffer = await pdfSharp.pageToBuffer({ page }); + if (!imageBuffer) continue; + const { data } = await worker.recognize(imageBuffer, {}, "text"); + this.log( + `✅ \x1b[34m[Worker ${ + workerIndex + 1 + }]\x1b[0m completed pg${pageNum}` + ); + results.push({ + pageContent: data.text, + metadata: { + ...metadata, + loc: { pageNumber: pageNum }, + }, + }); + } + }); + + await Promise.all(workerPromises); + documents.push( + ...results.sort( + (a, b) => a.metadata.loc.pageNumber - b.metadata.loc.pageNumber + ) + ); + } + return documents; + }; + + await Promise.race([timeoutPromise, processPages()]); + } catch (e) { + this.log(`Error: ${e.message}`, e.stack); + } finally { + global.Image = undefined; + await Promise.all(workerPool.map((worker) => worker.terminate())); + } + + this.log(`Completed OCR of ${documentTitle}!`, { + documentsParsed: documents.length, + totalPages: totalPages, + executionTime: `${((Date.now() - startTime) / 1000).toFixed(2)}s`, + }); + return documents; + } + + /** + * Loads an image file and returns the OCRed text. + * @param {string} filePath - The path to the image file. + * @param {Object} options - The options for the OCR. + * @param {number} options.maxExecutionTime - The maximum execution time of the OCR in milliseconds. + * @returns {Promise} The OCRed text. + */ + async ocrImage(filePath, { maxExecutionTime = 300_000 } = {}) { + let content = ""; + let worker = null; + if ( + !filePath || + !fs.existsSync(filePath) || + !fs.statSync(filePath).isFile() + ) { + this.log(`File ${filePath} does not exist. Skipping OCR.`); + return null; + } + + const documentTitle = path.basename(filePath); + try { + this.log(`Starting OCR of ${documentTitle}`); + const startTime = Date.now(); + const { createWorker, OEM } = require("tesseract.js"); + worker = await createWorker(this.language, OEM.LSTM_ONLY, { + cachePath: this.cacheDir, + }); + + // Race the timeout with the OCR + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `OCR job took too long to complete (${ + maxExecutionTime / 1000 + } seconds)` + ) + ); + }, maxExecutionTime); + }); + + const processImage = async () => { + const { data } = await worker.recognize(filePath, {}, "text"); + content = data.text; + }; + + await Promise.race([timeoutPromise, processImage()]); + this.log(`Completed OCR of ${documentTitle}!`, { + executionTime: `${((Date.now() - startTime) / 1000).toFixed(2)}s`, + }); + + return content; + } catch (e) { + this.log(`Error: ${e.message}`); + return null; + } finally { + //eslint-disable-next-line + if (!worker) return; + await worker.terminate(); + } + } +} + +/** + * Converts a PDF page to a buffer using Sharp. + * @param {Object} options - The options for the Sharp PDF page object. + * @param {Object} options.page - The PDFJS page proxy object. + * @returns {Promise} The buffer of the page. + */ +class PDFSharp { + constructor({ validOps = [] } = {}) { + this.sharp = null; + this.validOps = validOps; + } + + log(text, ...args) { + console.log(`\x1b[36m[PDFSharp]\x1b[0m ${text}`, ...args); + } + + async init() { + this.sharp = (await import("sharp")).default; + } + + /** + * Converts a PDF page to a buffer. + * @param {Object} options - The options for the Sharp PDF page object. + * @param {Object} options.page - The PDFJS page proxy object. + * @returns {Promise} The buffer of the page. + */ + async pageToBuffer({ page }) { + if (!this.sharp) await this.init(); + try { + this.log(`Converting page ${page.pageNumber} to image...`); + const ops = await page.getOperatorList(); + const pageImages = ops.fnArray.length; + + for (let i = 0; i < pageImages; i++) { + try { + if (!this.validOps.includes(ops.fnArray[i])) continue; + + const name = ops.argsArray[i][0]; + const img = await page.objs.get(name); + const { width, height } = img; + const size = img.data.length; + const channels = size / width / height; + const targetDPI = 70; + const targetWidth = Math.floor(width * (targetDPI / 72)); + const targetHeight = Math.floor(height * (targetDPI / 72)); + + const image = this.sharp(img.data, { + raw: { width, height, channels }, + density: targetDPI, + }) + .resize({ + width: targetWidth, + height: targetHeight, + fit: "fill", + }) + .withMetadata({ + density: targetDPI, + resolution: targetDPI, + }) + .png(); + + // For debugging purposes + // await image.toFile(path.resolve(__dirname, `../../storage/`, `pg${page.pageNumber}.png`)); + return await image.toBuffer(); + } catch (error) { + this.log(`Iteration error: ${error.message}`, error.stack); + continue; + } + } + this.log(`No valid images found on page ${page.pageNumber}`); + return null; + } catch (error) { + this.log(`Error: ${error.message}`, error.stack); + return null; + } + } +} + +module.exports = OCRLoader; diff --git a/collector/utils/OCRLoader/validLangs.js b/collector/utils/OCRLoader/validLangs.js new file mode 100644 index 0000000..5bc807a --- /dev/null +++ b/collector/utils/OCRLoader/validLangs.js @@ -0,0 +1,155 @@ +/* + +To get the list of valid language codes - do the following: +Open the following URL in your browser: https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html + +Check this element is the proper table tbody with all the codes via console: +document.getElementsByTagName('table').item(0).children.item(1) + +Now, copy the following code and paste it into the console: +function parseLangs() { +let langs = {}; + Array.from(document.getElementsByTagName('table').item(0).children.item(1).children).forEach((el) => { + const [codeEl, languageEl, ...rest] = el.children + const code = codeEl.innerText.trim() + const language = languageEl.innerText.trim() + if (!!code && !!language) langs[code] = language + }) + return langs; +} + +now, run the function: +copy(parseLangs()) +*/ + +const VALID_LANGUAGE_CODES = { + afr: "Afrikaans", + amh: "Amharic", + ara: "Arabic", + asm: "Assamese", + aze: "Azerbaijani", + aze_cyrl: "Azerbaijani - Cyrilic", + bel: "Belarusian", + ben: "Bengali", + bod: "Tibetan", + bos: "Bosnian", + bre: "Breton", + bul: "Bulgarian", + cat: "Catalan; Valencian", + ceb: "Cebuano", + ces: "Czech", + chi_sim: "Chinese - Simplified", + chi_tra: "Chinese - Traditional", + chr: "Cherokee", + cos: "Corsican", + cym: "Welsh", + dan: "Danish", + dan_frak: "Danish - Fraktur (contrib)", + deu: "German", + deu_frak: "German - Fraktur (contrib)", + deu_latf: "German (Fraktur Latin)", + dzo: "Dzongkha", + ell: "Greek, Modern (1453-)", + eng: "English", + enm: "English, Middle (1100-1500)", + epo: "Esperanto", + equ: "Math / equation detection module", + est: "Estonian", + eus: "Basque", + fao: "Faroese", + fas: "Persian", + fil: "Filipino (old - Tagalog)", + fin: "Finnish", + fra: "French", + frk: "German - Fraktur (now deu_latf)", + frm: "French, Middle (ca.1400-1600)", + fry: "Western Frisian", + gla: "Scottish Gaelic", + gle: "Irish", + glg: "Galician", + grc: "Greek, Ancient (to 1453) (contrib)", + guj: "Gujarati", + hat: "Haitian; Haitian Creole", + heb: "Hebrew", + hin: "Hindi", + hrv: "Croatian", + hun: "Hungarian", + hye: "Armenian", + iku: "Inuktitut", + ind: "Indonesian", + isl: "Icelandic", + ita: "Italian", + ita_old: "Italian - Old", + jav: "Javanese", + jpn: "Japanese", + kan: "Kannada", + kat: "Georgian", + kat_old: "Georgian - Old", + kaz: "Kazakh", + khm: "Central Khmer", + kir: "Kirghiz; Kyrgyz", + kmr: "Kurmanji (Kurdish - Latin Script)", + kor: "Korean", + kor_vert: "Korean (vertical)", + kur: "Kurdish (Arabic Script)", + lao: "Lao", + lat: "Latin", + lav: "Latvian", + lit: "Lithuanian", + ltz: "Luxembourgish", + mal: "Malayalam", + mar: "Marathi", + mkd: "Macedonian", + mlt: "Maltese", + mon: "Mongolian", + mri: "Maori", + msa: "Malay", + mya: "Burmese", + nep: "Nepali", + nld: "Dutch; Flemish", + nor: "Norwegian", + oci: "Occitan (post 1500)", + ori: "Oriya", + osd: "Orientation and script detection module", + pan: "Panjabi; Punjabi", + pol: "Polish", + por: "Portuguese", + pus: "Pushto; Pashto", + que: "Quechua", + ron: "Romanian; Moldavian; Moldovan", + rus: "Russian", + san: "Sanskrit", + sin: "Sinhala; Sinhalese", + slk: "Slovak", + slk_frak: "Slovak - Fraktur (contrib)", + slv: "Slovenian", + snd: "Sindhi", + spa: "Spanish; Castilian", + spa_old: "Spanish; Castilian - Old", + sqi: "Albanian", + srp: "Serbian", + srp_latn: "Serbian - Latin", + sun: "Sundanese", + swa: "Swahili", + swe: "Swedish", + syr: "Syriac", + tam: "Tamil", + tat: "Tatar", + tel: "Telugu", + tgk: "Tajik", + tgl: "Tagalog (new - Filipino)", + tha: "Thai", + tir: "Tigrinya", + ton: "Tonga", + tur: "Turkish", + uig: "Uighur; Uyghur", + ukr: "Ukrainian", + urd: "Urdu", + uzb: "Uzbek", + uzb_cyrl: "Uzbek - Cyrilic", + vie: "Vietnamese", + yid: "Yiddish", + yor: "Yoruba", +}; + +module.exports.VALID_LANGUAGE_CODES = VALID_LANGUAGE_CODES; diff --git a/collector/utils/WhisperProviders/GenericOpenAiWhisper.js b/collector/utils/WhisperProviders/GenericOpenAiWhisper.js new file mode 100644 index 0000000..d1b51f2 --- /dev/null +++ b/collector/utils/WhisperProviders/GenericOpenAiWhisper.js @@ -0,0 +1,51 @@ +const fs = require("fs"); + +class GenericOpenAiWhisper { + constructor({ options }) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!options.WhisperGenericOpenAiBaseUrl) + throw new Error("No base URL was set."); + + this.openai = new OpenAIApi({ + baseURL: options.WhisperGenericOpenAiBaseUrl, + apiKey: options.WhisperGenericOpenAiApiKey || null, + }); + this.model = options.WhisperGenericOpenAiModel || "whisper-small"; + this.temperature = 0; + this.#log("Initialized."); + } + + #log(text, ...args) { + console.log(`\x1b[32m[GenericOpenAiWhisper]\x1b[0m ${text}`, ...args); + } + + async processFile(fullFilePath) { + return await this.openai.audio.transcriptions + .create({ + file: fs.createReadStream(fullFilePath), + model: this.model, + temperature: this.temperature, + }) + .then((response) => { + if (!response) { + return { + content: "", + error: "No content was able to be transcribed.", + }; + } + + return { content: response.text, error: null }; + }) + .catch((error) => { + this.#log( + `Could not get any response from openai compatible whisper endpoint`, + error.message + ); + return { content: "", error: error.message }; + }); + } +} + +module.exports = { + GenericOpenAiWhisper, +}; diff --git a/collector/utils/WhisperProviders/OpenAiWhisper.js b/collector/utils/WhisperProviders/OpenAiWhisper.js new file mode 100644 index 0000000..e2405b6 --- /dev/null +++ b/collector/utils/WhisperProviders/OpenAiWhisper.js @@ -0,0 +1,49 @@ +const fs = require("fs"); + +class OpenAiWhisper { + constructor({ options }) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!options.openAiKey) throw new Error("No OpenAI API key was set."); + + this.openai = new OpenAIApi({ + apiKey: options.openAiKey, + }); + this.model = "whisper-1"; + this.temperature = 0; + this.#log("Initialized."); + } + + #log(text, ...args) { + console.log(`\x1b[32m[OpenAiWhisper]\x1b[0m ${text}`, ...args); + } + + async processFile(fullFilePath) { + return await this.openai.audio.transcriptions + .create({ + file: fs.createReadStream(fullFilePath), + model: this.model, + temperature: this.temperature, + }) + .then((response) => { + if (!response) { + return { + content: "", + error: "No content was able to be transcribed.", + }; + } + + return { content: response.text, error: null }; + }) + .catch((error) => { + this.#log( + `Could not get any response from openai whisper`, + error.message + ); + return { content: "", error: error.message }; + }); + } +} + +module.exports = { + OpenAiWhisper, +}; diff --git a/collector/utils/WhisperProviders/ffmpeg/index.js b/collector/utils/WhisperProviders/ffmpeg/index.js new file mode 100644 index 0000000..7e0ed75 --- /dev/null +++ b/collector/utils/WhisperProviders/ffmpeg/index.js @@ -0,0 +1,114 @@ +const fs = require("fs"); +const path = require("path"); +const { execSync, spawnSync } = require("child_process"); +const { patchShellEnvironmentPath } = require("../../shell"); +/** + * Custom FFMPEG wrapper class for audio file conversion. + * Replaces deprecated fluent-ffmpeg package. + * Locates ffmpeg binary and converts audio files to required + * WAV format (16k hz mono 32f) for Whisper transcription. + * + * @class FFMPEGWrapper + */ +class FFMPEGWrapper { + static _instance; + + constructor() { + if (FFMPEGWrapper._instance) return FFMPEGWrapper._instance; + FFMPEGWrapper._instance = this; + this._ffmpegPath = null; + } + + log(text, ...args) { + console.log(`\x1b[35m[FFMPEG]\x1b[0m ${text}`, ...args); + } + + /** + * Locates ffmpeg binary. + * Uses fix-path on non-Windows platforms to ensure we can find ffmpeg. + * + * @returns {Promise} Path to ffmpeg binary + * @throws {Error} + */ + async ffmpegPath() { + if (this._ffmpegPath) return this._ffmpegPath; + await patchShellEnvironmentPath(); + + try { + const which = process.platform === "win32" ? "where" : "which"; + const result = execSync(`${which} ffmpeg`, { encoding: "utf8" }).trim(); + const candidatePath = result?.split("\n")?.[0]?.trim(); + if (!candidatePath) throw new Error("FFMPEG candidate path not found."); + if (!this.isValidFFMPEG(candidatePath)) + throw new Error("FFMPEG candidate path is not valid ffmpeg binary."); + + this.log(`Found FFMPEG binary at ${candidatePath}`); + this._ffmpegPath = candidatePath; + return this._ffmpegPath; + } catch (error) { + this.log(error.message); + } + + throw new Error("FFMPEG binary not found."); + } + + /** + * Validates that path points to a valid ffmpeg binary. + * Runs ffmpeg -version command. + * + * @param {string} pathToTest - Path of ffmpeg binary + * @returns {boolean} + */ + isValidFFMPEG(pathToTest) { + try { + if (!pathToTest || !fs.existsSync(pathToTest)) return false; + execSync(`"${pathToTest}" -version`, { encoding: "utf8", stdio: "pipe" }); + return true; + } catch { + return false; + } + } + + /** + * Converts audio file to WAV format with required parameters for Whisper. + * Output: 16k hz, mono, 32bit float. + * + * @param {string} inputPath - Input path for audio file (any format supported by ffmpeg) + * @param {string} outputPath - Output path for converted file + * @returns {Promise} + * @throws {Error} If ffmpeg binary cannot be found or conversion fails + */ + async convertAudioToWav(inputPath, outputPath) { + if (!fs.existsSync(inputPath)) + throw new Error(`Input file ${inputPath} does not exist.`); + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); + + this.log(`Converting ${path.basename(inputPath)} to WAV format...`); + // Convert to 16k hz mono 32f + const result = spawnSync( + await this.ffmpegPath(), + [ + "-i", + inputPath, + "-ar", + "16000", + "-ac", + "1", + "-acodec", + "pcm_f32le", + "-y", + outputPath, + ], + { encoding: "utf8" } + ); + + // ffmpeg writes progress to stderr + if (result.stderr) this.log(result.stderr.trim()); + if (result.status !== 0) throw new Error(`FFMPEG conversion failed`); + this.log(`Conversion complete: ${path.basename(outputPath)}`); + return true; + } +} + +module.exports = { FFMPEGWrapper }; diff --git a/collector/utils/WhisperProviders/localWhisper.js b/collector/utils/WhisperProviders/localWhisper.js new file mode 100644 index 0000000..6ee124f --- /dev/null +++ b/collector/utils/WhisperProviders/localWhisper.js @@ -0,0 +1,197 @@ +const fs = require("fs"); +const path = require("path"); +const { v4 } = require("uuid"); +const defaultWhisper = "Xenova/whisper-small"; // Model Card: https://huggingface.co/Xenova/whisper-small +const fileSize = { + "Xenova/whisper-small": "250mb", + "Xenova/whisper-large": "1.56GB", +}; + +class LocalWhisper { + constructor({ options }) { + this.model = options?.WhisperModelPref ?? defaultWhisper; + this.fileSize = fileSize[this.model]; + this.cacheDir = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, `models`) + : path.resolve(__dirname, `../../../server/storage/models`) + ); + + this.modelPath = path.resolve(this.cacheDir, ...this.model.split("/")); + // Make directory when it does not exist in existing installations + if (!fs.existsSync(this.cacheDir)) + fs.mkdirSync(this.cacheDir, { recursive: true }); + + this.#log("Initialized."); + } + + #log(text, ...args) { + console.log(`\x1b[32m[LocalWhisper]\x1b[0m ${text}`, ...args); + } + + #validateAudioFile(wavFile) { + const sampleRate = wavFile.fmt.sampleRate; + const duration = wavFile.data.samples / sampleRate; + + // Most speech recognition systems expect minimum 8kHz + // But we'll set it lower to be safe + if (sampleRate < 4000) { + // 4kHz minimum + throw new Error( + "Audio file sample rate is too low for accurate transcription. Minimum required is 4kHz." + ); + } + + // Typical audio file duration limits + const MAX_DURATION_SECONDS = 4 * 60 * 60; // 4 hours + if (duration > MAX_DURATION_SECONDS) { + throw new Error("Audio file duration exceeds maximum limit of 4 hours."); + } + + // Check final sample count after upsampling to prevent memory issues + const targetSampleRate = 16000; + const upsampledSamples = duration * targetSampleRate; + const MAX_SAMPLES = 230_400_000; // ~4 hours at 16kHz + + if (upsampledSamples > MAX_SAMPLES) { + throw new Error("Audio file exceeds maximum allowed length."); + } + + return true; + } + + async #convertToWavAudioData(sourcePath) { + try { + let buffer; + const wavefile = require("wavefile"); + const { FFMPEGWrapper } = require("./ffmpeg"); + const ffmpeg = new FFMPEGWrapper(); + const outFolder = path.resolve(__dirname, `../../storage/tmp`); + if (!fs.existsSync(outFolder)) + fs.mkdirSync(outFolder, { recursive: true }); + + const outputFile = path.resolve(outFolder, `${v4()}.wav`); + const success = await ffmpeg.convertAudioToWav(sourcePath, outputFile); + if (!success) + throw new Error( + "[Conversion Failed]: Could not convert file to .wav format!" + ); + + buffer = fs.readFileSync(outputFile); + fs.rmSync(outputFile); + + const wavFile = new wavefile.WaveFile(buffer); + try { + this.#validateAudioFile(wavFile); + } catch (error) { + this.#log(`Audio validation failed: ${error.message}`); + throw new Error(`Invalid audio file: ${error.message}`); + } + + // Although we use ffmpeg to convert to the correct format (16k hz 32f), + // different versions of ffmpeg produce different results based on the + // environment. To ensure consistency, we convert to the correct format again. + wavFile.toBitDepth("32f"); + wavFile.toSampleRate(16000); + + let audioData = wavFile.getSamples(); + if (Array.isArray(audioData)) { + if (audioData.length > 1) { + const SCALING_FACTOR = Math.sqrt(2); + + // Merge channels into first channel to save memory + for (let i = 0; i < audioData[0].length; ++i) { + audioData[0][i] = + (SCALING_FACTOR * (audioData[0][i] + audioData[1][i])) / 2; + } + } + audioData = audioData[0]; + } + + return audioData; + } catch (error) { + console.error(`convertToWavAudioData`, error); + return null; + } + } + + async client() { + if (!fs.existsSync(this.modelPath)) { + this.#log( + `The native whisper model has never been run and will be downloaded right now. Subsequent runs will be faster. (~${this.fileSize})` + ); + } + + try { + // Convert ESM to CommonJS via import so we can load this library. + const pipeline = (...args) => + import("@xenova/transformers").then(({ pipeline }) => { + return pipeline(...args); + }); + return await pipeline("automatic-speech-recognition", this.model, { + cache_dir: this.cacheDir, + ...(!fs.existsSync(this.modelPath) + ? { + // Show download progress if we need to download any files + progress_callback: (data) => { + if (!data.hasOwnProperty("progress")) return; + console.log( + `\x1b[34m[ONNXWhisper - Downloading Model Files]\x1b[0m ${ + data.file + } ${~~data?.progress}%` + ); + }, + } + : {}), + }); + } catch (error) { + let errMsg = error.message; + if (errMsg.includes("Could not locate file")) { + errMsg = + "The native whisper model failed to download from the huggingface.co CDN. Your internet connection may be unstable or blocked by Huggingface.co - you will need to download the model manually and place it in the storage/models folder to use local Whisper transcription."; + } + + this.#log( + `Failed to load the native whisper model: ${errMsg}`, + error.stack + ); + throw new Error(errMsg); + } + } + + async processFile(fullFilePath, filename) { + try { + const audioDataPromise = new Promise((resolve) => + this.#convertToWavAudioData(fullFilePath).then((audioData) => + resolve(audioData) + ) + ); + const [audioData, transcriber] = await Promise.all([ + audioDataPromise, + this.client(), + ]); + + if (!audioData) { + this.#log(`Failed to parse content from ${filename}.`); + return { + content: null, + error: `Failed to parse content from ${filename}.`, + }; + } + + this.#log(`Transcribing audio data to text...`); + const { text } = await transcriber(audioData, { + chunk_length_s: 30, + stride_length_s: 5, + }); + + return { content: text, error: null }; + } catch (error) { + return { content: null, error: error.message }; + } + } +} + +module.exports = { + LocalWhisper, +}; diff --git a/collector/utils/comKey/index.js b/collector/utils/comKey/index.js new file mode 100644 index 0000000..a2e2f52 --- /dev/null +++ b/collector/utils/comKey/index.js @@ -0,0 +1,54 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const keyPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../../server/storage/comkey`) + : path.resolve( + process.env.STORAGE_DIR ?? + path.resolve(__dirname, `../../../server/storage`), + `comkey` + ); + +class CommunicationKey { + #pubKeyName = "ipc-pub.pem"; + #storageLoc = keyPath; + + constructor() {} + + log(text, ...args) { + console.log(`\x1b[36m[CommunicationKeyVerify]\x1b[0m ${text}`, ...args); + } + + #readPublicKey() { + return fs.readFileSync(path.resolve(this.#storageLoc, this.#pubKeyName)); + } + + // Given a signed payload from private key from /app/server/ this signature should + // decode to match the textData provided. This class does verification only in collector. + // Note: The textData is typically the JSON stringified body sent to the document processor API. + verify(signature = "", textData = "") { + try { + let data = textData; + if (typeof textData !== "string") data = JSON.stringify(data); + return crypto.verify( + "RSA-SHA256", + Buffer.from(data), + this.#readPublicKey(), + Buffer.from(signature, "hex") + ); + } catch {} + return false; + } + + // Use the rolling public-key to decrypt arbitrary data that was encrypted via the private key on the server side CommunicationKey class + // that we know was done with the same key-pair and the given input is in base64 format already. + // Returns plaintext string of the data that was encrypted. + decrypt(base64String = "") { + return crypto + .publicDecrypt(this.#readPublicKey(), Buffer.from(base64String, "base64")) + .toString(); + } +} + +module.exports = { CommunicationKey }; diff --git a/collector/utils/constants.js b/collector/utils/constants.js new file mode 100644 index 0000000..4813e6e --- /dev/null +++ b/collector/utils/constants.js @@ -0,0 +1,92 @@ +const WATCH_DIRECTORY = require("path").resolve(__dirname, "../hotdir"); + +const ACCEPTED_MIMES = { + "text/plain": [".txt", ".md", ".org", ".adoc", ".rst"], + "text/html": [".html"], + "text/csv": [".csv"], + "application/json": [".json"], + // TODO: Create asDoc.js that works for standard MS Word files. + // "application/msword": [".doc"], + + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [ + ".docx", + ], + "application/vnd.openxmlformats-officedocument.presentationml.presentation": [ + ".pptx", + ], + + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [ + ".xlsx", + ], + + "application/vnd.oasis.opendocument.text": [".odt"], + "application/vnd.oasis.opendocument.presentation": [".odp"], + + "application/pdf": [".pdf"], + "application/mbox": [".mbox"], + + "audio/wav": [".wav"], + "audio/mpeg": [".mp3"], + "audio/ogg": [".ogg", ".oga"], + "audio/opus": [".opus"], + "audio/mp4": [".m4a"], + "audio/x-m4a": [".m4a"], + "audio/webm": [".webm"], + + "video/mp4": [".mp4"], + "video/mpeg": [".mpeg"], + "application/epub+zip": [".epub"], + "image/png": [".png"], + "image/jpeg": [".jpg"], + "image/jpg": [".jpg"], + "image/webp": [".webp"], +}; + +const SUPPORTED_FILETYPE_CONVERTERS = { + ".txt": "./convert/asTxt.js", + ".md": "./convert/asTxt.js", + ".org": "./convert/asTxt.js", + ".adoc": "./convert/asTxt.js", + ".rst": "./convert/asTxt.js", + ".csv": "./convert/asTxt.js", + ".json": "./convert/asTxt.js", + + ".html": "./convert/asTxt.js", + ".pdf": "./convert/asPDF/index.js", + + ".docx": "./convert/asDocx.js", + // TODO: Create asDoc.js that works for standard MS Word files. + // ".doc": "./convert/asDoc.js", + + ".pptx": "./convert/asOfficeMime.js", + + ".odt": "./convert/asOfficeMime.js", + ".odp": "./convert/asOfficeMime.js", + + ".xlsx": "./convert/asXlsx.js", + + ".mbox": "./convert/asMbox.js", + + ".epub": "./convert/asEPub.js", + + ".mp3": "./convert/asAudio.js", + ".wav": "./convert/asAudio.js", + ".mp4": "./convert/asAudio.js", + ".mpeg": "./convert/asAudio.js", + ".ogg": "./convert/asAudio.js", + ".oga": "./convert/asAudio.js", + ".opus": "./convert/asAudio.js", + ".m4a": "./convert/asAudio.js", + ".webm": "./convert/asAudio.js", + + ".png": "./convert/asImage.js", + ".jpg": "./convert/asImage.js", + ".jpeg": "./convert/asImage.js", + ".webp": "./convert/asImage.js", +}; + +module.exports = { + SUPPORTED_FILETYPE_CONVERTERS, + WATCH_DIRECTORY, + ACCEPTED_MIMES, +}; diff --git a/collector/utils/downloadURIToFile/index.js b/collector/utils/downloadURIToFile/index.js new file mode 100644 index 0000000..ce91264 --- /dev/null +++ b/collector/utils/downloadURIToFile/index.js @@ -0,0 +1,90 @@ +const { WATCH_DIRECTORY, ACCEPTED_MIMES } = require("../constants"); +const fs = require("fs"); +const path = require("path"); +const { pipeline } = require("stream/promises"); +const { validURL } = require("../url"); +const { default: slugify } = require("slugify"); + +// Add a custom slugify extension for slashing to handle URLs with paths. +slugify.extend({ "/": "-" }); + +/** + * Maps a MIME type to the preferred file extension using ACCEPTED_MIMES. + * Returns null if the MIME type is not recognized or if there are no possible extensions. + * @param {string} mimeType - The MIME type to resolve (e.g., "application/pdf") + * @returns {string|null} - The file extension (e.g., ".pdf") or null + */ +function mimeToExtension(mimeType) { + if (!mimeType || !ACCEPTED_MIMES.hasOwnProperty(mimeType)) return null; + const possibleExtensions = ACCEPTED_MIMES[mimeType] ?? []; + if (possibleExtensions.length === 0) return null; + return possibleExtensions[0]; +} + +/** + * Download a file to the hotdir + * @param {string} url - The URL of the file to download + * @param {number} maxTimeout - The maximum timeout in milliseconds + * @returns {Promise<{success: boolean, fileLocation: string|null, reason: string|null}>} - The path to the downloaded file + */ +async function downloadURIToFile(url, maxTimeout = 10_000) { + if (!url || typeof url !== "string" || !validURL(url)) + return { success: false, reason: "Not a valid URL.", fileLocation: null }; + + try { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + console.error( + `Timeout ${maxTimeout}ms reached while downloading file for URL:`, + url.toString() + ); + }, maxTimeout); + + const res = await fetch(url, { signal: abortController.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + return res; + }) + .finally(() => clearTimeout(timeout)); + + const urlObj = new URL(url); + const sluggedPath = slugify(urlObj.pathname, { lower: true }); + let filename = `${urlObj.hostname}-${sluggedPath}`; + + const existingExt = path.extname(filename).toLowerCase(); + const { SUPPORTED_FILETYPE_CONVERTERS } = require("../constants"); + + // If the filename does not already have a supported file extension, + // try to infer one from the response Content-Type header. + // This handles URLs like https://arxiv.org/pdf/2307.10265 where the + // path has no explicit extension but the server responds with + // Content-Type: application/pdf. + if (!SUPPORTED_FILETYPE_CONVERTERS.hasOwnProperty(existingExt)) { + const { parseContentType } = require("../../processLink/helpers"); + const contentType = parseContentType(res.headers.get("Content-Type")); + const inferredExt = mimeToExtension(contentType); + if (inferredExt) { + console.log( + `[Collector] URL path has no recognized extension. Inferred ${inferredExt} from Content-Type: ${contentType}` + ); + filename += inferredExt; + } + } + + const localFilePath = path.join(WATCH_DIRECTORY, filename); + const writeStream = fs.createWriteStream(localFilePath); + await pipeline(res.body, writeStream); + + console.log(`[SUCCESS]: File ${localFilePath} downloaded to hotdir.`); + return { success: true, fileLocation: localFilePath, reason: null }; + } catch (error) { + console.error(`Error writing to hotdir: ${error} for URL: ${url}`); + return { success: false, reason: error.message, fileLocation: null }; + } +} + +module.exports = { + downloadURIToFile, + mimeToExtension, +}; diff --git a/collector/utils/extensions/Confluence/ConfluenceLoader/index.js b/collector/utils/extensions/Confluence/ConfluenceLoader/index.js new file mode 100644 index 0000000..687cf2a --- /dev/null +++ b/collector/utils/extensions/Confluence/ConfluenceLoader/index.js @@ -0,0 +1,154 @@ +/* + * This is a custom implementation of the Confluence langchain loader. There was an issue where + * code blocks were not being extracted. This is a temporary fix until this issue is resolved.*/ + +const { htmlToText } = require("html-to-text"); + +class ConfluencePagesLoader { + constructor({ + baseUrl, + spaceKey, + username, + accessToken, + limit = 25, + expand = "body.storage,version", + personalAccessToken, + cloud = true, + bypassSSL = false, + }) { + this.baseUrl = baseUrl; + this.spaceKey = spaceKey; + this.username = username; + this.accessToken = accessToken; + this.limit = limit; + this.expand = expand; + this.personalAccessToken = personalAccessToken; + this.cloud = cloud; + this.bypassSSL = bypassSSL; + this.log("Initialized Confluence Loader"); + if (this.bypassSSL) + this.log("!!SSL bypass is enabled!! Use at your own risk!!"); + } + + log(message, ...args) { + console.log(`\x1b[36m[Confluence Loader]\x1b[0m ${message}`, ...args); + } + + get authorizationHeader() { + if (this.personalAccessToken) { + return `Bearer ${this.personalAccessToken}`; + } else if (this.username && this.accessToken) { + const authToken = Buffer.from( + `${this.username}:${this.accessToken}` + ).toString("base64"); + return `Basic ${authToken}`; + } + return undefined; + } + + async load(options) { + try { + const pages = await this.fetchAllPagesInSpace( + options?.start, + options?.limit + ); + return pages.map((page) => this.createDocumentFromPage(page)); + } catch (error) { + this.log("Error:", error); + return []; + } + } + + async fetchConfluenceData(url) { + try { + const initialHeaders = { + "Content-Type": "application/json", + Accept: "application/json", + }; + const authHeader = this.authorizationHeader; + if (authHeader) initialHeaders.Authorization = authHeader; + + // If SSL bypass is enabled, set the NODE_TLS_REJECT_UNAUTHORIZED environment variable + if (this.bypassSSL) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + const response = await fetch(url, { headers: initialHeaders }); + if (!response.ok) { + throw new Error( + `Failed to fetch ${url} from Confluence: ${response.status}` + ); + } + return await response.json(); + } catch (error) { + this.log("Error:", error); + throw new Error(error.message); + } finally { + if (this.bypassSSL) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "1"; + } + } + + // https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#auth + async fetchAllPagesInSpace(start = 0, limit = this.limit) { + const url = `${this.baseUrl}${ + this.cloud ? "/wiki" : "" + }/rest/api/content?spaceKey=${ + this.spaceKey + }&limit=${limit}&start=${start}&expand=${this.expand}`; + const data = await this.fetchConfluenceData(url); + if (data.size === 0) { + return []; + } + const nextPageStart = start + data.size; + const nextPageResults = await this.fetchAllPagesInSpace( + nextPageStart, + limit + ); + return data.results.concat(nextPageResults); + } + + createDocumentFromPage(page) { + // Function to extract code blocks + const extractCodeBlocks = (content) => { + const codeBlockRegex = + /]*>[\s\S]*?<\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g; + const languageRegex = + /(.*?)<\/ac:parameter>/; + + return content.replace(codeBlockRegex, (match) => { + const language = match.match(languageRegex)?.[1] || ""; + const code = + match.match( + /<\/ac:plain-text-body>/ + )?.[1] || ""; + return `\n\`\`\`${language}\n${code.trim()}\n\`\`\`\n`; + }); + }; + + const contentWithCodeBlocks = extractCodeBlocks(page.body.storage.value); + const plainTextContent = htmlToText(contentWithCodeBlocks, { + wordwrap: false, + preserveNewlines: true, + }); + const textWithPreservedStructure = plainTextContent.replace( + /\n{3,}/g, + "\n\n" + ); + const pageUrl = `${this.baseUrl}${this.cloud ? "/wiki" : ""}/spaces/${ + this.spaceKey + }/pages/${page.id}`; + + return { + pageContent: textWithPreservedStructure, + metadata: { + id: page.id, + status: page.status, + title: page.title, + type: page.type, + url: pageUrl, + version: page.version?.number, + updated_by: page.version?.by?.displayName, + updated_at: page.version?.when, + }, + }; + } +} + +module.exports = { ConfluencePagesLoader }; diff --git a/collector/utils/extensions/Confluence/index.js b/collector/utils/extensions/Confluence/index.js new file mode 100644 index 0000000..b4a0b24 --- /dev/null +++ b/collector/utils/extensions/Confluence/index.js @@ -0,0 +1,292 @@ +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { writeToServerDocuments, sanitizeFileName } = require("../../files"); +const { tokenizeString } = require("../../tokenizer"); +const { ConfluencePagesLoader } = require("./ConfluenceLoader"); + +/** + * Load Confluence documents from a spaceID and Confluence credentials + * @param {object} args - forwarded request body params + * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker + * @returns + */ +async function loadConfluence( + { + baseUrl = null, + spaceKey = null, + username = null, + accessToken = null, + cloud = true, + personalAccessToken = null, + bypassSSL = false, + }, + response +) { + if (!personalAccessToken && (!username || !accessToken)) { + return { + success: false, + reason: + "You need either a personal access token (PAT), or a username and access token to use the Confluence connector.", + }; + } + + if (!baseUrl || !validBaseUrl(baseUrl)) { + return { + success: false, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!spaceKey) { + return { + success: false, + reason: "You need to provide a Confluence space key.", + }; + } + + const normalizedBaseUrl = resolveConfluenceBaseUrl(baseUrl, cloud); + const { hostname } = new URL(normalizedBaseUrl); + console.log(`-- Working Confluence ${normalizedBaseUrl} --`); + const loader = new ConfluencePagesLoader({ + baseUrl: normalizedBaseUrl, + spaceKey, + username, + accessToken, + cloud, + personalAccessToken, + bypassSSL, + }); + + const { docs, error } = await loader + .load() + .then((docs) => { + return { docs, error: null }; + }) + .catch((e) => { + return { + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + }; + }); + + if (!docs.length || !!error) { + return { + success: false, + reason: error ?? "No pages found for that Confluence space.", + }; + } + const outFolder = slugify( + `confluence-${hostname}-${v4().slice(0, 4)}` + ).toLowerCase(); + + const outFolderPath = + process.env.NODE_ENV === "development" + ? path.resolve( + __dirname, + `../../../../server/storage/documents/${outFolder}` + ) + : path.resolve(process.env.STORAGE_DIR, `documents/${outFolder}`); + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + docs.forEach((doc) => { + if (!doc.pageContent) return; + + const data = { + id: v4(), + url: doc.metadata.url + ".page", + title: doc.metadata.title || doc.metadata.source, + docAuthor: normalizedBaseUrl, + description: doc.metadata.title, + docSource: `${normalizedBaseUrl} Confluence`, + chunkSource: generateChunkSource( + { + doc, + baseUrl: normalizedBaseUrl, + spaceKey, + accessToken, + username, + cloud, + bypassSSL, + }, + response.locals.encryptionWorker + ), + published: new Date().toLocaleString(), + wordCount: doc.pageContent.split(" ").length, + pageContent: doc.pageContent, + token_count_estimate: tokenizeString(doc.pageContent), + }; + + console.log( + `[Confluence Loader]: Saving ${doc.metadata.title} to ${outFolder}` + ); + + const fileName = sanitizeFileName( + `${slugify(doc.metadata.title)}-${data.id}` + ); + writeToServerDocuments({ + data, + filename: fileName, + destinationOverride: outFolderPath, + }); + }); + + return { + success: true, + reason: null, + data: { + spaceKey, + destination: outFolder, + }, + }; +} + +/** + * Gets the page content from a specific Confluence page, not all pages in a workspace. + * @returns + */ +async function fetchConfluencePage({ + pageUrl, + baseUrl, + spaceKey, + username, + accessToken, + cloud = true, + bypassSSL = false, +}) { + if (!pageUrl || !baseUrl || !spaceKey || !username || !accessToken) { + return { + success: false, + content: null, + reason: + "You need either a username and access token, or a personal access token (PAT), to use the Confluence connector.", + }; + } + + if (!validBaseUrl(baseUrl)) { + return { + success: false, + content: null, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!spaceKey) { + return { + success: false, + content: null, + reason: "You need to provide a Confluence space key.", + }; + } + + console.log(`-- Working Confluence Page ${pageUrl} --`); + const normalizedBaseUrl = resolveConfluenceBaseUrl(baseUrl, cloud); + const loader = new ConfluencePagesLoader({ + baseUrl: normalizedBaseUrl, + spaceKey, + username, + accessToken, + cloud, + bypassSSL, + }); + + const { docs, error } = await loader + .load() + .then((docs) => { + return { docs, error: null }; + }) + .catch((e) => { + return { + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + }; + }); + + if (!docs.length || !!error) { + return { + success: false, + reason: error ?? "No pages found for that Confluence space.", + content: null, + }; + } + + const targetDocument = docs.find( + (doc) => doc.pageContent && doc.metadata.url === pageUrl + ); + if (!targetDocument) { + return { + success: false, + reason: "Target page could not be found in Confluence space.", + content: null, + }; + } + + return { + success: true, + reason: null, + content: targetDocument.pageContent, + }; +} + +/** + * Validates if the provided baseUrl is a valid URL at all. + * @param {string} baseUrl + * @returns {boolean} + */ +function validBaseUrl(baseUrl) { + try { + new URL(baseUrl); + return true; + } catch { + return false; + } +} + +/** + * Resolves the Confluence base URL, preserving context paths for self-hosted deployments. + * @param {string} baseUrl + * @param {boolean} cloud + * @returns {string} + */ +function resolveConfluenceBaseUrl(baseUrl, cloud = true) { + const url = new URL(baseUrl); + // Cloud URLs use just the origin; self-hosted may have a context path like /confluence + if (cloud) return url.origin; + + const contextPath = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${contextPath}`; +} + +/** + * Generate the full chunkSource for a specific Confluence page so that we can resync it later. + * This data is encrypted into a single `payload` query param so we can replay credentials later + * since this was encrypted with the systems persistent password and salt. + * @param {object} chunkSourceInformation + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {string} + */ +function generateChunkSource( + { doc, baseUrl, spaceKey, accessToken, username, cloud, bypassSSL }, + encryptionWorker +) { + const payload = { + baseUrl, + spaceKey, + token: accessToken, + username, + cloud, + bypassSSL, + }; + return `confluence://${doc.metadata.url}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; +} + +module.exports = { + loadConfluence, + fetchConfluencePage, + resolveConfluenceBaseUrl, +}; diff --git a/collector/utils/extensions/DrupalWiki/DrupalWiki/index.js b/collector/utils/extensions/DrupalWiki/DrupalWiki/index.js new file mode 100644 index 0000000..24b1f02 --- /dev/null +++ b/collector/utils/extensions/DrupalWiki/DrupalWiki/index.js @@ -0,0 +1,338 @@ +/** + * Copyright 2024 + * + * Authors: + * - Eugen Mayer (KontextWork) + */ + +const { htmlToText } = require("html-to-text"); +const { tokenizeString } = require("../../../tokenizer"); +const { + sanitizeFileName, + writeToServerDocuments, + documentsFolder, + normalizePath, + isWithin, +} = require("../../../files"); +const { default: slugify } = require("slugify"); +const path = require("path"); +const fs = require("fs"); +const { processSingleFile } = require("../../../../processSingleFile"); +const { + WATCH_DIRECTORY, + SUPPORTED_FILETYPE_CONVERTERS, +} = require("../../../constants"); + +class Page { + /** + * + * @param {number }id + * @param {string }title + * @param {string} created + * @param {string} type + * @param {string} processedBody + * @param {string} url + * @param {number} spaceId + */ + constructor({ id, title, created, type, processedBody, url, spaceId }) { + this.id = id; + this.title = title; + this.url = url; + this.created = created; + this.type = type; + this.processedBody = processedBody; + this.spaceId = spaceId; + } +} + +class DrupalWiki { + /** + * + * @param baseUrl + * @param spaceId + * @param accessToken + */ + constructor({ baseUrl, accessToken }) { + this.baseUrl = baseUrl; + this.accessToken = accessToken; + this.storagePath = this.#prepareStoragePath(baseUrl); + } + + /** + * Load all pages for the given space, fetching storing each page one by one + * to minimize the memory usage + * + * @param {number} spaceId + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {Promise} + */ + async loadAndStoreAllPagesForSpace(spaceId, encryptionWorker) { + const pageIndex = await this.#getPageIndexForSpace(spaceId); + for (const pageId of pageIndex) { + try { + const page = await this.loadPage(pageId); + + // Pages with an empty body will lead to embedding issues / exceptions + if (page.processedBody.trim() !== "") { + this.#storePage(page, encryptionWorker); + await this.#downloadAndProcessAttachments(page.id); + } else { + console.log(`Skipping page (${page.id}) since it has no content`); + } + } catch (e) { + console.error( + `Could not process DrupalWiki page ${pageId} (skipping and continuing): ` + ); + console.error(e); + } + } + } + + /** + * @param {number} pageId + * @returns {Promise} + */ + async loadPage(pageId) { + return this.#fetchPage(pageId); + } + + /** + * Fetches the page ids for the configured space + * @param {number} spaceId + * @returns{Promise} array of pageIds + */ + async #getPageIndexForSpace(spaceId) { + // errors on fetching the pageIndex is fatal, no error handling + let hasNext = true; + let pageIds = []; + let pageNr = 0; + do { + let { isLast, pageIdsForPage } = await this.#getPagesForSpacePaginated( + spaceId, + pageNr + ); + hasNext = !isLast; + pageNr++; + if (pageIdsForPage.length) { + pageIds = pageIds.concat(pageIdsForPage); + } + } while (hasNext); + + return pageIds; + } + + /** + * + * @param {number} pageNr + * @param {number} spaceId + * @returns {Promise<{isLast,pageIds}>} + */ + async #getPagesForSpacePaginated(spaceId, pageNr) { + /* + * { + * content: Page[], + * last: boolean, + * pageable: { + * pageNumber: number + * } + * } + */ + const data = await this._doFetch( + `${this.baseUrl}/api/rest/scope/api/page?size=100&space=${spaceId}&page=${pageNr}` + ); + + const pageIds = data.content.map((page) => { + return Number(page.id); + }); + + return { + isLast: data.last, + pageIdsForPage: pageIds, + }; + } + + /** + * @param pageId + * @returns {Promise} + */ + async #fetchPage(pageId) { + const data = await this._doFetch( + `${this.baseUrl}/api/rest/scope/api/page/${pageId}` + ); + const url = `${this.baseUrl}/node/${data.id}`; + return new Page({ + id: data.id, + title: data.title, + created: data.lastModified, + type: data.type, + processedBody: this.#processPageBody({ + body: data.body, + title: data.title, + lastModified: data.lastModified, + url: url, + }), + url: url, + }); + } + + /** + * @param {Page} page + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + */ + #storePage(page, encryptionWorker) { + const { hostname } = new URL(this.baseUrl); + + // This UUID will ensure that re-importing the same page without any changes will not + // show up (deduplication). + const targetUUID = `${hostname}.${page.spaceId}.${page.id}.${page.created}`; + const wordCount = page.processedBody.split(" ").length; + const data = { + id: targetUUID, + url: `drupalwiki://${page.url}`, + title: page.title, + docAuthor: this.baseUrl, + description: page.title, + docSource: `${this.baseUrl} DrupalWiki`, + chunkSource: this.#generateChunkSource(page.id, encryptionWorker), + published: new Date().toLocaleString(), + wordCount: wordCount, + pageContent: page.processedBody, + token_count_estimate: tokenizeString(page.processedBody), + }; + + const fileName = sanitizeFileName(`${slugify(page.title)}-${data.id}`); + console.log( + `[DrupalWiki Loader]: Saving page '${page.title}' (${page.id}) to '${this.storagePath}/${fileName}'` + ); + writeToServerDocuments({ + data, + filename: fileName, + destinationOverride: this.storagePath, + }); + } + + /** + * Generate the full chunkSource for a specific Confluence page so that we can resync it later. + * This data is encrypted into a single `payload` query param so we can replay credentials later + * since this was encrypted with the systems persistent password and salt. + * @param {number} pageId + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {string} + */ + #generateChunkSource(pageId, encryptionWorker) { + const payload = { + baseUrl: this.baseUrl, + pageId: pageId, + accessToken: this.accessToken, + }; + return `drupalwiki://${ + this.baseUrl + }/node/${pageId}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; + } + + async _doFetch(url) { + const response = await fetch(url, { + headers: this.#getHeaders(), + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return response.json(); + } + + #getHeaders() { + return { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${this.accessToken}`, + }; + } + + #prepareStoragePath(baseUrl) { + const { hostname } = new URL(baseUrl); + const subFolder = slugify(`drupalwiki-${hostname}`).toLowerCase(); + const outFolder = path.resolve(documentsFolder, subFolder); + if (!fs.existsSync(outFolder)) fs.mkdirSync(outFolder, { recursive: true }); + return outFolder; + } + + /** + * @param {string} body + * @param {string} url + * @param {string} title + * @param {string} lastModified + * @returns {string} + * @private + */ + #processPageBody({ body, title }) { + const textContent = body.trim() !== "" ? body : title; + + const plainTextContent = htmlToText(textContent, { + wordwrap: false, + preserveNewlines: true, + selectors: [ + { + selector: "table", + format: "dataTable", + options: { + colSpacing: 3, + rowSpacing: 1, + uppercaseHeaderCells: true, + maxColumnWidth: Infinity, + }, + }, + ], + }); + + const plainBody = plainTextContent.replace(/\n{3,}/g, "\n\n"); + return plainBody; + } + + async #downloadAndProcessAttachments(pageId) { + try { + const data = await this._doFetch( + `${this.baseUrl}/api/rest/scope/api/attachment?pageId=${pageId}&size=2000` + ); + + const extensionsList = Object.keys(SUPPORTED_FILETYPE_CONVERTERS); + for (const attachment of data.content || data) { + const { fileName, id: attachId } = attachment; + const lowerName = fileName.toLowerCase(); + if (!extensionsList.some((ext) => lowerName.endsWith(ext))) { + continue; + } + + const downloadUrl = `${this.baseUrl}/api/rest/scope/api/attachment/${attachId}/download`; + const attachmentResponse = await fetch(downloadUrl, { + headers: this.#getHeaders(), + }); + if (!attachmentResponse.ok) { + console.log(`Skipping attachment: ${fileName} - Download failed`); + continue; + } + + const buffer = await attachmentResponse.arrayBuffer(); + const localFilePath = normalizePath( + sanitizeFileName(path.resolve(WATCH_DIRECTORY, fileName)) + ); + if (!isWithin(path.resolve(WATCH_DIRECTORY), localFilePath)) { + console.error( + `[DrupalWiki Loader]: File name ${localFilePath} is not within the storage path ${path.resolve( + WATCH_DIRECTORY + )}` + ); + continue; + } + + require("fs").writeFileSync(localFilePath, Buffer.from(buffer)); + await processSingleFile(localFilePath); + } + } catch (err) { + console.error(`Fetching/processing attachments failed:`, err); + } + } +} + +module.exports = { DrupalWiki }; diff --git a/collector/utils/extensions/DrupalWiki/index.js b/collector/utils/extensions/DrupalWiki/index.js new file mode 100644 index 0000000..414ce89 --- /dev/null +++ b/collector/utils/extensions/DrupalWiki/index.js @@ -0,0 +1,102 @@ +/** + * Copyright 2024 + * + * Authors: + * - Eugen Mayer (KontextWork) + */ + +const { DrupalWiki } = require("./DrupalWiki"); +const { validBaseUrl } = require("../../../utils/http"); + +async function loadAndStoreSpaces( + { baseUrl = null, spaceIds = null, accessToken = null }, + response +) { + if (!baseUrl) { + return { + success: false, + reason: + "Please provide your baseUrl like https://mywiki.drupal-wiki.net.", + }; + } else if (!validBaseUrl(baseUrl)) { + return { + success: false, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!spaceIds) { + return { + success: false, + reason: + "Please provide a list of spaceIds like 21,56,67 you want to extract", + }; + } + + if (!accessToken) { + return { + success: false, + reason: "Please provide a REST API-Token.", + }; + } + + console.log(`-- Working Drupal Wiki ${baseUrl} for spaceIds: ${spaceIds} --`); + const drupalWiki = new DrupalWiki({ baseUrl, accessToken }); + + const encryptionWorker = response.locals.encryptionWorker; + const spaceIdsArr = spaceIds.split(",").map((idStr) => { + return Number(idStr.trim()); + }); + + for (const spaceId of spaceIdsArr) { + try { + await drupalWiki.loadAndStoreAllPagesForSpace(spaceId, encryptionWorker); + console.log(`--- Finished space ${spaceId} ---`); + } catch (e) { + console.error(e); + return { + success: false, + reason: e.message, + data: {}, + }; + } + } + console.log(`-- Finished all spaces--`); + + return { + success: true, + reason: null, + data: { + spaceIds, + destination: drupalWiki.storagePath, + }, + }; +} + +/** + * Gets the page content from a specific Confluence page, not all pages in a workspace. + * @returns + */ +async function loadPage({ baseUrl, pageId, accessToken }) { + console.log(`-- Working Drupal Wiki Page ${pageId} of ${baseUrl} --`); + const drupalWiki = new DrupalWiki({ baseUrl, accessToken }); + try { + const page = await drupalWiki.loadPage(pageId); + return { + success: true, + reason: null, + content: page.processedBody, + }; + } catch { + return { + success: false, + reason: `Failed (re)-fetching DrupalWiki page ${pageId} form ${baseUrl}}`, + content: null, + }; + } +} + +module.exports = { + loadAndStoreSpaces, + loadPage, +}; diff --git a/collector/utils/extensions/ObsidianVault/index.js b/collector/utils/extensions/ObsidianVault/index.js new file mode 100644 index 0000000..52d4bbb --- /dev/null +++ b/collector/utils/extensions/ObsidianVault/index.js @@ -0,0 +1,95 @@ +const { v4 } = require("uuid"); +const { default: slugify } = require("slugify"); +const path = require("path"); +const fs = require("fs"); +const { + writeToServerDocuments, + sanitizeFileName, + documentsFolder, +} = require("../../files"); + +function parseObsidianVaultPath(files = []) { + const possiblePaths = new Set(); + files.forEach( + (file) => file?.path && possiblePaths.add(file.path.split("/")[0]) + ); + + switch (possiblePaths.size) { + case 0: + return null; + case 1: + // The user specified a vault properly - so all files are in the same folder. + return possiblePaths.values().next().value; + default: + return null; + } +} + +async function loadObsidianVault({ files = [] }) { + if (!files || files?.length === 0) + return { success: false, error: "No files provided" }; + const vaultName = parseObsidianVaultPath(files); + const folderUUId = v4().slice(0, 4); + const outFolder = vaultName + ? slugify(`obsidian-vault-${vaultName}-${folderUUId}`).toLowerCase() + : slugify(`obsidian-${folderUUId}`).toLowerCase(); + const outFolderPath = path.resolve(documentsFolder, outFolder); + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + console.log( + `Processing ${files.length} files from Obsidian Vault ${ + vaultName ? `"${vaultName}"` : "" + }` + ); + const results = []; + for (const file of files) { + try { + const fullPageContent = file?.content; + // If the file has no content or is just whitespace, skip it. + if (!fullPageContent || fullPageContent.trim() === "") continue; + + const data = { + id: v4(), + url: `obsidian://${file.path}`, + title: file.name, + docAuthor: "Obsidian Vault", + description: file.name, + docSource: "Obsidian Vault", + chunkSource: `obsidian://${file.path}`, + published: new Date().toLocaleString(), + wordCount: fullPageContent.split(" ").length, + pageContent: fullPageContent, + token_count_estimate: fullPageContent.length / 4, // rough estimate + }; + + const targetFileName = sanitizeFileName( + `${slugify(file.name)}-${data.id}` + ); + writeToServerDocuments({ + data, + filename: targetFileName, + destinationOverride: outFolderPath, + }); + results.push({ file: file.path, status: "success" }); + } catch (e) { + console.error(`Failed to process ${file.path}:`, e); + results.push({ file: file.path, status: "failed", reason: e.message }); + } + } + + return { + success: true, + data: { + processed: results.filter((r) => r.status === "success").length, + failed: results.filter((r) => r.status === "failed").length, + total: files.length, + results, + destination: path.basename(outFolderPath), + }, + }; +} + +module.exports = { + loadObsidianVault, +}; diff --git a/collector/utils/extensions/PaperlessNgx/PaperlessNgxLoader/index.js b/collector/utils/extensions/PaperlessNgx/PaperlessNgxLoader/index.js new file mode 100644 index 0000000..ab8c9f5 --- /dev/null +++ b/collector/utils/extensions/PaperlessNgx/PaperlessNgxLoader/index.js @@ -0,0 +1,157 @@ +const { htmlToText } = require("html-to-text"); +const pdf = require("pdf-parse"); + +class PaperlessNgxLoader { + constructor({ baseUrl, apiToken }) { + this.baseUrl = new URL(baseUrl).origin; + this.apiToken = apiToken; + this.baseHeaders = { + Authorization: `Token ${this.apiToken}`, + }; + } + + async load() { + try { + const documents = await this.fetchAllDocuments(); + return documents.map((doc) => this.createDocumentFromPage(doc)); + } catch (error) { + console.error("Error:", error); + throw error; + } + } + + /** + * Fetches all documents from Paperless-ngx + * @returns {Promise<{{[key: string]: any, content: string}[]}>} The documents with their content + */ + async fetchAllDocuments() { + try { + const documents = []; + let nextUrl = `${this.baseUrl}/api/documents/`; + let page = 1; + + while (nextUrl) { + console.log(`Fetching documents page ${page} from Paperless-ngx`); + try { + const data = await fetch(nextUrl, { + headers: { + "Content-Type": "application/json", + ...this.baseHeaders, + }, + }).then((res) => { + if (!res.ok) + throw new Error( + `Failed to fetch documents from Paperless-ngx: ${res.status}` + ); + return res.json(); + }); + + const validResults = data.results.filter((doc) => doc?.id); + if (!validResults.length) break; + + documents.push(...validResults); + + if (data.next === nextUrl) break; + nextUrl = data.next || null; + page++; + } catch (error) { + console.error( + `Error fetching page ${page} from Paperless-ngx:`, + error + ); + break; + } + } + + console.log( + `Fetched ${documents.length} documents from Paperless-ngx (Pages: ${ + page - 1 + })` + ); + + const documentsWithContent = await Promise.all( + documents.map(async (doc) => { + const content = await this.fetchDocumentContent(doc.id); + return { ...doc, content }; + }) + ); + + return documentsWithContent.filter((doc) => !!doc.content); + } catch (error) { + throw new Error( + `Failed to fetch documents from Paperless-ngx: ${error.message}` + ); + } + } + + /** + * Fetches the content of a document from Paperless-ngx + * @param {string} documentId - The ID of the document to fetch + * @returns {Promise} The content of the document + */ + async fetchDocumentContent(documentId) { + try { + const response = await fetch( + `${this.baseUrl}/api/documents/${documentId}/download/`, + { + headers: this.baseHeaders, + } + ); + + if (!response.ok) + throw new Error(`Failed to fetch document content: ${response.status}`); + + const contentType = response.headers.get("content-type"); + switch (contentType) { + case "text/plain": + return await response.text(); + case "application/pdf": + const buffer = await response.arrayBuffer(); + return await this.parsePdfContent(buffer); + default: + return await response.text(); + } + } catch (error) { + console.error( + `Failed to fetch content for document ${documentId}:`, + error + ); + return ""; + } + } + + async parsePdfContent(buffer) { + try { + const data = await pdf(Buffer.from(buffer)); + return data.text; + } catch (error) { + console.error("Failed to parse PDF content:", error); + return ""; + } + } + + createDocumentFromPage(doc) { + const content = doc.content || ""; + const plainTextContent = htmlToText(content, { + wordwrap: false, + preserveNewlines: true, + }); + + return { + pageContent: plainTextContent, + metadata: { + id: doc.id, + title: doc.original_file_name, + created: doc.created, + modified: doc.modified, + added: doc.added, + tags: doc.tags, + correspondent: doc.correspondent, + documentType: doc.document_type, + url: `${this.baseUrl}/documents/${doc.id}`, + }, + }; + } +} + +module.exports = PaperlessNgxLoader; diff --git a/collector/utils/extensions/PaperlessNgx/index.js b/collector/utils/extensions/PaperlessNgx/index.js new file mode 100644 index 0000000..df8a6be --- /dev/null +++ b/collector/utils/extensions/PaperlessNgx/index.js @@ -0,0 +1,128 @@ +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { + writeToServerDocuments, + sanitizeFileName, + documentsFolder, +} = require("../../files"); +const { tokenizeString } = require("../../tokenizer"); +const { validBaseUrl } = require("../../http"); +const PaperlessNgxLoader = require("./PaperlessNgxLoader"); + +/** + * Load documents from a Paperless-ngx instance + * @param {object} args - forwarded request body params + * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker + * @returns + */ +async function loadPaperlessNgx({ baseUrl = null, apiToken = null }, response) { + if (!baseUrl || !validBaseUrl(baseUrl)) { + return { + success: false, + reason: "Provided base URL is not a valid URL.", + }; + } + + if (!apiToken) { + return { + success: false, + reason: + "You need to provide an API token to use the Paperless-ngx connector.", + }; + } + + const { origin, hostname } = new URL(baseUrl); + console.log(`-- Working Paperless-ngx ${origin} --`); + const loader = new PaperlessNgxLoader({ + baseUrl: origin, + apiToken, + }); + + const { docs, error } = await loader + .load() + .then((docs) => ({ docs, error: null })) + .catch((e) => ({ + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + })); + + if (!docs.length || !!error) { + return { + success: false, + reason: + error ?? "No parseable documents found in that Paperless-ngx instance.", + data: null, + }; + } + + const outFolder = slugify( + `paperless-${hostname}-${v4().slice(0, 4)}` + ).toLowerCase(); + const outFolderPath = path.resolve(documentsFolder, outFolder); + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + docs.forEach((doc) => { + if (!doc.pageContent) return; + + const data = { + id: v4(), + url: doc.metadata.url, + title: doc.metadata.title, + docAuthor: doc.metadata.correspondent || "Unknown", + description: `A document from the Paperless-ngx instance at ${origin}`, + docSource: `paperless-ngx`, + chunkSource: generateChunkSource( + { doc, baseUrl: origin, apiToken }, + response.locals.encryptionWorker + ), + published: doc.metadata.created, + wordCount: doc.pageContent.split(" ").length, + pageContent: doc.pageContent, + token_count_estimate: tokenizeString(doc.pageContent), + }; + + console.log( + `[Paperless-ngx Loader]: Saving ${doc.metadata.title} to ${outFolder}` + ); + const fileName = sanitizeFileName( + `${slugify(doc.metadata.title)}-${data.id}` + ); + writeToServerDocuments({ + data, + filename: fileName, + destinationOverride: outFolderPath, + }); + }); + + return { + success: true, + reason: null, + data: { + files: docs.length, + destination: outFolder, + }, + }; +} + +/** + * Generate the full chunkSource for a specific Paperless-ngx document so that we can resync it later. + * @param {object} chunkSourceInformation + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {string} + */ +function generateChunkSource({ doc, baseUrl, apiToken }, encryptionWorker) { + const payload = { + baseUrl, + token: apiToken, + }; + return `paperless-ngx://${doc.metadata.id}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; +} + +module.exports = { + loadPaperlessNgx, +}; diff --git a/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js b/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js new file mode 100644 index 0000000..5880751 --- /dev/null +++ b/collector/utils/extensions/RepoLoader/GithubRepo/RepoLoader/index.js @@ -0,0 +1,265 @@ +/** + * @typedef {Object} RepoLoaderArgs + * @property {string} repo - The GitHub repository URL. + * @property {string} [branch] - The branch to load from (optional). + * @property {string} [accessToken] - GitHub access token for authentication (optional). + * @property {string[]} [ignorePaths] - Array of paths to ignore when loading (optional). + */ + +/** + * @class + * @classdesc Loads and manages GitHub repository content. + */ +class GitHubRepoLoader { + /** + * Creates an instance of RepoLoader. + * @param {RepoLoaderArgs} [args] - The configuration options. + * @returns {GitHubRepoLoader} + */ + constructor(args = {}) { + this.ready = false; + this.repo = this.#processRepoUrl(args?.repo); + this.branch = args?.branch; + this.accessToken = args?.accessToken || null; + this.ignorePaths = args?.ignorePaths || []; + + this.author = null; + this.project = null; + this.branches = []; + } + + /** + * Processes a repository URL to ensure it is in the correct format + * - remove the .git suffix if present + * - ensure the url is valid + * @param {string} repoUrl - The repository URL to process. + * @returns {string|null} The processed repository URL, or null if the URL is invalid. + */ + #processRepoUrl(repoUrl) { + if (!repoUrl) return repoUrl; + try { + const url = new URL(repoUrl); + if (url.pathname.endsWith(".git")) + url.pathname = url.pathname.slice(0, -4); + return url.toString(); + } catch (e) { + console.error( + `[GitHub Loader]: Error processing repository URL ${this.repo}: ${e.message}` + ); + return repoUrl; + } + } + + /** + * Validates the GitHub URL format. + * - ensure the url is valid + * - ensure the hostname is github.com + * - ensure the pathname is in the format of github.com/{author}/{project} + * - sets the author and project properties of class instance + * @returns {boolean} True if the URL is valid, false otherwise. + */ + #validGithubUrl() { + try { + const url = new URL(this.repo); + + // Not a github url at all. + if (url.hostname !== "github.com") { + console.log( + `[GitHub Loader]: Invalid GitHub URL provided! Hostname must be 'github.com'. Got ${url.hostname}` + ); + return false; + } + + // Assume the url is in the format of github.com/{author}/{project} + // Remove the first slash from the pathname so we can split it properly. + const [author, project, ..._rest] = url.pathname.slice(1).split("/"); + if (!author || !project) { + console.log( + `[GitHub Loader]: Invalid GitHub URL provided! URL must be in the format of 'github.com/{author}/{project}'. Got ${url.pathname}` + ); + return false; + } + + this.author = author; + this.project = project; + return true; + } catch (e) { + console.log( + `[GitHub Loader]: Invalid GitHub URL provided! Error: ${e.message}` + ); + return false; + } + } + + // Ensure the branch provided actually exists + // and if it does not or has not been set auto-assign to primary branch. + async #validBranch() { + await this.getRepoBranches(); + if (!!this.branch && this.branches.includes(this.branch)) return; + + console.log( + "[GitHub Loader]: Branch not set! Auto-assigning to a default branch." + ); + this.branch = this.branches.includes("main") ? "main" : "master"; + console.log(`[GitHub Loader]: Branch auto-assigned to ${this.branch}.`); + return; + } + + async #validateAccessToken() { + if (!this.accessToken) return; + const valid = await fetch("https://api.github.com/octocat", { + method: "GET", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + .then((res) => { + if (!res.ok) throw new Error(res.statusText); + return res.ok; + }) + .catch((e) => { + console.error( + "Invalid GitHub Access Token provided! Access token will not be used", + e.message + ); + return false; + }); + + if (!valid) this.accessToken = null; + return; + } + + /** + * Initializes the RepoLoader instance. + * @returns {Promise} The initialized RepoLoader instance. + */ + async init() { + if (!this.#validGithubUrl()) return; + await this.#validBranch(); + await this.#validateAccessToken(); + this.ready = true; + return this; + } + + /** + * Recursively loads the repository content. + * @returns {Promise>} An array of loaded documents. + * @throws {Error} If the RepoLoader is not in a ready state. + */ + async recursiveLoader() { + if (!this.ready) throw new Error("[GitHub Loader]: not in ready state!"); + const { + GithubRepoLoader: LCGithubLoader, + } = require("@langchain/community/document_loaders/web/github"); + + if (this.accessToken) + console.log( + `[GitHub Loader]: Access token set! Recursive loading enabled!` + ); + + const loader = new LCGithubLoader(this.repo, { + branch: this.branch, + recursive: !!this.accessToken, // Recursive will hit rate limits. + maxConcurrency: 5, + unknown: "warn", + accessToken: this.accessToken, + ignorePaths: this.ignorePaths, + verbose: true, + }); + + const docs = await loader.load(); + return docs; + } + + // Sort branches to always show either main or master at the top of the result. + #branchPrefSort(branches = []) { + const preferredSort = ["main", "master"]; + return branches.reduce((acc, branch) => { + if (preferredSort.includes(branch)) return [branch, ...acc]; + return [...acc, branch]; + }, []); + } + + /** + * Retrieves all branches for the repository. + * @returns {Promise} An array of branch names. + */ + async getRepoBranches() { + if (!this.#validGithubUrl() || !this.author || !this.project) return []; + await this.#validateAccessToken(); // Ensure API access token is valid for pre-flight + + let page = 0; + let polling = true; + const branches = []; + + while (polling) { + console.log(`Fetching page ${page} of branches for ${this.project}`); + await fetch( + `https://api.github.com/repos/${this.author}/${this.project}/branches?per_page=100&page=${page}`, + { + method: "GET", + headers: { + ...(this.accessToken + ? { Authorization: `Bearer ${this.accessToken}` } + : {}), + "X-GitHub-Api-Version": "2022-11-28", + }, + } + ) + .then((res) => { + if (res.ok) return res.json(); + throw new Error(`Invalid request to Github API: ${res.statusText}`); + }) + .then((branchObjects) => { + polling = branchObjects.length > 0; + branches.push(branchObjects.map((branch) => branch.name)); + page++; + }) + .catch((err) => { + polling = false; + console.log(`RepoLoader.branches`, err); + }); + } + + this.branches = [...new Set(branches.flat())]; + return this.#branchPrefSort(this.branches); + } + + /** + * Fetches the content of a single file from the repository. + * @param {string} sourceFilePath - The path to the file in the repository. + * @returns {Promise} The content of the file, or null if fetching fails. + */ + async fetchSingleFile(sourceFilePath) { + try { + return fetch( + `https://api.github.com/repos/${this.author}/${this.project}/contents/${sourceFilePath}?ref=${this.branch}`, + { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(!!this.accessToken + ? { Authorization: `Bearer ${this.accessToken}` } + : {}), + }, + } + ) + .then((res) => { + if (res.ok) return res.json(); + throw new Error(`Failed to fetch from Github API: ${res.statusText}`); + }) + .then((json) => { + if (json.hasOwnProperty("status") || !json.hasOwnProperty("content")) + throw new Error(json?.message || "missing content"); + return atob(json.content); + }); + } catch (e) { + console.error(`RepoLoader.fetchSingleFile`, e); + return null; + } + } +} + +module.exports = GitHubRepoLoader; diff --git a/collector/utils/extensions/RepoLoader/GithubRepo/index.js b/collector/utils/extensions/RepoLoader/GithubRepo/index.js new file mode 100644 index 0000000..fae6ef4 --- /dev/null +++ b/collector/utils/extensions/RepoLoader/GithubRepo/index.js @@ -0,0 +1,159 @@ +const RepoLoader = require("./RepoLoader"); +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { writeToServerDocuments } = require("../../../files"); +const { tokenizeString } = require("../../../tokenizer"); + +/** + * Load in a GitHub Repo recursively or just the top level if no PAT is provided + * @param {object} args - forwarded request body params + * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker + * @returns + */ +async function loadGithubRepo(args, response) { + const repo = new RepoLoader(args); + await repo.init(); + + if (!repo.ready) + return { + success: false, + reason: "Could not prepare GitHub repo for loading! Check URL", + }; + + console.log( + `-- Working GitHub ${repo.author}/${repo.project}:${repo.branch} --` + ); + const docs = await repo.recursiveLoader(); + if (!docs.length) { + return { + success: false, + reason: "No files were found for those settings.", + }; + } + + console.log(`[GitHub Loader]: Found ${docs.length} source files. Saving...`); + const outFolder = slugify( + `${repo.author}-${repo.project}-${repo.branch}-${v4().slice(0, 4)}` + ).toLowerCase(); + + const outFolderPath = + process.env.NODE_ENV === "development" + ? path.resolve( + __dirname, + `../../../../../server/storage/documents/${outFolder}` + ) + : path.resolve(process.env.STORAGE_DIR, `documents/${outFolder}`); + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + for (const doc of docs) { + if (!doc.pageContent) continue; + const data = { + id: v4(), + url: "github://" + doc.metadata.source, + title: doc.metadata.source, + docAuthor: repo.author, + description: "No description found.", + docSource: doc.metadata.source, + chunkSource: generateChunkSource( + repo, + doc, + response.locals.encryptionWorker + ), + published: new Date().toLocaleString(), + wordCount: doc.pageContent.split(" ").length, + pageContent: doc.pageContent, + token_count_estimate: tokenizeString(doc.pageContent), + }; + console.log( + `[GitHub Loader]: Saving ${doc.metadata.source} to ${outFolder}` + ); + writeToServerDocuments({ + data, + filename: `${slugify(doc.metadata.source)}-${data.id}`, + destinationOverride: outFolderPath, + }); + } + + return { + success: true, + reason: null, + data: { + author: repo.author, + repo: repo.project, + branch: repo.branch, + files: docs.length, + destination: outFolder, + }, + }; +} + +/** + * Gets the page content from a specific source file in a give GitHub Repo, not all items in a repo. + * @returns + */ +async function fetchGithubFile({ + repoUrl, + branch, + accessToken = null, + sourceFilePath, +}) { + const repo = new RepoLoader({ + repo: repoUrl, + branch, + accessToken, + }); + await repo.init(); + + if (!repo.ready) + return { + success: false, + content: null, + reason: "Could not prepare GitHub repo for loading! Check URL or PAT.", + }; + + console.log( + `-- Working GitHub ${repo.author}/${repo.project}:${repo.branch} file:${sourceFilePath} --` + ); + const fileContent = await repo.fetchSingleFile(sourceFilePath); + if (!fileContent) { + return { + success: false, + reason: "Target file returned a null content response.", + content: null, + }; + } + + return { + success: true, + reason: null, + content: fileContent, + }; +} + +/** + * Generate the full chunkSource for a specific file so that we can resync it later. + * This data is encrypted into a single `payload` query param so we can replay credentials later + * since this was encrypted with the systems persistent password and salt. + * @param {RepoLoader} repo + * @param {import("@langchain/core/documents").Document} doc + * @param {import("../../EncryptionWorker").EncryptionWorker} encryptionWorker + * @returns {string} + */ +function generateChunkSource(repo, doc, encryptionWorker) { + const payload = { + owner: repo.author, + project: repo.project, + branch: repo.branch, + path: doc.metadata.source, + pat: !!repo.accessToken ? repo.accessToken : null, + }; + return `github://${repo.repo}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; +} + +module.exports = { loadGithubRepo, fetchGithubFile }; diff --git a/collector/utils/extensions/RepoLoader/GitlabRepo/RepoLoader/index.js b/collector/utils/extensions/RepoLoader/GitlabRepo/RepoLoader/index.js new file mode 100644 index 0000000..7a0310d --- /dev/null +++ b/collector/utils/extensions/RepoLoader/GitlabRepo/RepoLoader/index.js @@ -0,0 +1,447 @@ +const ignore = require("ignore"); +const MAX_RETRIES = 3; + +/** + * @typedef {Object} RepoLoaderArgs + * @property {string} repo - The GitLab repository URL. + * @property {string} [branch] - The branch to load from (optional). + * @property {string} [accessToken] - GitLab access token for authentication (optional). + * @property {string[]} [ignorePaths] - Array of paths to ignore when loading (optional). + * @property {boolean} [fetchIssues] - Should issues be fetched (optional). + * @property {boolean} [fetchWikis] - Should wiki be fetched (optional). + */ + +/** + * @typedef {Object} FileTreeObject + * @property {string} id - The file object ID. + * @property {string} name - name of file. + * @property {('blob'|'tree')} type - type of file object. + * @property {string} path - path + name of file. + * @property {string} mode - Linux permission code. + */ + +/** + * @class + * @classdesc Loads and manages GitLab repository content. + */ +class GitLabRepoLoader { + /** + * Creates an instance of RepoLoader. + * @param {RepoLoaderArgs} [args] - The configuration options. + * @returns {GitLabRepoLoader} + */ + constructor(args = {}) { + this.ready = false; + this.repo = args?.repo; + this.branch = args?.branch; + this.accessToken = args?.accessToken || null; + this.ignorePaths = args?.ignorePaths || []; + this.ignoreFilter = ignore().add(this.ignorePaths); + this.withIssues = args?.fetchIssues || false; + this.withWikis = args?.fetchWikis || false; + + this.projectId = null; + this.apiBase = "https://gitlab.com"; + this.author = null; + this.project = null; + this.branches = []; + } + + #wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + #validGitlabUrl() { + const validPatterns = [ + //eslint-disable-next-line + /https:\/\/gitlab\.com\/(?[^\/]+)\/(?.*)/, + // This should even match the regular hosted URL, but we may want to know + // if this was a hosted GitLab (above) or a self-hosted (below) instance + // since the API interface could be different. + //eslint-disable-next-line + /(http|https):\/\/[^\/]+\/(?[^\/]+)\/(?.*)/, + ]; + + const match = validPatterns + .find((pattern) => this.repo.match(pattern)?.groups) + ?.exec(this.repo); + if (!match?.groups) return false; + + const { author, project } = match.groups; + this.projectId = encodeURIComponent(`${author}/${project}`); + this.apiBase = new URL(this.repo).origin; + this.author = author; + this.project = project; + return true; + } + + async #validBranch() { + await this.getRepoBranches(); + if (!!this.branch && this.branches.includes(this.branch)) return; + + console.log( + "[Gitlab Loader]: Branch not set! Auto-assigning to a default branch." + ); + this.branch = this.branches.includes("main") ? "main" : "master"; + console.log(`[Gitlab Loader]: Branch auto-assigned to ${this.branch}.`); + return; + } + + async #validateAccessToken() { + if (!this.accessToken) return; + try { + await fetch(`${this.apiBase}/api/v4/user`, { + method: "GET", + headers: this.accessToken ? { "PRIVATE-TOKEN": this.accessToken } : {}, + }).then((res) => res.ok); + } catch (e) { + console.error( + "Invalid Gitlab Access Token provided! Access token will not be used", + e.message + ); + this.accessToken = null; + } + } + + /** + * Initializes the RepoLoader instance. + * @returns {Promise} The initialized RepoLoader instance. + */ + async init() { + if (!this.#validGitlabUrl()) return; + await this.#validBranch(); + await this.#validateAccessToken(); + this.ready = true; + return this; + } + + /** + * Recursively loads the repository content. + * @returns {Promise>} An array of loaded documents. + * @throws {Error} If the RepoLoader is not in a ready state. + */ + async recursiveLoader() { + if (!this.ready) throw new Error("[Gitlab Loader]: not in ready state!"); + + if (this.accessToken) + console.log( + `[Gitlab Loader]: Access token set! Recursive loading enabled for ${this.repo}!` + ); + + const docs = []; + + console.log(`[Gitlab Loader]: Fetching files.`); + + const files = await this.fetchFilesRecursive(); + + console.log(`[Gitlab Loader]: Fetched ${files.length} files.`); + + for (const file of files) { + if (this.ignoreFilter.ignores(file.path)) continue; + + docs.push({ + pageContent: file.content, + metadata: { + source: file.path, + url: `${this.repo}/-/blob/${this.branch}/${file.path}`, + }, + }); + } + + if (this.withIssues) { + console.log(`[Gitlab Loader]: Fetching issues.`); + const issues = await this.fetchIssues(); + console.log( + `[Gitlab Loader]: Fetched ${issues.length} issues with discussions.` + ); + docs.push( + ...issues.map((issue) => ({ + issue, + metadata: { + source: `issue-${this.repo}-${issue.iid}`, + url: issue.web_url, + }, + })) + ); + } + + if (this.withWikis) { + console.log(`[Gitlab Loader]: Fetching wiki.`); + const wiki = await this.fetchWiki(); + console.log(`[Gitlab Loader]: Fetched ${wiki.length} wiki pages.`); + docs.push( + ...wiki.map((wiki) => ({ + wiki, + metadata: { + source: `wiki-${this.repo}-${wiki.slug}`, + url: `${this.repo}/-/wikis/${wiki.slug}`, + }, + })) + ); + } + + return docs; + } + + #branchPrefSort(branches = []) { + const preferredSort = ["main", "master"]; + return branches.reduce((acc, branch) => { + if (preferredSort.includes(branch)) return [branch, ...acc]; + return [...acc, branch]; + }, []); + } + + /** + * Retrieves all branches for the repository. + * @returns {Promise} An array of branch names. + */ + async getRepoBranches() { + if (!this.#validGitlabUrl() || !this.projectId) return []; + await this.#validateAccessToken(); + this.branches = []; + + const branchesRequestData = { + endpoint: `/api/v4/projects/${this.projectId}/repository/branches`, + }; + + let branchesPage = []; + while ((branchesPage = await this.fetchNextPage(branchesRequestData))) { + if (!Array.isArray(branchesPage) || !branchesPage?.length) break; + this.branches.push(...branchesPage.map((branch) => branch.name)); + } + return this.#branchPrefSort(this.branches); + } + + /** + * Returns list of all file objects from tree API for GitLab + * @returns {Promise} + */ + async fetchFilesRecursive() { + const files = []; + const filesRequestData = { + endpoint: `/api/v4/projects/${this.projectId}/repository/tree`, + queryParams: { + ref: this.branch, + recursive: true, + }, + }; + + let filesPage = null; + let pagePromises = []; + while ((filesPage = await this.fetchNextPage(filesRequestData))) { + if (!Array.isArray(filesPage) || !filesPage?.length) break; + // Fetch all the files that are not ignored in parallel. + pagePromises = filesPage + .filter((file) => { + if (file.type !== "blob") return false; + return !this.ignoreFilter.ignores(file.path); + }) + .map(async (file) => { + const content = await this.fetchSingleFileContents(file.path); + if (!content) return null; + return { + path: file.path, + content, + }; + }); + + const pageFiles = await Promise.all(pagePromises); + + files.push(...pageFiles.filter((item) => item !== null)); + console.log(`Fetched ${files.length} files.`); + } + console.log(`Total files fetched: ${files.length}`); + return files; + } + + /** + * Fetches all issues from the repository. + * @returns {Promise} An array of issue objects. + */ + async fetchIssues() { + const issues = []; + const issuesRequestData = { + endpoint: `/api/v4/projects/${this.projectId}/issues`, + }; + + let issuesPage = null; + let pagePromises = []; + while ((issuesPage = await this.fetchNextPage(issuesRequestData))) { + if (!Array.isArray(issuesPage) || !issuesPage?.length) break; + // Fetch all the issues in parallel. + pagePromises = issuesPage.map(async (issue) => { + const discussionsRequestData = { + endpoint: `/api/v4/projects/${this.projectId}/issues/${issue.iid}/discussions`, + }; + let discussionPage = null; + const discussions = []; + + while ( + (discussionPage = await this.fetchNextPage(discussionsRequestData)) + ) { + if (!Array.isArray(discussionPage) || !discussionPage?.length) break; + discussions.push( + ...discussionPage.map(({ notes }) => + notes.map( + ({ body, author, created_at }) => + `${author.username} at ${created_at}: +${body}` + ) + ) + ); + } + const result = { + ...issue, + discussions, + }; + return result; + }); + + const pageIssues = await Promise.all(pagePromises); + + issues.push(...pageIssues); + console.log(`Fetched ${issues.length} issues.`); + } + console.log(`Total issues fetched: ${issues.length}`); + return issues; + } + + /** + * Fetches all wiki pages from the repository. + * @returns {Promise} An array of wiki page objects. + */ + async fetchWiki() { + const wikiRequestData = { + endpoint: `/api/v4/projects/${this.projectId}/wikis`, + queryParams: { + with_content: "1", + }, + }; + + const wikiPages = await this.fetchNextPage(wikiRequestData); + if (!Array.isArray(wikiPages)) return []; + console.log(`Total wiki pages fetched: ${wikiPages.length}`); + return wikiPages; + } + + /** + * Fetches the content of a single file from the repository. + * @param {string} sourceFilePath - The path to the file in the repository. + * @returns {Promise} The content of the file, or null if fetching fails. + */ + async fetchSingleFileContents(sourceFilePath, retries = 0) { + try { + const url = `${this.apiBase}/api/v4/projects/${ + this.projectId + }/repository/files/${encodeURIComponent(sourceFilePath)}/raw?ref=${ + this.branch + }`; + const response = await fetch(url, { + method: "GET", + headers: this.accessToken ? { "PRIVATE-TOKEN": this.accessToken } : {}, + }); + + if (response.status === 429) { + if (retries >= MAX_RETRIES) { + console.warn( + `[Gitlab Loader]: Rate limit persists for ${sourceFilePath} after ${retries} retries. Skipping.` + ); + return null; + } + const retryAfter = Number(response.headers.get("retry-after")) || 60; + console.warn( + `[Gitlab Loader]: Rate limit hit fetching ${sourceFilePath}. Waiting ${retryAfter}s...` + ); + await this.#wait(retryAfter * 1000); + return this.fetchSingleFileContents(sourceFilePath, retries + 1); + } + + if (!response.ok) + throw new Error(`Failed to fetch single file ${sourceFilePath}`); + + return await response.text(); + } catch (e) { + console.error(`RepoLoader.fetchSingleFileContents`, e); + return null; + } + } + + /** + * Fetches the next page of data from the API. + * @param {Object} requestData - The request data. + * @returns {Promise|null>} The next page of data, or null if no more pages. + */ + async fetchNextPage(requestData, retries = 0) { + try { + if (requestData.page === -1) return null; + if (!requestData.page) requestData.page = 1; + + const { endpoint, perPage = 100, queryParams = {} } = requestData; + const params = new URLSearchParams({ + ...queryParams, + per_page: perPage, + page: requestData.page, + }); + const url = `${this.apiBase}${endpoint}?${params.toString()}`; + + const response = await fetch(url, { + method: "GET", + headers: this.accessToken ? { "PRIVATE-TOKEN": this.accessToken } : {}, + }); + + if (response.status === 429) { + if (retries >= MAX_RETRIES) { + console.warn( + `[Gitlab Loader]: Rate limit persists for ${endpoint} after ${retries} retries. Skipping.` + ); + return null; + } + const retryAfter = Number(response.headers.get("retry-after")) || 60; + console.warn( + `[Gitlab Loader]: Rate limit hit for ${endpoint}. Waiting ${retryAfter}s before retrying...` + ); + await this.#wait(retryAfter * 1000); + return this.fetchNextPage(requestData, retries + 1); + } + + if (response.status === 401) { + console.warn( + `[Gitlab Loader]: Unauthorized request for ${endpoint}. Skipping.` + ); + return null; + } + + if (!response.ok) { + console.warn( + `[Gitlab Loader]: Unexpected status ${response.status} for ${endpoint}. Skipping.` + ); + return null; + } + + const data = await response.json(); + if (!Array.isArray(data)) { + console.warn(`Unexpected response format for ${endpoint}:`, data); + return []; + } + + // GitLab omits x-total-pages for large repos, so use x-next-page + // as the sole pagination signal — it's empty on the last page. + const nextPage = response.headers.get("x-next-page"); + const totalPages = response.headers.get("x-total-pages"); + console.log( + `Gitlab RepoLoader: fetched ${endpoint} page ${requestData.page}${ + totalPages ? `/${totalPages}` : "" + } with ${data.length} records.` + ); + + requestData.page = nextPage?.trim() ? Number(nextPage) : -1; + + return data; + } catch (e) { + console.error(`RepoLoader.fetchNextPage`, e); + return null; + } + } +} + +module.exports = GitLabRepoLoader; diff --git a/collector/utils/extensions/RepoLoader/GitlabRepo/index.js b/collector/utils/extensions/RepoLoader/GitlabRepo/index.js new file mode 100644 index 0000000..5c312f0 --- /dev/null +++ b/collector/utils/extensions/RepoLoader/GitlabRepo/index.js @@ -0,0 +1,261 @@ +const RepoLoader = require("./RepoLoader"); +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { sanitizeFileName, writeToServerDocuments } = require("../../../files"); +const { tokenizeString } = require("../../../tokenizer"); + +/** + * Load in a Gitlab Repo recursively or just the top level if no PAT is provided + * @param {object} args - forwarded request body params + * @param {import("../../../middleware/setDataSigner").ResponseWithSigner} response - Express response object with encryptionWorker + * @returns + */ +async function loadGitlabRepo(args, response) { + const repo = new RepoLoader(args); + await repo.init(); + + if (!repo.ready) + return { + success: false, + reason: "Could not prepare Gitlab repo for loading! Check URL", + }; + + console.log( + `-- Working GitLab ${repo.author}/${repo.project}:${repo.branch} --` + ); + const docs = await repo.recursiveLoader(); + if (!docs.length) { + return { + success: false, + reason: "No files were found for those settings.", + }; + } + + console.log(`[GitLab Loader]: Found ${docs.length} source files. Saving...`); + const outFolder = slugify( + `${repo.author}-${repo.project}-${repo.branch}-${v4().slice(0, 4)}` + ).toLowerCase(); + + const outFolderPath = + process.env.NODE_ENV === "development" + ? path.resolve( + __dirname, + `../../../../../server/storage/documents/${outFolder}` + ) + : path.resolve(process.env.STORAGE_DIR, `documents/${outFolder}`); + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + + for (const doc of docs) { + if (!doc.metadata || (!doc.pageContent && !doc.issue && !doc.wiki)) + continue; + let pageContent = null; + + const data = { + id: v4(), + url: "gitlab://" + doc.metadata.source, + docSource: doc.metadata.source, + chunkSource: generateChunkSource( + repo, + doc, + response.locals.encryptionWorker + ), + published: new Date().toLocaleString(), + }; + + if (doc.pageContent) { + pageContent = doc.pageContent; + + data.title = doc.metadata.source; + data.docAuthor = repo.author; + data.description = "No description found."; + } else if (doc.issue) { + pageContent = issueToMarkdown(doc.issue); + + data.title = `Issue ${doc.issue.iid}: ${doc.issue.title}`; + data.docAuthor = doc.issue.author.username; + data.description = doc.issue.description; + } else if (doc.wiki) { + pageContent = doc.wiki.content; + data.title = doc.wiki.title; + data.docAuthor = repo.author; + data.description = + doc.wiki.format === "markdown" + ? "GitLab Wiki Page (Markdown)" + : "GitLab Wiki Page"; + } else { + continue; + } + + data.wordCount = pageContent.split(" ").length; + data.token_count_estimate = tokenizeString(pageContent); + data.pageContent = pageContent; + + console.log( + `[GitLab Loader]: Saving ${doc.metadata.source} to ${outFolder}` + ); + + writeToServerDocuments({ + data, + filename: sanitizeFileName(`${slugify(doc.metadata.source)}-${data.id}`), + destinationOverride: outFolderPath, + }); + } + + return { + success: true, + reason: null, + data: { + author: repo.author, + repo: repo.project, + projectId: repo.projectId, + branch: repo.branch, + files: docs.length, + destination: outFolder, + }, + }; +} + +async function fetchGitlabFile({ + repoUrl, + branch, + accessToken = null, + sourceFilePath, +}) { + const repo = new RepoLoader({ + repo: repoUrl, + branch, + accessToken, + }); + await repo.init(); + + if (!repo.ready) + return { + success: false, + content: null, + reason: "Could not prepare GitLab repo for loading! Check URL or PAT.", + }; + console.log( + `-- Working GitLab ${repo.author}/${repo.project}:${repo.branch} file:${sourceFilePath} --` + ); + const fileContent = await repo.fetchSingleFile(sourceFilePath); + if (!fileContent) { + return { + success: false, + reason: "Target file returned a null content response.", + content: null, + }; + } + + return { + success: true, + reason: null, + content: fileContent, + }; +} + +function generateChunkSource(repo, doc, encryptionWorker) { + const payload = { + projectId: decodeURIComponent(repo.projectId), + branch: repo.branch, + path: doc.metadata.source, + pat: !!repo.accessToken ? repo.accessToken : null, + }; + return `gitlab://${repo.repo}?payload=${encryptionWorker.encrypt( + JSON.stringify(payload) + )}`; +} + +function issueToMarkdown(issue) { + const metadata = {}; + + const userFields = ["author", "assignees", "closed_by"]; + const userToUsername = ({ username }) => username; + for (const userField of userFields) { + if (issue[userField]) { + if (Array.isArray(issue[userField])) { + metadata[userField] = issue[userField].map(userToUsername); + } else { + metadata[userField] = userToUsername(issue[userField]); + } + } + } + + const singleValueFields = [ + "web_url", + "state", + "created_at", + "updated_at", + "closed_at", + "due_date", + "type", + "merge_request_count", + "upvotes", + "downvotes", + "labels", + "has_tasks", + "task_status", + "confidential", + "severity", + ]; + + for (const singleValueField of singleValueFields) { + metadata[singleValueField] = issue[singleValueField]; + } + + if (issue.milestone) { + metadata.milestone = `${issue.milestone.title} (${issue.milestone.id})`; + } + + if (issue.time_stats) { + const timeFields = ["time_estimate", "total_time_spent"]; + for (const timeField of timeFields) { + const fieldName = `human_${timeField}`; + if (issue?.time_stats[fieldName]) { + metadata[timeField] = issue.time_stats[fieldName]; + } + } + } + + const metadataString = Object.entries(metadata) + .map(([name, value]) => { + if (!value || value?.length < 1) { + return null; + } + let result = `- ${name.replace("_", " ")}:`; + + if (!Array.isArray(value)) { + result += ` ${value}`; + } else { + result += "\n" + value.map((s) => ` - ${s}`).join("\n"); + } + + return result; + }) + .filter((item) => item != null) + .join("\n"); + + let markdown = `# ${issue.title} (${issue.iid}) + +${issue.description} + +## Metadata + +${metadataString}`; + + if (issue.discussions.length > 0) { + markdown += ` + +## Activity + +${issue.discussions.join("\n\n")} +`; + } + + return markdown; +} + +module.exports = { loadGitlabRepo, fetchGitlabFile }; diff --git a/collector/utils/extensions/RepoLoader/index.js b/collector/utils/extensions/RepoLoader/index.js new file mode 100644 index 0000000..28e8780 --- /dev/null +++ b/collector/utils/extensions/RepoLoader/index.js @@ -0,0 +1,41 @@ +/** + * Dynamically load the correct repository loader from a specific platform + * by default will return GitHub. + * @param {('github'|'gitlab')} platform + * @returns {import("./GithubRepo/RepoLoader")|import("./GitlabRepo/RepoLoader")} the repo loader class for provider + */ +function resolveRepoLoader(platform = "github") { + switch (platform) { + case "github": + console.log(`Loading GitHub RepoLoader...`); + return require("./GithubRepo/RepoLoader"); + case "gitlab": + console.log(`Loading GitLab RepoLoader...`); + return require("./GitlabRepo/RepoLoader"); + default: + console.log(`Loading GitHub RepoLoader...`); + return require("./GithubRepo/RepoLoader"); + } +} + +/** + * Dynamically load the correct repository loader function from a specific platform + * by default will return Github. + * @param {('github'|'gitlab')} platform + * @returns {import("./GithubRepo")['fetchGithubFile'] | import("./GitlabRepo")['fetchGitlabFile']} the repo loader class for provider + */ +function resolveRepoLoaderFunction(platform = "github") { + switch (platform) { + case "github": + console.log(`Loading GitHub loader function...`); + return require("./GithubRepo").loadGithubRepo; + case "gitlab": + console.log(`Loading GitLab loader function...`); + return require("./GitlabRepo").loadGitlabRepo; + default: + console.log(`Loading GitHub loader function...`); + return require("./GithubRepo").loadGithubRepo; + } +} + +module.exports = { resolveRepoLoader, resolveRepoLoaderFunction }; diff --git a/collector/utils/extensions/WebsiteDepth/index.js b/collector/utils/extensions/WebsiteDepth/index.js new file mode 100644 index 0000000..2ab2a5e --- /dev/null +++ b/collector/utils/extensions/WebsiteDepth/index.js @@ -0,0 +1,211 @@ +const { v4 } = require("uuid"); +const { + PuppeteerWebBaseLoader, +} = require("langchain/document_loaders/web/puppeteer"); +const { default: slugify } = require("slugify"); +const { parse } = require("node-html-parser"); +const { writeToServerDocuments, documentsFolder } = require("../../files"); +const { tokenizeString } = require("../../tokenizer"); +const path = require("path"); +const fs = require("fs"); +const RuntimeSettings = require("../../runtimeSettings"); + +async function discoverLinks(startUrl, maxDepth = 1, maxLinks = 20) { + const baseUrl = new URL(startUrl); + const discoveredLinks = new Set([startUrl]); + let queue = [[startUrl, 0]]; // [url, currentDepth] + const scrapedUrls = new Set(); + + for (let currentDepth = 0; currentDepth < maxDepth; currentDepth++) { + const levelSize = queue.length; + const nextQueue = []; + + for (let i = 0; i < levelSize && discoveredLinks.size < maxLinks; i++) { + const [currentUrl, urlDepth] = queue[i]; + + if (!scrapedUrls.has(currentUrl)) { + scrapedUrls.add(currentUrl); + const newLinks = await getPageLinks(currentUrl, baseUrl); + + for (const link of newLinks) { + if (!discoveredLinks.has(link) && discoveredLinks.size < maxLinks) { + discoveredLinks.add(link); + if (urlDepth + 1 < maxDepth) { + nextQueue.push([link, urlDepth + 1]); + } + } + } + } + } + + queue = nextQueue; + if (queue.length === 0 || discoveredLinks.size >= maxLinks) break; + } + + return Array.from(discoveredLinks); +} + +async function getPageLinks(url, baseUrl) { + try { + const runtimeSettings = new RuntimeSettings(); + /** @type {import('puppeteer').PuppeteerLaunchOptions} */ + let launchConfig = { headless: "new" }; + + /* On MacOS 15.1, the headless=new option causes the browser to crash immediately. + * It is not clear why this is the case, but it is reproducible. Since AnythinglLM + * in production runs in a container, we can disable headless mode to workaround the issue for development purposes. + * + * This may show a popup window when scraping a page in development mode. + * This is expected behavior if seen in development mode on MacOS 15+ + */ + if ( + process.platform === "darwin" && + process.env.NODE_ENV === "development" + ) { + console.log( + "Darwin Development Mode: Disabling headless mode to prevent Chromium from crashing." + ); + launchConfig.headless = "false"; + } + + const loader = new PuppeteerWebBaseLoader(url, { + launchOptions: { + headless: launchConfig.headless, + ignoreHTTPSErrors: true, + args: runtimeSettings.get("browserLaunchArgs"), + }, + gotoOptions: { waitUntil: "networkidle2" }, + }); + const docs = await loader.load(); + const html = docs[0].pageContent; + const links = extractLinks(html, baseUrl); + return links; + } catch (error) { + console.error(`Failed to get page links from ${url}.`, error); + return []; + } +} + +function extractLinks(html, baseUrl) { + const root = parse(html); + const links = root.querySelectorAll("a"); + const extractedLinks = new Set(); + + for (const link of links) { + const href = link.getAttribute("href"); + if (href) { + const absoluteUrl = new URL(href, baseUrl.href).href; + if ( + absoluteUrl.startsWith( + baseUrl.origin + baseUrl.pathname.split("/").slice(0, -1).join("/") + ) + ) { + extractedLinks.add(absoluteUrl); + } + } + } + + return Array.from(extractedLinks); +} + +async function bulkScrapePages(links, outFolderPath) { + const runtimeSettings = new RuntimeSettings(); + /** @type {import('puppeteer').PuppeteerLaunchOptions} */ + let launchConfig = { headless: "new" }; + + /* On MacOS 15.1, the headless=new option causes the browser to crash immediately. + * It is not clear why this is the case, but it is reproducible. Since AnythinglLM + * in production runs in a container, we can disable headless mode to workaround the issue for development purposes. + * + * This may show a popup window when scraping a page in development mode. + * This is expected behavior if seen in development mode on MacOS 15+ + */ + if (process.platform === "darwin" && process.env.NODE_ENV === "development") { + console.log( + "Darwin Development Mode: Disabling headless mode to prevent Chromium from crashing." + ); + launchConfig.headless = "false"; + } + + const scrapedData = []; + + for (let i = 0; i < links.length; i++) { + const link = links[i]; + console.log(`Scraping ${i + 1}/${links.length}: ${link}`); + + try { + const loader = new PuppeteerWebBaseLoader(link, { + launchOptions: { + headless: launchConfig.headless, + ignoreHTTPSErrors: true, + args: runtimeSettings.get("browserLaunchArgs"), + }, + gotoOptions: { waitUntil: "networkidle2" }, + async evaluate(page, browser) { + const result = await page.evaluate(() => document.body.innerText); + await browser.close(); + return result; + }, + }); + const docs = await loader.load(); + const content = docs[0].pageContent; + + if (!content.length) { + console.warn(`Empty content for ${link}. Skipping.`); + continue; + } + + const url = new URL(link); + const decodedPathname = decodeURIComponent(url.pathname); + const filename = `${url.hostname}${decodedPathname.replace(/\//g, "_")}`; + + const data = { + id: v4(), + url: "file://" + slugify(filename) + ".html", + title: slugify(filename) + ".html", + docAuthor: "no author found", + description: "No description found.", + docSource: "URL link uploaded by the user.", + chunkSource: `link://${link}`, + published: new Date().toLocaleString(), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + writeToServerDocuments({ + data, + filename: data.title, + destinationOverride: outFolderPath, + }); + scrapedData.push(data); + + console.log(`Successfully scraped ${link}.`); + } catch (error) { + console.error(`Failed to scrape ${link}.`, error); + } + } + + return scrapedData; +} + +async function websiteScraper(startUrl, depth = 1, maxLinks = 20) { + const websiteName = new URL(startUrl).hostname; + const outFolder = slugify( + `${slugify(websiteName)}-${v4().slice(0, 4)}` + ).toLowerCase(); + const outFolderPath = path.resolve(documentsFolder, outFolder); + console.log("Discovering links..."); + const linksToScrape = await discoverLinks(startUrl, depth, maxLinks); + console.log(`Found ${linksToScrape.length} links to scrape.`); + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + console.log("Starting bulk scraping..."); + const scrapedData = await bulkScrapePages(linksToScrape, outFolderPath); + console.log(`Scraped ${scrapedData.length} pages.`); + + return scrapedData; +} + +module.exports = websiteScraper; diff --git a/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/index.js b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/index.js new file mode 100644 index 0000000..14a4639 --- /dev/null +++ b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/index.js @@ -0,0 +1,99 @@ +const { validYoutubeVideoUrl } = require("../../../url"); + +/* + * This is just a custom implementation of the Langchain JS YouTubeLoader class + * as the dependency for YoutubeTranscript is quite fickle and its a rat race to keep it up + * and instead of waiting for patches we can just bring this simple script in-house and at least + * be able to patch it since its so flaky. When we have more connectors we can kill this because + * it will be a pain to maintain over time. + */ +class YoutubeLoader { + #videoId; + #language; + #addVideoInfo; + + constructor({ videoId = null, language = null, addVideoInfo = false } = {}) { + if (!videoId) throw new Error("Invalid video id!"); + this.#videoId = videoId; + this.#language = language; + this.#addVideoInfo = addVideoInfo; + } + + /** + * Extracts the videoId from a YouTube video URL. + * @param url The URL of the YouTube video. + * @returns The videoId of the YouTube video. + */ + static getVideoID(url) { + const videoId = validYoutubeVideoUrl(url, true); + if (videoId) return videoId; + throw new Error("Failed to get youtube video id from the url"); + } + + /** + * Creates a new instance of the YoutubeLoader class from a YouTube video + * URL. + * @param url The URL of the YouTube video. + * @param config Optional configuration options for the YoutubeLoader instance, excluding the videoId. + * @returns A new instance of the YoutubeLoader class. + */ + static createFromUrl(url, config = {}) { + const videoId = YoutubeLoader.getVideoID(url); + return new YoutubeLoader({ ...config, videoId }); + } + + /** + * Loads the transcript and video metadata from the specified YouTube + * video. It uses the youtube-transcript library to fetch the transcript + * and the youtubei.js library to fetch the video metadata. + * @returns Langchain like doc that is 1 element with PageContent and + */ + async load() { + let transcript; + const metadata = { + source: this.#videoId, + }; + try { + const fetchTranscript = await import("youtube-transcript-plus").then( + (module) => module.fetchTranscript + ); + const transcriptSegments = await fetchTranscript(this.#videoId, { + lang: this.#language, + }); + if (!transcriptSegments || transcriptSegments.length === 0) + throw new Error("Transcription not found"); + transcript = this.#convertTranscriptSegmentsToText(transcriptSegments); + if (this.#addVideoInfo) { + const { Innertube } = require("youtubei.js"); + const youtube = await Innertube.create(); + const info = (await youtube.getBasicInfo(this.#videoId)).basic_info; + metadata.description = info.short_description; + metadata.title = info.title; + metadata.view_count = info.view_count; + metadata.author = info.author; + } + } catch (e) { + throw new Error( + `Failed to get YouTube video transcription: ${e?.message}` + ); + } + return [ + { + pageContent: transcript, + metadata, + }, + ]; + } + + #convertTranscriptSegmentsToText(transcriptSegments) { + return transcriptSegments + .map((segment) => + typeof segment === "string" ? segment : segment.text || "" + ) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + } +} + +module.exports.YoutubeLoader = YoutubeLoader; diff --git a/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js new file mode 100644 index 0000000..5a7cb87 --- /dev/null +++ b/collector/utils/extensions/YoutubeTranscript/YoutubeLoader/youtube-transcript.js @@ -0,0 +1,246 @@ +const { validYoutubeVideoUrl } = require("../../../url"); + +class YoutubeTranscriptError extends Error { + constructor(message) { + super(`[YoutubeTranscript] ${message}`); + } +} + +/** + * Handles fetching and parsing YouTube video transcripts + */ +class YoutubeTranscript { + /** + * Encodes a string as a protobuf field + * @param {number} fieldNumber - The protobuf field number + * @param {string} str - The string to encode + * @returns {Buffer} Encoded protobuf field + */ + static #encodeProtobufString(fieldNumber, str) { + const utf8Bytes = Buffer.from(str, "utf8"); + const tag = (fieldNumber << 3) | 2; // wire type 2 for string + const lengthBytes = this.#encodeVarint(utf8Bytes.length); + + return Buffer.concat([ + Buffer.from([tag]), + Buffer.from(lengthBytes), + utf8Bytes, + ]); + } + + /** + * Encodes a number as a protobuf varint + * @param {number} value - The number to encode + * @returns {number[]} Encoded varint bytes + */ + static #encodeVarint(value) { + const bytes = []; + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80); + value >>>= 7; + } + bytes.push(value); + return bytes; + } + + /** + * Creates a base64 encoded protobuf message + * @param {Object} param - The parameters to encode + * @param {string} param.param1 - First parameter + * @param {string} param.param2 - Second parameter + * @returns {string} Base64 encoded protobuf + */ + static #getBase64Protobuf({ param1, param2 }) { + const field1 = this.#encodeProtobufString(1, param1); + const field2 = this.#encodeProtobufString(2, param2); + return Buffer.concat([field1, field2]).toString("base64"); + } + + /** + * Extracts transcript text from YouTube API response + * @param {Object} responseData - The YouTube API response + * @returns {string} Combined transcript text + */ + static #extractTranscriptFromResponse(responseData) { + const transcriptRenderer = + responseData.actions?.[0]?.updateEngagementPanelAction?.content + ?.transcriptRenderer; + if (!transcriptRenderer) { + throw new Error("No transcript data found in response"); + } + + const segments = + transcriptRenderer.content?.transcriptSearchPanelRenderer?.body + ?.transcriptSegmentListRenderer?.initialSegments; + if (!segments) { + throw new Error("Transcript segments not found in response"); + } + + return segments + .map((segment) => { + const runs = segment.transcriptSegmentRenderer?.snippet?.runs; + return runs ? runs.map((run) => run.text).join("") : ""; + }) + .filter((text) => text) + .join(" ") + .trim() + .replace(/\s+/g, " "); + } + + /** + * Calculates a preference score for a caption track to determine the best match + * @param {Object} track - The caption track object from YouTube + * @param {string} track.languageCode - ISO language code (e.g., 'zh-HK', 'en', 'es') + * @param {string} track.kind - Track type ('asr' for auto-generated, "" for human-transcribed) + * @param {string[]} preferredLanguages - Array of language codes in preference order (e.g., ['zh-HK', 'en']) + * @returns {number} Preference score (lower is better) + */ + static #calculatePreferenceScore(track, preferredLanguages) { + // Language preference: index in preferredLanguages array (0 = most preferred) + const languagePreference = preferredLanguages.indexOf(track.languageCode); + const languageScore = languagePreference === -1 ? 9999 : languagePreference; + + // Kind bonus: prefer human-transcribed (undefined) over auto-generated ('asr') + const kindBonus = track.kind === "asr" ? 0.5 : 0; + + return languageScore + kindBonus; + } + + /** + * Finds the most suitable caption track based on preferred languages + * @param {string} videoBody - The raw HTML response from YouTube + * @param {string[]} preferredLanguages - Array of language codes in preference order + * @returns {Object|null} The selected caption track or null if none found + */ + static #findPreferredCaptionTrack(videoBody, preferredLanguages) { + const captionsConfigJson = videoBody.match( + /"captions":(.*?),"videoDetails":/s + ); + + const captionsConfig = captionsConfigJson?.[1] + ? JSON.parse(captionsConfigJson[1]) + : null; + + const captionTracks = captionsConfig + ? captionsConfig.playerCaptionsTracklistRenderer.captionTracks + : null; + + if (!captionTracks || captionTracks.length === 0) { + return null; + } + + const sortedTracks = [...captionTracks].sort((a, b) => { + const scoreA = this.#calculatePreferenceScore(a, preferredLanguages); + const scoreB = this.#calculatePreferenceScore(b, preferredLanguages); + return scoreA - scoreB; + }); + + return sortedTracks[0]; + } + + /** + * Fetches video page content and finds the preferred caption track + * @param {string} videoId - YouTube video ID + * @param {string[]} preferredLanguages - Array of preferred language codes + * @returns {Promise} The preferred caption track + * @throws {YoutubeTranscriptError} If no suitable caption track is found + */ + static async #getPreferredCaptionTrack(videoId, preferredLanguages) { + const videoResponse = await fetch( + `https://www.youtube.com/watch?v=${videoId}`, + { credentials: "omit" } + ); + const videoBody = await videoResponse.text(); + + const preferredCaptionTrack = this.#findPreferredCaptionTrack( + videoBody, + preferredLanguages + ); + + if (!preferredCaptionTrack) { + throw new YoutubeTranscriptError( + "No suitable caption track found for the video" + ); + } + + return preferredCaptionTrack; + } + + /** + * Fetch transcript from YouTube video + * @param {string} videoId - Video URL or video identifier + * @param {Object} config - Configuration options + * @param {string} [config.lang='en'] - Language code (e.g., 'en', 'es', 'fr') + * @returns {Promise} Video transcript text + */ + static async fetchTranscript(videoId, config = {}) { + const preferredLanguages = config?.lang ? [config?.lang, "en"] : ["en"]; + const identifier = this.retrieveVideoId(videoId); + + try { + const preferredCaptionTrack = await this.#getPreferredCaptionTrack( + identifier, + preferredLanguages + ); + + const innerProto = this.#getBase64Protobuf({ + param1: preferredCaptionTrack.kind || "", + param2: preferredCaptionTrack.languageCode, + }); + + const params = this.#getBase64Protobuf({ + param1: identifier, + param2: innerProto, + }); + + const response = await fetch( + "https://www.youtube.com/youtubei/v1/get_transcript", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36,gzip(gfe)", + }, + body: JSON.stringify({ + context: { + client: { + clientName: "WEB", + clientVersion: "2.20240826.01.00", + }, + }, + params, + }), + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData = await response.json(); + return this.#extractTranscriptFromResponse(responseData); + } catch (e) { + throw new YoutubeTranscriptError(e.message || e); + } + } + + /** + * Extract video ID from a YouTube URL or verify an existing ID + * @param {string} videoId - Video URL or ID + * @returns {string} YouTube video ID + */ + static retrieveVideoId(videoId) { + if (videoId.length === 11) return videoId; // already a valid ID most likely + const matchedId = validYoutubeVideoUrl(videoId, true); + if (matchedId) return matchedId; + throw new YoutubeTranscriptError( + "Impossible to retrieve Youtube video ID." + ); + } +} + +module.exports = { + YoutubeTranscript, + YoutubeTranscriptError, +}; diff --git a/collector/utils/extensions/YoutubeTranscript/index.js b/collector/utils/extensions/YoutubeTranscript/index.js new file mode 100644 index 0000000..02b2e4c --- /dev/null +++ b/collector/utils/extensions/YoutubeTranscript/index.js @@ -0,0 +1,219 @@ +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { v4 } = require("uuid"); +const { + writeToServerDocuments, + sanitizeFileName, + documentsFolder, + isWithin, +} = require("../../files"); +const { tokenizeString } = require("../../tokenizer"); +const { YoutubeLoader } = require("./YoutubeLoader"); +const { validYoutubeVideoUrl } = require("../../url"); + +/** + * Fetch the transcript content for a YouTube video + * @param {string} url - The URL of the YouTube video + * @returns {Promise<{success: boolean, reason: string|null, content: string|null, metadata: TranscriptMetadata}>} - The transcript content for the YouTube video + */ +async function fetchVideoTranscriptContent({ url }) { + if (!validYoutubeVideoUrl(url)) { + return { + success: false, + reason: "Invalid URL. Should be youtu.be or youtube.com/watch.", + content: null, + metadata: {}, + }; + } + + console.log(`-- Working YouTube ${url} --`); + const loader = YoutubeLoader.createFromUrl(url, { addVideoInfo: true }); + const { docs, error } = await loader + .load() + .then((docs) => ({ docs, error: null })) + .catch((e) => ({ + docs: [], + error: e.message?.split("Error:")?.[1] || e.message, + })); + + if (!docs.length || !!error) { + return { + success: false, + reason: error ?? "No transcript found for that YouTube video.", + content: null, + metadata: {}, + }; + } + + const metadata = docs[0].metadata; + const content = docs[0].pageContent; + if (!content.length) { + return { + success: false, + reason: "No transcript could be parsed for that YouTube video.", + content: null, + metadata: {}, + }; + } + + return { + success: true, + reason: null, + content, + metadata, + }; +} + +/** + * @typedef {Object} TranscriptMetadata + * @property {string} title - The title of the video + * @property {string} author - The author of the video + * @property {string} description - The description of the video + * @property {string} view_count - The view count of the video + * @property {string} source - The source of the video (videoId) + */ + +/** + * @typedef {Object} TranscriptAsDocument + * @property {boolean} success - Whether the transcript was successful + * @property {string|null} reason - The reason for the transcript + * @property {TranscriptMetadata} metadata - The metadata from the transcript + */ + +/** + * @typedef {Object} TranscriptAsContent + * @property {boolean} success - Whether the transcript was successful + * @property {string|null} reason - The reason for the transcript + * @property {string|null} content - The content of the transcript + * @property {Object[]} documents - The documents from the transcript + * @property {boolean} saveAsDocument - Whether to save the transcript as a document + */ + +/** + * Load the transcript content for a YouTube video as well as save it to the server documents + * @param {Object} params - The parameters for the YouTube transcript + * @param {string} params.url - The URL of the YouTube video + * @param {Object} options - The options for the YouTube transcript + * @param {boolean} options.parseOnly - Whether to parse the transcript content only or save it to the server documents + * @returns {Promise} - The transcript content for the YouTube video + */ +async function loadYouTubeTranscript({ url }, options = { parseOnly: false }) { + const transcriptResults = await fetchVideoTranscriptContent({ url }); + if (!transcriptResults.success) { + return { + success: false, + reason: + transcriptResults.reason || + "An unknown error occurred during transcription retrieval", + documents: [], + content: null, + saveAsDocument: options.parseOnly, + data: {}, + }; + } + + const { content, metadata } = transcriptResults; + + if (options.parseOnly) { + return { + success: true, + reason: null, + content: buildTranscriptContentWithMetadata(content, metadata), + documents: [], + saveAsDocument: options.parseOnly, + data: {}, + }; + } + + const outFolder = sanitizeFileName( + slugify(`${metadata.author} YouTube transcripts`).toLowerCase() + ); + const outFolderPath = path.resolve(documentsFolder, outFolder); + const uuid = v4(); + const fileName = sanitizeFileName(`${slugify(metadata.title)}-${uuid}`); + + if (!isWithin(documentsFolder, path.resolve(outFolderPath, fileName))) { + console.error( + `[YouTube Loader]: Invalid file path ${path.resolve( + outFolderPath, + fileName + )} is not within the documents folder ${documentsFolder}` + ); + return { + success: false, + reason: `[YouTube Loader]: Invalid file path ${path.resolve( + outFolderPath, + fileName + )} is not within the documents folder ${documentsFolder}`, + documents: [], + data: {}, + }; + } + + if (!fs.existsSync(outFolderPath)) + fs.mkdirSync(outFolderPath, { recursive: true }); + const data = { + id: uuid, + url: url + ".youtube", + title: metadata.title || url, + docAuthor: metadata.author, + description: metadata.description, + docSource: url, + chunkSource: `youtube://${url}`, + published: new Date().toLocaleString(), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: tokenizeString(content), + }; + + console.log(`[YouTube Loader]: Saving ${metadata.title} to ${outFolder}`); + const document = writeToServerDocuments({ + data, + filename: fileName, + destinationOverride: outFolderPath, + }); + + return { + success: true, + reason: null, + documents: [document], + data: { + title: metadata.title, + author: metadata.author, + destination: outFolder, + }, + }; +} + +/** + * Generate the transcript content and metadata into a single string + * + * Why? For ephemeral documents where we just want the content, we want to include the metadata as keys in the content + * so that the LLM has context about the video, this gives it a better understanding of the video + * and allows it to use the metadata in the conversation if relevant. + * Examples: + * - How many views does have? + * - Checkout and tell me the key points and if it is performing well + * - Summarize this video ? -> description could have links and references + * @param {string} content - The content of the transcript + * @param {TranscriptMetadata} metadata - The metadata from the transcript + * @returns {string} - The concatenated transcript content and metadata + */ +function buildTranscriptContentWithMetadata(content = "", metadata = {}) { + const VALID_METADATA_KEYS = ["title", "author", "description", "view_count"]; + if (!content || !metadata || Object.keys(metadata).length === 0) + return content; + + let contentWithMetadata = ""; + VALID_METADATA_KEYS.forEach((key) => { + if (!metadata[key]) return; + contentWithMetadata += `<${key}>${metadata[key]}`; + }); + return `${contentWithMetadata}\nTranscript:\n${content}`; +} + +module.exports = { + loadYouTubeTranscript, + fetchVideoTranscriptContent, +}; diff --git a/collector/utils/files/index.js b/collector/utils/files/index.js new file mode 100644 index 0000000..76d9bba --- /dev/null +++ b/collector/utils/files/index.js @@ -0,0 +1,254 @@ +const fs = require("fs"); +const path = require("path"); +const { MimeDetector } = require("./mime"); + +/** + * The folder where documents are stored to be stored when + * processed by the collector. + */ +const documentsFolder = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../../server/storage/documents`) + : path.resolve(process.env.STORAGE_DIR, `documents`); + +/** + * The folder where direct uploads are stored to be stored when + * processed by the collector. These are files that were DnD'd into UI + * and are not to be embedded or selectable from the file picker. + */ +const directUploadsFolder = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../../server/storage/direct-uploads`) + : path.resolve(process.env.STORAGE_DIR, `direct-uploads`); + +/** + * Checks if a file is text by checking the mime type and then falling back to buffer inspection. + * This way we can capture all the cases where the mime type is not known but still parseable as text + * without having to constantly add new mime type overrides. + * @param {string} filepath - The path to the file. + * @returns {boolean} - Returns true if the file is text, false otherwise. + */ +function isTextType(filepath) { + if (!fs.existsSync(filepath)) return false; + const result = isKnownTextMime(filepath); + if (result.valid) return true; // Known text type - return true. + if (result.reason !== "generic") return false; // If any other reason than generic - return false. + return parseableAsText(filepath); // Fallback to parsing as text via buffer inspection. +} + +/** + * Checks if a file is known to be text by checking the mime type. + * @param {string} filepath - The path to the file. + * @returns {boolean} - Returns true if the file is known to be text, false otherwise. + */ +function isKnownTextMime(filepath) { + try { + const mimeLib = new MimeDetector(); + const mime = mimeLib.getType(filepath); + if (mimeLib.badMimes.includes(mime)) + return { valid: false, reason: "bad_mime" }; + + const type = mime.split("/")[0]; + if (mimeLib.nonTextTypes.includes(type)) + return { valid: false, reason: "non_text_mime" }; + return { valid: true, reason: "valid_mime" }; + } catch { + return { valid: false, reason: "generic" }; + } +} + +/** + * Checks if a file is parseable as text by forcing it to be read as text in utf8 encoding. + * If the file looks too much like a binary file, it will return false. + * @param {string} filepath - The path to the file. + * @returns {boolean} - Returns true if the file is parseable as text, false otherwise. + */ +function parseableAsText(filepath) { + try { + const fd = fs.openSync(filepath, "r"); + const buffer = Buffer.alloc(1024); // Read first 1KB of the file synchronously + const bytesRead = fs.readSync(fd, buffer, 0, 1024, 0); + fs.closeSync(fd); + + const content = buffer.subarray(0, bytesRead).toString("utf8"); + const nullCount = (content.match(/\0/g) || []).length; + //eslint-disable-next-line + const controlCount = (content.match(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g) || []) + .length; + + const threshold = bytesRead * 0.1; + return nullCount + controlCount < threshold; + } catch { + return false; + } +} + +function trashFile(filepath) { + if (!fs.existsSync(filepath)) return; + + try { + const isDir = fs.lstatSync(filepath).isDirectory(); + if (isDir) return; + } catch { + return; + } + + fs.rmSync(filepath); + return; +} + +function createdDate(filepath) { + try { + const { birthtimeMs, birthtime } = fs.statSync(filepath); + if (birthtimeMs === 0) throw new Error("Invalid stat for file!"); + return birthtime.toLocaleString(); + } catch { + return "unknown"; + } +} + +/** + * Writes a document to the server documents folder. + * @param {Object} params - The parameters for the function. + * @param {Object} params.data - The data to write to the file. Must look like a document object. + * @param {string} params.filename - The name of the file to write to. + * @param {string|null} params.destinationOverride - A forced destination to write to - will be honored if provided. + * @param {Object} params.options - The options for the function. + * @param {boolean} params.options.parseOnly - If true, the file will be written to the direct uploads folder instead of the documents folder. Will be ignored if destinationOverride is provided. + * @returns {Object} - The data with the location added. + */ +function writeToServerDocuments({ + data = {}, + filename, + destinationOverride = null, + options = {}, +}) { + if (!filename) throw new Error("Filename is required!"); + + let destination = null; + if (destinationOverride) destination = path.resolve(destinationOverride); + else if (options.parseOnly) destination = path.resolve(directUploadsFolder); + else destination = path.resolve(documentsFolder, "custom-documents"); + + if (!fs.existsSync(destination)) + fs.mkdirSync(destination, { recursive: true }); + const safeFilename = sanitizeFileName(filename); + const destinationFilePath = normalizePath( + path.resolve(destination, safeFilename) + ".json" + ); + + fs.writeFileSync(destinationFilePath, JSON.stringify(data, null, 4), { + encoding: "utf-8", + }); + + return { + ...data, + // relative location string that can be passed into the /update-embeddings api + // that will work since we know the location exists and since we only allow + // 1-level deep folders this will always work. This still works for integrations like GitHub and YouTube. + location: destinationFilePath.split("/").slice(-2).join("/"), + isDirectUpload: options.parseOnly || false, + }; +} + +// When required we can wipe the entire collector hotdir and tmp storage in case +// there were some large file failures that we unable to be removed a reboot will +// force remove them. +async function wipeCollectorStorage() { + const cleanHotDir = new Promise((resolve) => { + const directory = path.resolve(__dirname, "../../hotdir"); + + if (!fs.existsSync(directory)) resolve(); + fs.readdir(directory, (err, files) => { + if (err) resolve(); + + for (const file of files) { + if (file === "__HOTDIR__.md") continue; + try { + fs.rmSync(path.join(directory, file)); + } catch {} + } + resolve(); + }); + }); + + const cleanTmpDir = new Promise((resolve) => { + const directory = path.resolve(__dirname, "../../storage/tmp"); + fs.readdir(directory, (err, files) => { + if (err) resolve(); + + for (const file of files) { + if (file === ".placeholder") continue; + try { + fs.rmSync(path.join(directory, file)); + } catch {} + } + resolve(); + }); + }); + + await Promise.all([cleanHotDir, cleanTmpDir]); + console.log(`Collector hot directory and tmp storage wiped!`); + return; +} + +/** + * Checks if a given path is strictly within another path. Used to prevent + * path-traversal attacks (CWE-22). Both arguments are resolved to absolute + * paths internally so callers do not need to pre-resolve. + * + * NOTE: This function does NOT follow or detect symlinks. A symlink inside + * `outer` that points outside it will not be caught here — validate symlinks + * separately at read/write time if your threat model requires it (wontfix). + * + * @param {string} outer - The containing directory path. + * @param {string} inner - The path to test. + * @returns {boolean} True if `inner` is strictly inside `outer`, false otherwise. + */ +function isWithin(outer, inner) { + const resolvedOuter = path.resolve(outer); + const resolvedInner = path.resolve(inner); + const rel = path.relative(resolvedOuter, resolvedInner); + + if (rel === "") return false; + return ( + !rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel) + ); +} + +function normalizePath(filepath = "") { + const result = path + .normalize(filepath.trim()) + .replace(/^(\.\.(\/|\\|$))+/, "") + .trim(); + if (["..", ".", "/"].includes(result)) throw new Error("Invalid path."); + return result; +} + +/** + * Strips characters that are illegal in Windows filenames, including Unicode + * quotation marks (U+201C, U+201D, etc.) that can get corrupted into ASCII + * double-quotes during charset conversion in the upload pipeline. + * @param {string} fileName - The filename to sanitize. + * @returns {string} - The sanitized filename. + */ +function sanitizeFileName(fileName) { + if (!fileName) return fileName; + return fileName.replace( + /[<>:"/\\|?*\u201C\u201D\u201E\u201F\u2018\u2019\u201A\u201B]/g, + "" + ); +} + +module.exports = { + trashFile, + isTextType, + createdDate, + writeToServerDocuments, + wipeCollectorStorage, + normalizePath, + isWithin, + sanitizeFileName, + documentsFolder, + directUploadsFolder, +}; diff --git a/collector/utils/files/mime.js b/collector/utils/files/mime.js new file mode 100644 index 0000000..bd95496 --- /dev/null +++ b/collector/utils/files/mime.js @@ -0,0 +1,64 @@ +const MimeLib = require("mime"); +class MimeDetector { + nonTextTypes = ["multipart", "model", "audio", "video", "font"]; + badMimes = [ + "application/octet-stream", + "application/zip", + "application/pkcs8", + "application/vnd.microsoft.portable-executable", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // XLSX are binaries and need to be handled explicitly. + "application/x-msdownload", + ]; + + constructor() { + this.lib = MimeLib; + this.setOverrides(); + } + + setOverrides() { + // the .ts extension maps to video/mp2t because of https://en.wikipedia.org/wiki/MPEG_transport_stream + // which has had this extension far before TS was invented. So need to force re-map this MIME map. + this.lib.define( + { + "text/plain": [ + "ts", + "tsx", + "py", + "opts", + "lock", + "jsonl", + "qml", + "sh", + "c", + "cs", + "h", + "js", + "lua", + "pas", + "r", + "go", + "ino", + "hpp", + "linq", + "cs", + ], + }, + true + ); + } + + /** + * Returns the MIME type of the file. If the file has no extension found, it will be processed as a text file. + * @param {string} filepath + * @returns {string} + */ + getType(filepath) { + const parsedMime = this.lib.getType(filepath); + if (!!parsedMime) return parsedMime; + return null; + } +} + +module.exports = { + MimeDetector, +}; diff --git a/collector/utils/http/index.js b/collector/utils/http/index.js new file mode 100644 index 0000000..8b71720 --- /dev/null +++ b/collector/utils/http/index.js @@ -0,0 +1,54 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); +const DEFAULT_COLLECTOR_PORT = 8888; + +function reqBody(request) { + return typeof request.body === "string" + ? JSON.parse(request.body) + : request.body; +} + +function queryParams(request) { + return request.query; +} + +/** + * Validates if the provided baseUrl is a valid URL at all. + * - Does not validate if the URL is reachable or accessible. + * - Does not do any further validation of the URL like `validURL` in `utils/url/index.js` + * @param {string} baseUrl + * @returns {boolean} + */ +function validBaseUrl(baseUrl) { + try { + new URL(baseUrl); + return true; + } catch { + return false; + } +} + +/** + * Gets the collector port from the environment variables. + * If the port is not set, it will fall back to the default port. + * If the port is invalid, it will log a warning and return the default port. + * @returns {number} + */ +function getCollectorPort() { + if (!("COLLECTOR_PORT" in process.env)) return DEFAULT_COLLECTOR_PORT; + const port = Number(process.env.COLLECTOR_PORT); + if (Number.isInteger(port) && port > 0 && port <= 65535) return port; + + console.warn( + `Invalid COLLECTOR_PORT "${process.env.COLLECTOR_PORT}". Falling back to ${DEFAULT_COLLECTOR_PORT}.` + ); + return DEFAULT_COLLECTOR_PORT; +} + +module.exports = { + reqBody, + queryParams, + validBaseUrl, + getCollectorPort, +}; diff --git a/collector/utils/logger/index.js b/collector/utils/logger/index.js new file mode 100644 index 0000000..a0eddec --- /dev/null +++ b/collector/utils/logger/index.js @@ -0,0 +1,68 @@ +const winston = require("winston"); + +class Logger { + logger = console; + static _instance; + constructor() { + if (Logger._instance) return Logger._instance; + this.logger = + process.env.NODE_ENV === "production" ? this.getWinstonLogger() : console; + Logger._instance = this; + } + + getWinstonLogger() { + const logger = winston.createLogger({ + level: "info", + defaultMeta: { service: "collector" }, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.printf( + ({ level, message, service, origin = "" }) => { + return `\x1b[36m[${service}]\x1b[0m${ + origin ? `\x1b[33m[${origin}]\x1b[0m` : "" + } ${level}: ${message}`; + } + ) + ), + }), + ], + }); + + function formatArgs(args) { + return args + .map((arg) => { + if (arg instanceof Error) { + return arg.stack; // If argument is an Error object, return its stack trace + } else if (typeof arg === "object") { + return JSON.stringify(arg); // Convert objects to JSON string + } else { + return arg; // Otherwise, return as-is + } + }) + .join(" "); + } + + console.log = function (...args) { + logger.info(formatArgs(args)); + }; + console.error = function (...args) { + logger.error(formatArgs(args)); + }; + console.info = function (...args) { + logger.warn(formatArgs(args)); + }; + return logger; + } +} + +/** + * Sets and overrides Console methods for logging when called. + * This is a singleton method and will not create multiple loggers. + * @returns {winston.Logger | console} - instantiated logger interface. + */ +function setLogger() { + return new Logger().logger; +} +module.exports = setLogger; diff --git a/collector/utils/runtimeSettings/index.js b/collector/utils/runtimeSettings/index.js new file mode 100644 index 0000000..5469680 --- /dev/null +++ b/collector/utils/runtimeSettings/index.js @@ -0,0 +1,97 @@ +const { reqBody } = require("../http"); + +/** + * Runtime settings are used to configure the collector per-request. + * These settings are persisted across requests, but can be overridden per-request. + * + * The settings are passed in the request body via `options.runtimeSettings` + * which is set in the backend #attachOptions function in CollectorApi. + * + * We do this so that the collector and backend can share the same ENV variables + * but only pass the relevant settings to the collector per-request and be able to + * access them across the collector via a single instance of RuntimeSettings. + * + * TODO: We may want to set all options passed from backend to collector here, + * but for now - we are only setting the runtime settings specifically for backwards + * compatibility with existing CollectorApi usage. + */ +class RuntimeSettings { + static _instance = null; + settings = {}; + + // Any settings here will be persisted across requests + // and must be explicitly defined here. + settingConfigs = { + seenAnyIpWarning: { + default: false, + validate: (value) => String(value) === "true", + }, + allowAnyIp: { + default: false, + // Value must be explicitly "true" or "false" as a string + validate: (value) => String(value) === "true", + }, + browserLaunchArgs: { + default: [], + validate: (value) => { + let args = []; + if (Array.isArray(value)) args = value.map((arg) => String(arg.trim())); + if (typeof value === "string") + args = value.split(",").map((arg) => arg.trim()); + return args; + }, + }, + }; + + constructor() { + if (RuntimeSettings._instance) return RuntimeSettings._instance; + RuntimeSettings._instance = this; + return this; + } + + /** + * Parse the runtime settings from the request body options body + * see #attachOptions https://github.com/Mintplex-Labs/anything-llm/blob/ebf112007e0d579af3d2b43569db95bdfc59074b/server/utils/collectorApi/index.js#L18 + * @param {import('express').Request} request + * @returns {void} + */ + parseOptionsFromRequest(request = {}) { + const options = reqBody(request)?.options?.runtimeSettings || {}; + for (const [key, value] of Object.entries(options)) { + if (!this.settingConfigs.hasOwnProperty(key)) continue; + this.set(key, value); + } + return; + } + + /** + * Get a runtime setting + * - Will throw an error if the setting requested is not a supported runtime setting key + * - Will return the default value if the setting requested is not set at all + * @param {string} key + * @returns {any} + */ + get(key) { + if (!this.settingConfigs[key]) + throw new Error(`Invalid runtime setting: ${key}`); + return this.settings.hasOwnProperty(key) + ? this.settings[key] + : this.settingConfigs[key].default; + } + + /** + * Set a runtime setting + * - Will throw an error if the setting requested is not a supported runtime setting key + * - Will validate the value against the setting's validate function + * @param {string} key + * @param {any} value + * @returns {void} + */ + set(key, value = null) { + if (!this.settingConfigs[key]) + throw new Error(`Invalid runtime setting: ${key}`); + this.settings[key] = this.settingConfigs[key].validate(value); + } +} + +module.exports = RuntimeSettings; diff --git a/collector/utils/shell.js b/collector/utils/shell.js new file mode 100644 index 0000000..b2030cb --- /dev/null +++ b/collector/utils/shell.js @@ -0,0 +1,25 @@ +/** + * Patch the shell environment path to ensure the PATH is properly set for the current platform. + * On Docker, we are on Node v18 and cannot support fix-path v5. + * So we need to use the ESM-style import() to import the fix-path module + add the strip-ansi call to patch the PATH, which is the only change between v4 and v5. + * https://github.com/sindresorhus/fix-path/issues/6 + * @returns {Promise<{[key: string]: string}>} - Environment variables from shell + */ +async function patchShellEnvironmentPath() { + try { + if (process.platform === "win32") return process.env; + const { default: fixPath } = await import("fix-path"); + const { default: stripAnsi } = await import("strip-ansi"); + fixPath(); + if (process.env.PATH) process.env.PATH = stripAnsi(process.env.PATH); + console.log("Shell environment path patched successfully."); + return process.env; + } catch (error) { + console.error("Failed to patch shell environment path:", error); + return process.env; + } +} + +module.exports = { + patchShellEnvironmentPath, +}; diff --git a/collector/utils/tokenizer/index.js b/collector/utils/tokenizer/index.js new file mode 100644 index 0000000..2086be2 --- /dev/null +++ b/collector/utils/tokenizer/index.js @@ -0,0 +1,66 @@ +const { getEncoding } = require("js-tiktoken"); + +class TikTokenTokenizer { + static MAX_KB_ESTIMATE = 10; + static DIVISOR = 8; + + constructor() { + if (TikTokenTokenizer.instance) { + this.log( + "Singleton instance already exists. Returning existing instance." + ); + return TikTokenTokenizer.instance; + } + + this.encoder = getEncoding("cl100k_base"); + TikTokenTokenizer.instance = this; + this.log("Initialized new TikTokenTokenizer instance."); + } + + log(text, ...args) { + console.log(`\x1b[35m[TikTokenTokenizer]\x1b[0m ${text}`, ...args); + } + + /** + * Check if the input is too long to encode + * this is more of a rough estimate and a sanity check to prevent + * CPU issues from encoding too large of strings + * Assumes 1 character = 2 bytes in JS + * @param {string} input + * @returns {boolean} + */ + #isTooLong(input) { + const bytesEstimate = input.length * 2; + const kbEstimate = Math.floor(bytesEstimate / 1024); + return kbEstimate >= TikTokenTokenizer.MAX_KB_ESTIMATE; + } + + /** + * Encode a string into tokens for rough token count estimation. + * @param {string} input + * @returns {number} + */ + tokenizeString(input = "") { + try { + if (this.#isTooLong(input)) { + this.log("Input will take too long to encode - estimating"); + return Math.ceil(input.length / TikTokenTokenizer.DIVISOR); + } + + return this.encoder.encode(input).length; + } catch (e) { + this.log("Could not tokenize string! Estimating...", e.message, e.stack); + return Math.ceil(input?.length / TikTokenTokenizer.DIVISOR) || 0; + } + } +} + +const tokenizer = new TikTokenTokenizer(); +module.exports = { + /** + * Encode a string into tokens for rough token count estimation. + * @param {string} input + * @returns {number} + */ + tokenizeString: (input) => tokenizer.tokenizeString(input), +}; diff --git a/collector/utils/url/index.js b/collector/utils/url/index.js new file mode 100644 index 0000000..71f31fd --- /dev/null +++ b/collector/utils/url/index.js @@ -0,0 +1,137 @@ +const RuntimeSettings = require("../runtimeSettings"); +/** ATTN: SECURITY RESEARCHERS + * To Security researchers about to submit an SSRF report CVE - please don't. + * We are aware that the code below is does not defend against any of the thousands of ways + * you can map a hostname to another IP via tunneling, hosts editing, etc. The code below does not have intention of blocking this + * and is simply to prevent the user from accidentally putting in non-valid websites, which is all this protects + * since _all urls must be submitted by the user anyway_ and cannot be done with authentication and manager or admin roles. + * If an attacker has those roles then the system is already vulnerable and this is not a primary concern. + * + * We have gotten this report may times, marked them as duplicate or information and continue to get them. We communicate + * already that deployment (and security) of an instance is on the deployer and system admin deploying it. This would include + * isolation, firewalls, and the general security of the instance. + */ + +const VALID_PROTOCOLS = ["https:", "http:"]; +const INVALID_OCTETS = [192, 172, 10, 127, 169]; +const runtimeSettings = new RuntimeSettings(); + +/** + * If an ip address is passed in the user is attempting to collector some internal service running on internal/private IP. + * This is not a security feature and simply just prevents the user from accidentally entering invalid IP addresses. + * Can be bypassed via COLLECTOR_ALLOW_ANY_IP environment variable. + * @param {URL} param0 + * @param {URL['hostname']} param0.hostname + * @returns {boolean} + */ +function isInvalidIp({ hostname }) { + if (runtimeSettings.get("allowAnyIp")) { + if (!runtimeSettings.get("seenAnyIpWarning")) { + console.log( + "\x1b[33mURL IP local address restrictions have been disabled by administrator!\x1b[0m" + ); + runtimeSettings.set("seenAnyIpWarning", true); + } + return false; + } + + const IPRegex = new RegExp( + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/gi + ); + + // Not an IP address at all - passthrough + if (!IPRegex.test(hostname)) return false; + const [octetOne, ..._rest] = hostname.split("."); + + // If fails to validate to number - abort and return as invalid. + if (isNaN(Number(octetOne))) return true; + + // Allow localhost loopback and 0.0.0.0 for scraping convenience + // for locally hosted services or websites + if (["127.0.0.1", "0.0.0.0"].includes(hostname)) return false; + + return INVALID_OCTETS.includes(Number(octetOne)); +} + +/** + * Validates a URL strictly + * - Checks the URL forms a valid URL + * - Checks the URL is at least HTTP(S) + * - Checks the URL is not an internal IP - can be bypassed via COLLECTOR_ALLOW_ANY_IP + * @param {string} url + * @returns {boolean} + */ +function validURL(url) { + try { + const destination = new URL(url); + if (!VALID_PROTOCOLS.includes(destination.protocol)) return false; + if (isInvalidIp(destination)) return false; + return true; + } catch {} + return false; +} + +/** + * Modifies a URL to be valid: + * - Checks the URL is at least HTTP(S) so that protocol exists + * - Checks the URL forms a valid URL + * @param {string} url + * @returns {string} + */ +function validateURL(url) { + try { + let destination = url.trim(); + // If the URL has a protocol, just pass through + // If the URL doesn't have a protocol, assume https:// + if (destination.includes("://")) + destination = new URL(destination).toString(); + else destination = new URL(`https://${destination}`).toString(); + + // If the URL ends with a slash, remove it + return destination.endsWith("/") ? destination.slice(0, -1) : destination; + } catch { + if (typeof url !== "string") return ""; + return url.trim(); + } +} + +/** + * Validate if a link is a valid YouTube video URL + * - Checks youtu.be, youtube.com, m.youtube.com, music.youtube.com + * - Embed video URLs + * - Short URLs + * - Live URLs + * - Regular watch URLs + * - Optional query parameters (including ?v parameter) + * + * Can be used to extract the video ID from a YouTube video URL via the returnVideoId parameter. + * @param {string} link - The link to validate + * @param {boolean} returnVideoId - Whether to return the video ID if the link is a valid YouTube video URL + * @returns {boolean|string} - Whether the link is a valid YouTube video URL or the video ID if returnVideoId is true + */ +function validYoutubeVideoUrl(link, returnVideoId = false) { + try { + if (!link || typeof link !== "string") return false; + let urlToValidate = link; + + if (!link.startsWith("http://") && !link.startsWith("https://")) { + urlToValidate = "https://" + link; + urlToValidate = new URL(urlToValidate).toString(); + } + + const regex = + /^(?:https?:\/\/)?(?:www\.|m\.|music\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?(?:.*&)?v=|(?:live\/)?|shorts\/))([\w-]{11})(?:\S+)?$/; + const match = urlToValidate.match(regex); + if (returnVideoId) return match?.[1] ?? null; + return !!match?.[1]; + } catch (error) { + console.error("Error validating YouTube video URL", error); + return returnVideoId ? null : false; + } +} + +module.exports = { + validURL, + validateURL, + validYoutubeVideoUrl, +}; diff --git a/collector/yarn.lock b/collector/yarn.lock new file mode 100644 index 0000000..9d08c9d --- /dev/null +++ b/collector/yarn.lock @@ -0,0 +1,4425 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@anthropic-ai/sdk@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@anthropic-ai/sdk/-/sdk-0.9.1.tgz#b2d2b7bf05c90dce502c9a2e869066870f69ba88" + integrity sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + digest-fetch "^1.3.0" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + web-streams-polyfill "^3.2.1" + +"@babel/code-frame@^7.0.0": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/helper-validator-identifier@^7.27.1": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + +"@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== + dependencies: + "@so-ric/colorspace" "^1.1.6" + enabled "2.0.x" + kuler "^2.0.0" + +"@emnapi/runtime@^1.2.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" + integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== + dependencies: + tslib "^2.4.0" + +"@eslint-community/eslint-utils@^4.8.0": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.1": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641" + integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ== + dependencies: + ajv "^6.14.0" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.3" + strip-json-comments "^3.1.1" + +"@eslint/js@9.39.3", "@eslint/js@^9.0.0": + version "9.39.3" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.3.tgz#c6168736c7e0c43ead49654ed06a4bcb3833363d" + integrity sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw== + +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== + dependencies: + "@eslint/core" "^0.17.0" + levn "^0.4.1" + +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + +"@huggingface/jinja@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@huggingface/jinja/-/jinja-0.2.2.tgz#faeb205a9d6995089bef52655ddd8245d3190627" + integrity sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA== + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@img/sharp-darwin-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" + integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.4" + +"@img/sharp-darwin-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" + integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.4" + +"@img/sharp-libvips-darwin-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" + integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== + +"@img/sharp-libvips-darwin-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" + integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== + +"@img/sharp-libvips-linux-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" + integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== + +"@img/sharp-libvips-linux-arm@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" + integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== + +"@img/sharp-libvips-linux-s390x@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" + integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== + +"@img/sharp-libvips-linux-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== + +"@img/sharp-libvips-linuxmusl-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" + integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== + +"@img/sharp-libvips-linuxmusl-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== + +"@img/sharp-linux-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" + integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.4" + +"@img/sharp-linux-arm@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" + integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.5" + +"@img/sharp-linux-s390x@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" + integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.4" + +"@img/sharp-linux-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.4" + +"@img/sharp-linuxmusl-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" + integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + +"@img/sharp-linuxmusl-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + +"@img/sharp-wasm32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" + integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== + dependencies: + "@emnapi/runtime" "^1.2.0" + +"@img/sharp-win32-ia32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" + integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== + +"@img/sharp-win32-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" + integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@langchain/community@^0.2.23": + version "0.2.33" + resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.2.33.tgz#c3c920492470196d0f560552cc95954fccdde0e8" + integrity sha512-YsytROnBYoPqtUcV2In+afyLACzxwFrYRn7EBKYL7XWl3XNwrT85U1+nLM5b+MOjXvg9YfJSjrs1Tlbfy4st8g== + dependencies: + "@langchain/core" ">=0.2.21 <0.3.0" + "@langchain/openai" ">=0.2.0 <0.3.0" + binary-extensions "^2.2.0" + expr-eval "^2.0.2" + flat "^5.0.2" + js-yaml "^4.1.0" + langchain "~0.2.3" + langsmith "~0.1.30" + uuid "^10.0.0" + zod "^3.22.3" + zod-to-json-schema "^3.22.5" + +"@langchain/community@~0.0.47": + version "0.0.57" + resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.0.57.tgz#9d77c5acb74a4a8ec01d2cefb71dcd4088701c44" + integrity sha512-tib4UJNkyA4TPNsTNChiBtZmThVJBr7X/iooSmKeCr+yUEha2Yxly3A4OAO95Vlpj4Q+od8HAfCbZih/1XqAMw== + dependencies: + "@langchain/core" "~0.1.60" + "@langchain/openai" "~0.0.28" + expr-eval "^2.0.2" + flat "^5.0.2" + langsmith "~0.1.1" + uuid "^9.0.0" + zod "^3.22.3" + zod-to-json-schema "^3.22.5" + +"@langchain/core@>0.1.56 <0.3.0", "@langchain/core@>0.2.0 <0.3.0", "@langchain/core@>=0.2.21 <0.3.0", "@langchain/core@>=0.2.26 <0.3.0": + version "0.2.36" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.2.36.tgz#75754c33aa5b9310dcf117047374a1ae011005a4" + integrity sha512-qHLvScqERDeH7y2cLuJaSAlMwg3f/3Oc9nayRSXRU2UuaK/SOhI42cxiPLj1FnuHJSmN0rBQFkrLx02gI4mcVg== + dependencies: + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.1.56-rc.1" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^10.0.0" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +"@langchain/core@~0.1.60": + version "0.1.63" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.1.63.tgz#33cc48877739e9fdb5885fbd4b16fd08d1597050" + integrity sha512-+fjyYi8wy6x1P+Ee1RWfIIEyxd9Ee9jksEwvrggPwwI/p45kIDTdYTblXsM13y4mNWTiACyLSdbwnPaxxdoz+w== + dependencies: + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "~0.1.7" + ml-distance "^4.0.0" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +"@langchain/openai@>=0.1.0 <0.3.0", "@langchain/openai@>=0.2.0 <0.3.0": + version "0.2.11" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.2.11.tgz#b1a0403eb5db8133bb4ff41fe0680e727b78ddfc" + integrity sha512-Pu8+WfJojCgSf0bAsXb4AjqvcDyAWyoEB1AoCRNACgEnBWZuitz3hLwCo9I+6hAbeg3QJ37g82yKcmvKAg1feg== + dependencies: + "@langchain/core" ">=0.2.26 <0.3.0" + js-tiktoken "^1.0.12" + openai "^4.57.3" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +"@langchain/openai@~0.0.28": + version "0.0.34" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.0.34.tgz#36c9bca0721ab9f7e5d40927e7c0429cacbd5b56" + integrity sha512-M+CW4oXle5fdoz2T2SwdOef8pl3/1XmUx1vjn2mXUVM/128aO0l23FMF0SNBsAbRV6P+p/TuzjodchJbi0Ht/A== + dependencies: + "@langchain/core" ">0.1.56 <0.3.0" + js-tiktoken "^1.0.12" + openai "^4.41.1" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +"@langchain/textsplitters@~0.0.0": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.0.3.tgz#1a3cc93dd2ab330edb225400ded190a22fea14e3" + integrity sha512-cXWgKE3sdWLSqAa8ykbCcUsUF1Kyr5J3HOWYGuobhPEycXW4WI++d5DhzdpL238mzoEXTi90VqfSCra37l5YqA== + dependencies: + "@langchain/core" ">0.2.0 <0.3.0" + js-tiktoken "^1.0.12" + +"@mixmark-io/domino@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" + integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@pkgr/core@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" + integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + +"@puppeteer/browsers@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-1.8.0.tgz#fb6ee61de15e7f0e67737aea9f9bab1512dbd7d8" + integrity sha512-TkRHIV6k2D8OlUe8RtG+5jgOF/H98Myx0M6AOafC8DdNVOFiBSFa5cpRDtpm8LXOa9sVwe0+e6Q3FC56X/DZfg== + dependencies: + debug "4.3.4" + extract-zip "2.0.1" + progress "2.0.3" + proxy-agent "6.3.1" + tar-fs "3.0.4" + unbzip2-stream "1.4.3" + yargs "17.7.2" + +"@selderee/plugin-htmlparser2@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517" + integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + dependencies: + domhandler "^5.0.3" + selderee "^0.11.0" + +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== + +"@tootallnate/quickjs-emscripten@^0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" + integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== + +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/long@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" + integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== + +"@types/mailparser@^3.0.0": + version "3.4.6" + resolved "https://registry.yarnpkg.com/@types/mailparser/-/mailparser-3.4.6.tgz#fcca99fe9f919f3da691a0bf5e3022c6283c3068" + integrity sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q== + dependencies: + "@types/node" "*" + iconv-lite "^0.6.3" + +"@types/node-fetch@^2.6.4": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + +"@types/node@*", "@types/node@>=13.7.0": + version "24.10.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01" + integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== + dependencies: + undici-types "~7.16.0" + +"@types/node@^18.11.18": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + +"@types/yauzl@^2.9.1": + version "2.10.3" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== + dependencies: + "@types/node" "*" + +"@xenova/transformers@^2.14.0": + version "2.17.2" + resolved "https://registry.yarnpkg.com/@xenova/transformers/-/transformers-2.17.2.tgz#7448d73b90f67bced66f39fe2dd656adc891fde5" + integrity sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ== + dependencies: + "@huggingface/jinja" "^0.2.2" + onnxruntime-web "1.14.0" + sharp "^0.32.0" + optionalDependencies: + onnxruntime-node "1.14.0" + +"@xmldom/xmldom@^0.8.10", "@xmldom/xmldom@^0.8.6": + version "0.8.11" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" + integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== + +"@zone-eu/mailsplit@5.4.7": + version "5.4.7" + resolved "https://registry.yarnpkg.com/@zone-eu/mailsplit/-/mailsplit-5.4.7.tgz#ad86fe08222883418f33cf02d57025de02d2eb38" + integrity sha512-jApX86aDgolMz08pP20/J2zcns02NSK3zSiYouf01QQg4250L+GUAWSWicmS7eRvs+Z7wP7QfXrnkaTBGrIpwQ== + dependencies: + libbase64 "1.3.0" + libmime "5.3.7" + libqp "2.1.1" + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + +acorn@^8.8.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +adm-zip@^0.5.10: + version "0.5.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909" + integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== + +agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + +ajv@^6.12.4, ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +argparse@~1.0.3: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array-hyper-unique@^2.1.4: + version "2.1.6" + resolved "https://registry.yarnpkg.com/array-hyper-unique/-/array-hyper-unique-2.1.6.tgz#429412fd63b7bd7c920f6cdbf60d1dd292855b2e" + integrity sha512-BdlHRqjKSYs88WFaVNVEc6Kv8ln/FdzCKPbcDPuWs4/EXkQFhnjc8TyR7hnPxRjcjo5LKOhUMGUWpAqRgeJvpA== + dependencies: + deep-eql "= 4.0.0" + lodash "^4.17.21" + +ast-types@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" + integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w== + dependencies: + tslib "^2.0.1" + +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +b4a@^1.6.4: + version "1.7.3" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.7.3.tgz#24cf7ccda28f5465b66aec2bac69e32809bf112f" + integrity sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.8.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" + integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== + +bare-fs@^4.0.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.5.1.tgz#498a20a332d4a7f0b310eb89b8d2319041aa1eef" + integrity sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-os@^3.0.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.6.2.tgz#b3c4f5ad5e322c0fd0f3c29fc97d19009e2796e5" + integrity sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.0.tgz#b59d18130ba52a6af9276db3e96a2e3d3ea52178" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.7.0" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.7.0.tgz#5b9e7dd0a354d06e82d6460c426728536c35d789" + integrity sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A== + dependencies: + streamx "^2.21.0" + +bare-url@^2.2.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.3.2.tgz#4aef382efa662b2180a6fe4ca07a71b39bdf7ca3" + integrity sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw== + dependencies: + bare-path "^3.0.0" + +base-64@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" + integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== + +base64-js@^1.3.1, base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +basic-ftp@^5.0.2: + version "5.0.5" + resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0" + integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg== + +binary-extensions@^2.0.0, binary-extensions@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +binary-search@^1.3.5: + version "1.3.6" + resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" + integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== + +bl@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== + dependencies: + readable-stream "^2.3.5" + safe-buffer "^5.1.1" + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +bluebird@~3.4.0: + version "3.4.7" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" + integrity sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA== + +bmp-js@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" + integrity sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw== + +body-parser@^1.20.3, body-parser@~1.20.3: + version "1.20.4" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" + integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== + dependencies: + bytes "~3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "~1.2.0" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + on-finished "~2.4.1" + qs "~6.14.0" + raw-body "~2.5.3" + type-is "~1.6.18" + unpipe "~1.0.0" + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== + +buffer@^5.2.1, buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@6: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== + +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chromium-bidi@0.4.33: + version "0.4.33" + resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-0.4.33.tgz#9a9aba5a5b07118c8e7d6405f8ee79f47418dd1d" + integrity sha512-IxoFM5WGQOIAd95qrSXzJUv4eXIrh+RvU3rwwqIiwYuvfE7U/Llj4fejbsJnjJMUYCuGtVQsY2gv7oGl4aTNSQ== + dependencies: + mitt "3.0.1" + urlpattern-polyfill "9.0.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-convert@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.3.tgz#db6627b97181cb8facdfce755ae26f97ab0711f1" + integrity sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg== + dependencies: + color-name "^2.0.0" + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color-string@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== + dependencies: + color-name "^2.0.0" + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +color@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.3.tgz#f79390b1b778e222ffbb54304d3dbeaef633f97f" + integrity sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA== + dependencies: + color-convert "^3.1.3" + color-string "^2.1.3" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^2.8.1: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +content-disposition@~0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" + integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + +cookie@~0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cosmiconfig@8.3.6: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + +crlf-normalize@^1.0.19: + version "1.0.20" + resolved "https://registry.yarnpkg.com/crlf-normalize/-/crlf-normalize-1.0.20.tgz#0b3105d3de807bce8a7599113235d725fe9361a8" + integrity sha512-h/rBerTd3YHQGfv7tNT25mfhWvRq2BBLCZZ80GFarFxf6HQGbpW6iqDL3N+HBLpjLfAdcBXfWAzVlLfHkRUQBQ== + dependencies: + ts-type ">=2" + +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-fetch@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + +cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== + +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + +data-uri-to-buffer@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b" + integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decamelize@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" + integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== + dependencies: + file-type "^5.2.0" + is-stream "^1.1.0" + tar-stream "^1.5.2" + +decompress-tarbz2@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" + integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== + dependencies: + decompress-tar "^4.1.0" + file-type "^6.1.0" + is-stream "^1.1.0" + seek-bzip "^1.0.5" + unbzip2-stream "^1.0.9" + +decompress-targz@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" + integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== + dependencies: + decompress-tar "^4.1.1" + file-type "^5.2.0" + is-stream "^1.1.0" + +decompress-unzip@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" + integrity sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw== + dependencies: + file-type "^3.8.0" + get-stream "^2.2.0" + pify "^2.3.0" + yauzl "^2.4.2" + +decompress@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" + integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== + dependencies: + decompress-tar "^4.0.0" + decompress-tarbz2 "^4.0.0" + decompress-targz "^4.0.0" + decompress-unzip "^4.0.1" + graceful-fs "^4.1.10" + make-dir "^1.0.0" + pify "^2.3.0" + strip-dirs "^2.0.0" + +"deep-eql@= 4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.0.0.tgz#c70af2713a4e18d9c2c1203ff9d11abbd51c8fbd" + integrity sha512-GxJC5MOg2KyQlv6WiUF/VAnMj4MWnYiXo4oLgeptOELVoknyErb4Z8+5F/IM/K4g9/80YzzatxmWcyRwUseH0A== + dependencies: + type-detect "^4.0.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +default-shell@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/default-shell/-/default-shell-2.2.0.tgz#31481c19747bfe59319b486591643eaf115a1864" + integrity sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw== + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +degenerator@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5" + integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ== + dependencies: + ast-types "^0.13.4" + escodegen "^2.1.0" + esprima "^4.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +depd@2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0, destroy@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.0, detect-libc@^2.0.2, detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +devtools-protocol@0.0.1203626: + version "0.0.1203626" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1203626.tgz#4366a4c81a7e0d4fd6924e9182c67f1e5941e820" + integrity sha512-nEzHZteIUZfGCZtTiS1fRpC8UZmsfD1SiyPvaUNvS13dvKf666OAm8YTi0+Ca3n1nLEyu49Cy4+dPWpaHFJk9g== + +digest-fetch@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/digest-fetch/-/digest-fetch-1.3.0.tgz#898e69264d00012a23cf26e8a3e40320143fc661" + integrity sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA== + dependencies: + base-64 "^0.1.0" + md5 "^2.3.0" + +dingbat-to-unicode@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz#5091dd673241453e6b5865e26e5a4452cdef5c83" + integrity sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w== + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dotenv@^16.0.3: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + +duck@^0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/duck/-/duck-0.1.12.tgz#de7adf758421230b6d7aee799ce42670586b9efa" + integrity sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg== + dependencies: + underscore "^1.13.1" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +encoding-japanese@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz#0ef2d2351250547f432a2dd155453555c16deb59" + integrity sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A== + +end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== + dependencies: + once "^1.4.0" + +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +"epub2@git+https://github.com/Mintplex-Labs/epub2-static.git#main": + version "3.0.2" + resolved "git+https://github.com/Mintplex-Labs/epub2-static.git#eb0a45cd41ac1a5b4c97766e9935d97583104d35" + dependencies: + adm-zip "^0.5.10" + array-hyper-unique "^2.1.4" + bluebird "^3.7.2" + crlf-normalize "^1.0.19" + tslib "^2.6.2" + xml2js "^0.6.2" + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escodegen@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + +eslint-config-prettier@^9.0.0: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz#90deb4fa0259592df774b600dbd1d2249a78ce91" + integrity sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ== + +eslint-plugin-prettier@^5.0.0: + version "5.5.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz#9eae11593faa108859c26f9a9c367d619a0769c0" + integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw== + dependencies: + prettier-linter-helpers "^1.0.1" + synckit "^0.11.12" + +eslint-plugin-unused-imports@^4.0.0: + version "4.4.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz#a831f0a2937d7631eba30cb87091ab7d3a5da0e1" + integrity sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.0.0: + version "9.39.3" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.3.tgz#08d63df1533d7743c0907b32a79a7e134e63ee2f" + integrity sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.39.3" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.5.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +expr-eval@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval/-/expr-eval-2.0.2.tgz#fa6f044a7b0c93fde830954eb9c5b0f7fbc7e201" + integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg== + +express@^4.21.2: + version "4.22.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" + integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "~1.20.3" + content-disposition "~0.5.4" + content-type "~1.0.4" + cookie "~0.7.1" + cookie-signature "~1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "~1.3.1" + fresh "~0.5.2" + http-errors "~2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "~2.4.1" + parseurl "~1.3.3" + path-to-regexp "~0.1.12" + proxy-addr "~2.0.7" + qs "~6.14.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "~0.19.0" + serve-static "~1.16.2" + setprototypeof "1.2.0" + statuses "~2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== + dependencies: + pend "~1.2.0" + +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +file-type@^16.5.4: + version "16.5.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" + integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== + dependencies: + readable-web-to-node-stream "^3.0.0" + strtok3 "^6.2.4" + token-types "^4.1.1" + +file-type@^3.8.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" + integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== + +file-type@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" + integrity sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ== + +file-type@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" + integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@~1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" + integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== + dependencies: + debug "2.6.9" + encodeurl "~2.0.0" + escape-html "~1.0.3" + on-finished "~2.4.1" + parseurl "~1.3.3" + statuses "~2.0.2" + unpipe "~1.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +fix-path@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fix-path/-/fix-path-4.0.0.tgz#bc1d14f038edb734ac46944a45454106952ca429" + integrity sha512-g31GX207Tt+psI53ZSaB1egprYbEN0ZYl90aKcO22A2LmCNnFsSq3b5YpoKp3E/QEiWByTXGJOkFQG4S07Bc1A== + dependencies: + shell-path "^3.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatbuffers@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-1.12.0.tgz#72e87d1726cb1b216e839ef02658aa87dcef68aa" + integrity sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ== + +flatted@^3.2.9: + version "3.3.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.4.tgz#0986e681008f0f13f58e18656c47967682db5ff6" + integrity sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA== + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2, fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stream@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" + integrity sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA== + dependencies: + object-assign "^4.0.1" + pinkie-promise "^2.0.0" + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-uri@^6.0.1: + version "6.0.5" + resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.5.tgz#714892aa4a871db671abc5395e5e9447bc306a16" + integrity sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg== + dependencies: + basic-ftp "^5.0.2" + data-uri-to-buffer "^6.0.2" + debug "^4.3.4" + +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.3.7: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^17.4.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5" + integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw== + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.10: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +guid-typescript@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/guid-typescript/-/guid-typescript-1.0.9.tgz#e35f77003535b0297ea08548f5ace6adb1480ddc" + integrity sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +html-to-text@9.0.5, html-to-text@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.5.tgz#6149a0f618ae7a0db8085dca9bbf96d32bb8368d" + integrity sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg== + dependencies: + "@selderee/plugin-htmlparser2" "^0.11.0" + deepmerge "^4.3.1" + dom-serializer "^2.0.0" + htmlparser2 "^8.0.2" + selderee "^0.11.0" + +htmlparser2@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-errors@~2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^7.0.2, https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +humanize-duration@^3.25.1: + version "3.33.1" + resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.33.1.tgz#e4df2ce6660f24a6a3bf4a7b3bc63edb5be7826f" + integrity sha512-hwzSCymnRdFx9YdRkQQ0OYequXiVAV6ZGQA2uzocwB0F4309Ke6pO8dg0P8LHhRQJyVjGteRTAA/zNfEcpXn8A== + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +iconv-lite@0.6.3, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e" + integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +idb-keyval@^6.2.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.2.tgz#b0171b5f73944854a3291a5cdba8e12768c4854a" + integrity sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg== + +ieee754@^1.1.13, ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +ignore@^5.2.0, ignore@^5.3.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +ip-address@^10.0.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" + integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-any-array@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-any-array/-/is-any-array-2.0.1.tgz#9233242a9c098220290aa2ec28f82ca7fa79899e" + integrity sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-arrayish@^0.3.1: + version "0.3.4" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d" + integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-natural-number@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" + integrity sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-typed-array@^1.1.14: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-url@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" + integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jintr@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/jintr/-/jintr-1.2.0.tgz#f000275ee86f32a7d3d9eb9b71ffdb3c93600f50" + integrity sha512-OYPVJPlDih+G7LXjnLR5Brsz8ClxkXYn0ZmWyJVIUmFI6lySQu/dzpGRK/ujBLAYCvuJu/VqXzf84C1edowbLg== + dependencies: + acorn "^8.8.0" + +js-tiktoken@^1.0.12, js-tiktoken@^1.0.7, js-tiktoken@^1.0.8: + version "1.0.21" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0, js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +jsonpointer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" + integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== + +jszip@^3.7.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +langchain@0.1.36: + version "0.1.36" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.1.36.tgz#2f201f681d83fb265716e28e7dfcfe61fbeef2c2" + integrity sha512-NTbnCL/jKWIeEI//Nm1oG8nhW3vkYWvEMr1MPotmTThTfeKfO87eV/OAzAyh6Ruy6GFs/qofRgQZGIe6XvXTNQ== + dependencies: + "@anthropic-ai/sdk" "^0.9.1" + "@langchain/community" "~0.0.47" + "@langchain/core" "~0.1.60" + "@langchain/openai" "~0.0.28" + "@langchain/textsplitters" "~0.0.0" + binary-extensions "^2.2.0" + js-tiktoken "^1.0.7" + js-yaml "^4.1.0" + jsonpointer "^5.0.1" + langchainhub "~0.0.8" + langsmith "~0.1.7" + ml-distance "^4.0.0" + openapi-types "^12.1.3" + p-retry "4" + uuid "^9.0.0" + yaml "^2.2.1" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +langchain@~0.2.3: + version "0.2.20" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.2.20.tgz#05bf348d684ece96cba73e0b8afe6b9778164857" + integrity sha512-tbels6Rr524iMM3VOQ4aTGnEOOjAA1BQuBR8u/8gJ2yT48lMtIQRAN32Y4KVjKK+hEWxHHlmLBrtgLpTphFjNA== + dependencies: + "@langchain/core" ">=0.2.21 <0.3.0" + "@langchain/openai" ">=0.1.0 <0.3.0" + "@langchain/textsplitters" "~0.0.0" + binary-extensions "^2.2.0" + js-tiktoken "^1.0.12" + js-yaml "^4.1.0" + jsonpointer "^5.0.1" + langsmith "^0.1.56-rc.1" + openapi-types "^12.1.3" + p-retry "4" + uuid "^10.0.0" + yaml "^2.2.1" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + +langchainhub@~0.0.8: + version "0.0.11" + resolved "https://registry.yarnpkg.com/langchainhub/-/langchainhub-0.0.11.tgz#2ce22def9c84699dcbd4fd4b78270d34bd2a9ae9" + integrity sha512-WnKI4g9kU2bHQP136orXr2bcRdgz9iiTBpTN0jWt9IlScUKnJBoD0aa2HOzHURQKeQDnt2JwqVmQ6Depf5uDLQ== + +langsmith@^0.1.56-rc.1, langsmith@~0.1.1, langsmith@~0.1.30, langsmith@~0.1.7: + version "0.1.68" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.1.68.tgz#848332e822fe5e6734a07f1c36b6530cc1798afb" + integrity sha512-otmiysWtVAqzMx3CJ4PrtUBhWRG5Co8Z4o7hSZENPjlit9/j3/vm3TSvbaxpDYakZxtMjhkcJTqrdYFipISEiQ== + dependencies: + "@types/uuid" "^10.0.0" + commander "^10.0.1" + p-queue "^6.6.2" + p-retry "4" + semver "^7.6.3" + uuid "^10.0.0" + +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +libbase64@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-1.3.0.tgz#053314755a05d2e5f08bbfc48d0290e9322f4406" + integrity sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg== + +libmime@5.3.7: + version "5.3.7" + resolved "https://registry.yarnpkg.com/libmime/-/libmime-5.3.7.tgz#3835b6443d982d5cd1ac32ee241adbbc11b34406" + integrity sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw== + dependencies: + encoding-japanese "2.2.0" + iconv-lite "0.6.3" + libbase64 "1.3.0" + libqp "2.1.1" + +libqp@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.1.1.tgz#f1be767a58f966f500597997cab72cfc1e17abfa" + integrity sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow== + +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +linkify-it@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421" + integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== + dependencies: + uc.micro "^2.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +logform@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.7.0.tgz#cfca97528ef290f2e125a08396805002b2d060d1" + integrity sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +lop@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/lop/-/lop-0.4.2.tgz#c9c2f958a39b9da1c2f36ca9ad66891a9fe84640" + integrity sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw== + dependencies: + duck "^0.1.12" + option "~0.2.1" + underscore "^1.13.1" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + +mailparser@^3.0.1: + version "3.9.0" + resolved "https://registry.yarnpkg.com/mailparser/-/mailparser-3.9.0.tgz#3b71732a26aa7c3390cd39540713eed7daf0aa2c" + integrity sha512-jpaNLhDjwy0w2f8sySOSRiWREjPqssSc0C2czV98btCXCRX3EyNloQ2IWirmMDj1Ies8Fkm0l96bZBZpDG7qkg== + dependencies: + "@zone-eu/mailsplit" "5.4.7" + encoding-japanese "2.2.0" + he "1.2.0" + html-to-text "9.0.5" + iconv-lite "0.7.0" + libmime "5.3.7" + linkify-it "5.0.0" + nodemailer "7.0.10" + punycode.js "2.3.1" + tlds "1.261.0" + +make-dir@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" + integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== + dependencies: + pify "^3.0.0" + +mammoth@^1.6.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/mammoth/-/mammoth-1.11.0.tgz#f6c68624eaffcf56728a792fcccd3495d688bac5" + integrity sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ== + dependencies: + "@xmldom/xmldom" "^0.8.6" + argparse "~1.0.3" + base64-js "^1.5.1" + bluebird "~3.4.0" + dingbat-to-unicode "^1.0.1" + jszip "^3.7.1" + lop "^0.4.2" + path-is-absolute "^1.0.0" + underscore "^1.13.1" + xmlbuilder "^10.0.0" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +mbox-parser@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mbox-parser/-/mbox-parser-1.0.1.tgz#cba6bb372a1d3c27aff9da356b4519aeee458e1f" + integrity sha512-9PNE026G/SodrYYxZiDZDJ5YQ0piN8QCqO662MupIGwgsvBtJHFPxYc/uUsQyhUysl4kMG0NLYvfELNkM5+HzQ== + dependencies: + "@types/mailparser" "^3.0.0" + humanize-duration "^3.25.1" + mailparser "^3.0.1" + +md5@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.1.3: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.3: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mitt@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +ml-array-mean@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/ml-array-mean/-/ml-array-mean-1.1.6.tgz#d951a700dc8e3a17b3e0a583c2c64abd0c619c56" + integrity sha512-MIdf7Zc8HznwIisyiJGRH9tRigg3Yf4FldW8DxKxpCCv/g5CafTw0RRu51nojVEOXuCQC7DRVVu5c7XXO/5joQ== + dependencies: + ml-array-sum "^1.1.6" + +ml-array-sum@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/ml-array-sum/-/ml-array-sum-1.1.6.tgz#d1d89c20793cd29c37b09d40e85681aa4515a955" + integrity sha512-29mAh2GwH7ZmiRnup4UyibQZB9+ZLyMShvt4cH4eTK+cL2oEMIZFnSyB3SS8MlsTh6q/w/yh48KmqLxmovN4Dw== + dependencies: + is-any-array "^2.0.0" + +ml-distance-euclidean@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ml-distance-euclidean/-/ml-distance-euclidean-2.0.0.tgz#3a668d236649d1b8fec96380b9435c6f42c9a817" + integrity sha512-yC9/2o8QF0A3m/0IXqCTXCzz2pNEzvmcE/9HFKOZGnTjatvBbsn4lWYJkxENkA4Ug2fnYl7PXQxnPi21sgMy/Q== + +ml-distance@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/ml-distance/-/ml-distance-4.0.1.tgz#4741d17a1735888c5388823762271dfe604bd019" + integrity sha512-feZ5ziXs01zhyFUUUeZV5hwc0f5JW0Sh0ckU1koZe/wdVkJdGxcP06KNQuF0WBTj8FttQUzcvQcpcrOp/XrlEw== + dependencies: + ml-array-mean "^1.1.6" + ml-distance-euclidean "^2.0.0" + ml-tree-similarity "^1.0.0" + +ml-tree-similarity@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ml-tree-similarity/-/ml-tree-similarity-1.0.0.tgz#24705a107e32829e24d945e87219e892159c53f0" + integrity sha512-XJUyYqjSuUQkNQHMscr6tcjldsOoAekxADTplt40QKfwW6nd++1wHWV9AArl0Zvw/TIHgNaZZNvr8QGvE8wLRg== + dependencies: + binary-search "^1.3.5" + num-sort "^2.0.0" + +moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +netmask@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" + integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== + +node-abi@^3.3.0: + version "3.85.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" + integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== + dependencies: + semver "^7.3.5" + +node-addon-api@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" + integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== + +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-ensure@^0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" + integrity sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw== + +node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-html-parser@^6.1.13: + version "6.1.13" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.13.tgz#a1df799b83df5c6743fcd92740ba14682083b7e4" + integrity sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg== + dependencies: + css-select "^5.1.0" + he "1.2.0" + +node-xlsx@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/node-xlsx/-/node-xlsx-0.24.0.tgz#a6a365acb18ad37c66c2b254b6ebe0c22dc9dc6f" + integrity sha512-1olwK48XK9nXZsyH/FCltvGrQYvXXZuxVitxXXv2GIuRm51aBi1+5KwR4rWM4KeO61sFU+00913WLZTD+AcXEg== + dependencies: + xlsx "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" + +nodemailer@7.0.10: + version "7.0.10" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-7.0.10.tgz#540062dbbe574220b42e79d2d949956d3eac5a46" + integrity sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w== + +nodemon@^2.0.22: + version "2.0.22" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.22.tgz#182c45c3a78da486f673d6c1702e00728daf5258" + integrity sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ== + dependencies: + chokidar "^3.5.2" + debug "^3.2.7" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^5.7.1" + simple-update-notifier "^1.0.7" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +num-sort@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/num-sort/-/num-sort-2.1.0.tgz#1cbb37aed071329fdf41151258bc011898577a9b" + integrity sha512-1MQz1Ed8z2yckoBeSfkQHHO9K1yDRxxtotKSJ9yvcTUUxSvfvzEq5GwBrjjHEpMlq/k5gvXdmJ1SbYxWtpNoVg== + +object-assign@^4, object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +officeparser@^4.0.5: + version "4.2.0" + resolved "https://registry.yarnpkg.com/officeparser/-/officeparser-4.2.0.tgz#6d0baf411bd21e7e31a4b1f206af7034a4506ca2" + integrity sha512-LXSfaET8ZOBNjmSev4K1N6AiKTaY7m9NkddeCaMUdEe5D/HUuv2byB8VoPIaiLldtKun0I92tbhO+VGDUr/aXQ== + dependencies: + "@xmldom/xmldom" "^0.8.10" + decompress "^4.2.1" + file-type "^16.5.4" + node-ensure "^0.0.0" + rimraf "^5.0.10" + +on-finished@2.4.1, on-finished@~2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +onnx-proto@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/onnx-proto/-/onnx-proto-4.0.4.tgz#2431a25bee25148e915906dda0687aafe3b9e044" + integrity sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA== + dependencies: + protobufjs "^6.8.8" + +onnxruntime-common@~1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz#2bb5dac5261269779aa5fb6536ca379657de8bf6" + integrity sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew== + +onnxruntime-node@1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz#c4ae6c355cfae7d83abaf36dd39a905c4a010217" + integrity sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w== + dependencies: + onnxruntime-common "~1.14.0" + +onnxruntime-web@1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz#c8cee538781b1d4c1c6b043934f4a3e6ddf1466e" + integrity sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw== + dependencies: + flatbuffers "^1.12.0" + guid-typescript "^1.0.9" + long "^4.0.0" + onnx-proto "^4.0.4" + onnxruntime-common "~1.14.0" + platform "^1.3.6" + +openai@4.95.1: + version "4.95.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.95.1.tgz#7157697c2b150a546b13eb860180c4a6058051da" + integrity sha512-IqJy+ymeW+k/Wq+2YVN3693OQMMcODRtHEYOlz263MdUwnN/Dwdl9c2EXSxLLtGEHkSHAfvzpDMHI5MaWJKXjQ== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + +openai@^4.41.1, openai@^4.57.3: + version "4.104.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.104.0.tgz#c489765dc051b95019845dab64b0e5207cae4d30" + integrity sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + +opencollective-postinstall@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259" + integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q== + +option@~0.2.1: + version "0.2.4" + resolved "https://registry.yarnpkg.com/option/-/option-0.2.4.tgz#fd475cdf98dcabb3cb397a3ba5284feb45edbfe4" + integrity sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A== + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + +pac-proxy-agent@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz#9cfaf33ff25da36f6147a20844230ec92c06e5df" + integrity sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA== + dependencies: + "@tootallnate/quickjs-emscripten" "^0.23.0" + agent-base "^7.1.2" + debug "^4.3.4" + get-uri "^6.0.1" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.6" + pac-resolver "^7.0.1" + socks-proxy-agent "^8.0.5" + +pac-resolver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6" + integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg== + dependencies: + degenerator "^5.0.0" + netmask "^2.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parseley@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef" + integrity sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw== + dependencies: + leac "^0.6.0" + peberminta "^0.9.0" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@~0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pdf-parse@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/pdf-parse/-/pdf-parse-1.1.4.tgz#97bca6f46758130dafb1fdd9df905efd07581f4a" + integrity sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ== + dependencies: + node-ensure "^0.0.0" + +peberminta@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" + integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + +peek-readable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" + integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw== + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== + +platform@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" + integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +prebuild-install@^7.1.1: + version "7.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd" + integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg== + dependencies: + fast-diff "^1.1.2" + +prettier@^2.4.1: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +progress@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +protobufjs@^6.8.8: + version "6.11.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.4.tgz#29a412c38bf70d89e537b6d02d904a6f448173aa" + integrity sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/long" "^4.0.1" + "@types/node" ">=13.7.0" + long "^4.0.0" + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-agent@6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.3.1.tgz#40e7b230552cf44fd23ffaf7c59024b692612687" + integrity sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + lru-cache "^7.14.1" + pac-proxy-agent "^7.0.1" + proxy-from-env "^1.1.0" + socks-proxy-agent "^8.0.2" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +pump@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d" + integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode.js@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" + integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +puppeteer-core@21.5.2: + version "21.5.2" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-21.5.2.tgz#6d3de4efb2ae65f1ee072043787b75594e88035f" + integrity sha512-v4T0cWnujSKs+iEfmb8ccd7u4/x8oblEyKqplqKnJ582Kw8PewYAWvkH4qUWhitN3O2q9RF7dzkvjyK5HbzjLA== + dependencies: + "@puppeteer/browsers" "1.8.0" + chromium-bidi "0.4.33" + cross-fetch "4.0.0" + debug "4.3.4" + devtools-protocol "0.0.1203626" + ws "8.14.2" + +puppeteer@~21.5.2: + version "21.5.2" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-21.5.2.tgz#0a4a72175c0fd0944d6486f4734807e1671d527b" + integrity sha512-BaAGJOq8Fl6/cck6obmwaNLksuY0Bg/lIahCLhJPGXBFUD2mCffypa4A592MaWnDcye7eaHmSK9yot0pxctY8A== + dependencies: + "@puppeteer/browsers" "1.8.0" + cosmiconfig "8.3.6" + puppeteer-core "21.5.2" + +qs@~6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + dependencies: + side-channel "^1.1.0" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@~2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@~2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" + integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +readable-web-to-node-stream@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz#392ba37707af5bf62d725c36c1b5d6ef4119eefc" + integrity sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw== + dependencies: + readable-stream "^4.7.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regenerator-runtime@^0.13.3: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + +rimraf@^5.0.10: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== + dependencies: + glob "^10.3.7" + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sax@>=0.6.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.3.tgz#fcebae3b756cdc8428321805f4b70f16ec0ab5db" + integrity sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ== + +seek-bzip@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" + integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== + dependencies: + commander "^2.8.1" + +selderee@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a" + integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + dependencies: + parseley "^0.12.0" + +semver@^5.7.1: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^7.3.5, semver@^7.5.4, semver@^7.6.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@~7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +send@~0.19.0: + version "0.19.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.1.tgz#1c2563b2ee4fe510b806b21ec46f355005a369f9" + integrity sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@~1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.19.0" + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +setprototypeof@1.2.0, setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +sharp@^0.32.0: + version "0.32.6" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.32.6.tgz#6ad30c0b7cd910df65d5f355f774aa4fce45732a" + integrity sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w== + dependencies: + color "^4.2.3" + detect-libc "^2.0.2" + node-addon-api "^6.1.0" + prebuild-install "^7.1.1" + semver "^7.5.4" + simple-get "^4.0.1" + tar-fs "^3.0.4" + tunnel-agent "^0.6.0" + +sharp@^0.33.5: + version "0.33.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" + integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.5" + "@img/sharp-darwin-x64" "0.33.5" + "@img/sharp-libvips-darwin-arm64" "1.0.4" + "@img/sharp-libvips-darwin-x64" "1.0.4" + "@img/sharp-libvips-linux-arm" "1.0.5" + "@img/sharp-libvips-linux-arm64" "1.0.4" + "@img/sharp-libvips-linux-s390x" "1.0.4" + "@img/sharp-libvips-linux-x64" "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + "@img/sharp-linux-arm" "0.33.5" + "@img/sharp-linux-arm64" "0.33.5" + "@img/sharp-linux-s390x" "0.33.5" + "@img/sharp-linux-x64" "0.33.5" + "@img/sharp-linuxmusl-arm64" "0.33.5" + "@img/sharp-linuxmusl-x64" "0.33.5" + "@img/sharp-wasm32" "0.33.5" + "@img/sharp-win32-ia32" "0.33.5" + "@img/sharp-win32-x64" "0.33.5" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shell-env@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/shell-env/-/shell-env-4.0.1.tgz#883302d9426095d398a39b102a851adb306b8cb8" + integrity sha512-w3oeZ9qg/P6Lu6qqwavvMnB/bwfsz67gPB3WXmLd/n6zuh7TWQZtGa3iMEdmua0kj8rivkwl+vUjgLWlqZOMPw== + dependencies: + default-shell "^2.0.0" + execa "^5.1.1" + strip-ansi "^7.0.1" + +shell-path@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/shell-path/-/shell-path-3.1.0.tgz#950671fe15de70fb4d984b886d55e8a2f10bfe33" + integrity sha512-s/9q9PEtcRmDTz69+cJ3yYBAe9yGrL7e46gm2bU4pQ9N48ecPK9QrGFnLwYgb4smOHskx4PL7wCNMktW2AoD+g== + dependencies: + shell-env "^4.0.1" + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0, simple-get@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + +simple-swizzle@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667" + integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw== + dependencies: + is-arrayish "^0.3.1" + +simple-update-notifier@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz#67694c121de354af592b347cdba798463ed49c82" + integrity sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg== + dependencies: + semver "~7.0.0" + +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== + +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.2, socks-proxy-agent@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== + dependencies: + agent-base "^7.1.2" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.7" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea" + integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A== + dependencies: + ip-address "^10.0.1" + smart-buffer "^4.2.0" + +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +statuses@~2.0.1, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +streamx@^2.15.0, streamx@^2.21.0: + version "2.23.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.23.0.tgz#7d0f3d00d4a6c5de5728aecd6422b4008d66fd0b" + integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, string-width@^5.1.2: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1, strip-ansi@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-dirs@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" + integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== + dependencies: + is-natural-number "^4.0.1" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + +strtok3@^6.2.4: + version "6.3.0" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" + integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^4.1.0" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +synckit@^0.11.12: + version "0.11.12" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b" + integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ== + dependencies: + "@pkgr/core" "^0.2.9" + +tar-fs@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf" + integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w== + dependencies: + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^3.1.5" + +tar-fs@^2.0.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-fs@^3.0.4: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.1.tgz#4f164e59fb60f103d472360731e8c6bb4a7fe9ef" + integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg== + dependencies: + pump "^3.0.0" + tar-stream "^3.1.5" + optionalDependencies: + bare-fs "^4.0.1" + bare-path "^3.0.0" + +tar-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" + integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== + dependencies: + bl "^1.0.0" + buffer-alloc "^1.2.0" + end-of-stream "^1.0.0" + fs-constants "^1.0.0" + readable-stream "^2.3.0" + to-buffer "^1.1.1" + xtend "^4.0.0" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^3.1.5: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + +tesseract.js-core@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz#6f25da94f70f8e8f02aff47a43be61d49e6f67c3" + integrity sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA== + +tesseract.js@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tesseract.js/-/tesseract.js-6.0.1.tgz#5b2ff39aae92d59cef79589a43a0f3ab963801cc" + integrity sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA== + dependencies: + bmp-js "^0.1.0" + idb-keyval "^6.2.0" + is-url "^1.2.4" + node-fetch "^2.6.9" + opencollective-postinstall "^2.0.3" + regenerator-runtime "^0.13.3" + tesseract.js-core "^6.0.0" + wasm-feature-detect "^1.2.11" + zlibjs "^0.3.1" + +text-decoder@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" + integrity sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA== + dependencies: + b4a "^1.6.4" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +tlds@1.261.0: + version "1.261.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.261.0.tgz#055e412e92f01f84a9c8ac0504d3472d68b3c4c9" + integrity sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA== + +to-buffer@^1.1.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" + integrity sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1, toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +token-types@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" + integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + +ts-type@>=2: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ts-type/-/ts-type-3.0.1.tgz#b52e7623065e0beb43c77c426347d85cf81dff84" + integrity sha512-cleRydCkBGBFQ4KAvLH0ARIkciduS745prkGVVxPGvcRGhMMoSJUB7gNR1ByKhFTEYrYRg2CsMRGYnqp+6op+g== + dependencies: + "@types/node" "*" + tslib ">=2" + typedarray-dts "^1.0.0" + +tslib@>=2, tslib@^2.0.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + +turndown@7.2.4: + version "7.2.4" + resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.2.4.tgz#42d98202aefa8c188c997b586bc6da78bdf27ea2" + integrity sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ== + dependencies: + "@mixmark-io/domino" "^2.2.0" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typedarray-dts@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/typedarray-dts/-/typedarray-dts-1.0.0.tgz#9dec9811386dbfba964c295c2606cf9a6b982d06" + integrity sha512-Ka0DBegjuV9IPYFT1h0Qqk5U4pccebNIJCGl8C5uU7xtOs+jpJvKGAY4fHGK25hTmXZOEUl9Cnsg5cS6K/b5DA== + +uc.micro@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" + integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + +unbzip2-stream@1.4.3, unbzip2-stream@^1.0.9: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +underscore@^1.13.1: + version "1.13.7" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.7.tgz#970e33963af9a7dda228f17ebe8399e5fbe63a10" + integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + +undici@^5.19.1: + version "5.29.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" + integrity sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg== + dependencies: + "@fastify/busboy" "^2.0.0" + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url-pattern@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1" + integrity sha512-uQcEj/2puA4aq1R3A2+VNVBgaWYR24FdWjl7VNW83rnWftlhyzOZ/tBjezRiC2UkIzuxC8Top3IekN3vUf1WxA== + +urlpattern-polyfill@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460" + integrity sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +wasm-feature-detect@^1.2.11: + version "1.8.0" + resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz#4e9f55b0a64d801f372fbb0324ed11ad3abd0c78" + integrity sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ== + +wavefile@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/wavefile/-/wavefile-11.0.0.tgz#9302165874327ff63a704d00b154c753eaa1b8e7" + integrity sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng== + +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + +web-streams-polyfill@^3.2.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-typed-array@^1.1.16: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +winston-transport@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9" + integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== + dependencies: + logform "^2.7.0" + readable-stream "^3.6.2" + triple-beam "^1.3.0" + +winston@^3.13.0: + version "3.18.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.18.3.tgz#93ac10808c8e1081d723bc8811cd2f445ddfdcd1" + integrity sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.8" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.7.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.9.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0, wrap-ansi@^8.1.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@8.14.2: + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== + +"xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": + version "0.20.2" + resolved "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz#0f64eeed3f1a46e64724620c3553f2dbd3cd2d7d" + +xml2js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@^10.0.0: + version "10.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz#8cae6688cc9b38d850b7c8d3c0a4161dcaf475b0" + integrity sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yaml@^2.2.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79" + integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yauzl@^2.10.0, yauzl@^2.4.2: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +youtube-transcript-plus@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/youtube-transcript-plus/-/youtube-transcript-plus-1.1.2.tgz#f86851852a056088c11f4f6523ab0f8dba7d9711" + integrity sha512-bLlqkA6gVVUorZpcc+THuECXyAwOpnHqW2lOav9g6gGovxAP3FCD8s9GBFVjmSl3cWWwwPPXtG/zY1nD+GvQ7A== + +youtubei.js@^9.1.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.4.0.tgz#ccccaf4a295b96e3e17134a66730bbc82461594b" + integrity sha512-8plCOZD2WabqWSEgZU3RjzigIIeR7sF028EERJENYrC9xO/6awpLMZfeoE1gNrNEbKcA+bzbMvonqlvBdxGdKg== + dependencies: + jintr "^1.1.0" + tslib "^2.5.0" + undici "^5.19.1" + +zlibjs@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/zlibjs/-/zlibjs-0.3.1.tgz#50197edb28a1c42ca659cc8b4e6a9ddd6d444554" + integrity sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w== + +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.22.5: + version "3.25.0" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz#df504c957c4fb0feff467c74d03e6aab0b013e1c" + integrity sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ== + +zod@^3.22.3, zod@^3.22.4: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..a3845ce --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,526 @@ +SERVER_PORT=3001 +STORAGE_DIR="/app/server/storage" +UID='1000' +GID='1000' +# SIG_KEY='passphrase' # Please generate random string at least 32 chars long. +# SIG_SALT='salt' # Please generate random string at least 32 chars long. +# JWT_SECRET="my-random-string-for-seeding" # Only needed if AUTH_TOKEN is set. Please generate random string at least 12 chars long. +# JWT_EXPIRY="30d" # (optional) https://docs.anythingllm.com/configuration#custom-ttl-for-sessions + +########################################### +######## LLM API SElECTION ################ +########################################### +# LLM_PROVIDER='openai' +# OPEN_AI_KEY= +# OPEN_MODEL_PREF='gpt-4.1-nano' + +# LLM_PROVIDER='gemini' +# GEMINI_API_KEY= +# GEMINI_LLM_MODEL_PREF='gemini-2.0-flash-lite' + +# LLM_PROVIDER='azure' +# AZURE_OPENAI_ENDPOINT= +# AZURE_OPENAI_KEY= +# AZURE_OPENAI_MODEL_PREF='my-gpt35-deployment' # This is the "deployment" on Azure you want to use. Not the base model. +# EMBEDDING_MODEL_PREF='embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002 + +# LLM_PROVIDER='anthropic' +# ANTHROPIC_API_KEY=sk-ant-xxxx +# ANTHROPIC_MODEL_PREF='claude-sonnet-4-6' +# ANTHROPIC_CACHE_CONTROL="5m" # Enable prompt caching (5m=5min cache, 1h=1hour cache). Reduces costs and improves speed by caching system prompts. + +# LLM_PROVIDER='lmstudio' +# LMSTUDIO_BASE_PATH='http://your-server:1234/v1' +# LMSTUDIO_MODEL_PREF='Loaded from Chat UI' # this is a bug in LMStudio 0.2.17 +# LMSTUDIO_MODEL_TOKEN_LIMIT=4096 +# LMSTUDIO_AUTH_TOKEN='your-lmstudio-auth-token-here' + +# LLM_PROVIDER='localai' +# LOCAL_AI_BASE_PATH='http://host.docker.internal:8080/v1' +# LOCAL_AI_MODEL_PREF='luna-ai-llama2' +# LOCAL_AI_MODEL_TOKEN_LIMIT=4096 +# LOCAL_AI_API_KEY="sk-123abc" + +# LLM_PROVIDER='ollama' +# OLLAMA_BASE_PATH='http://host.docker.internal:11434' +# OLLAMA_MODEL_PREF='llama2' +# OLLAMA_MODEL_TOKEN_LIMIT=4096 +# OLLAMA_AUTH_TOKEN='your-ollama-auth-token-here (optional, only for ollama running behind auth - Bearer token)' +# OLLAMA_RESPONSE_TIMEOUT=7200000 (optional, max timeout in milliseconds for ollama response to conclude. Default is 5min before aborting) + +# LLM_PROVIDER='togetherai' +# TOGETHER_AI_API_KEY='my-together-ai-key' +# TOGETHER_AI_MODEL_PREF='mistralai/Mixtral-8x7B-Instruct-v0.1' + +# LLM_PROVIDER='mistral' +# MISTRAL_API_KEY='example-mistral-ai-api-key' +# MISTRAL_MODEL_PREF='mistral-tiny' + +# LLM_PROVIDER='perplexity' +# PERPLEXITY_API_KEY='my-perplexity-key' +# PERPLEXITY_MODEL_PREF='codellama-34b-instruct' + +# LLM_PROVIDER='openrouter' +# OPENROUTER_API_KEY='my-openrouter-key' +# OPENROUTER_MODEL_PREF='openrouter/auto' + +# LLM_PROVIDER='groq' +# GROQ_API_KEY=gsk_abcxyz +# GROQ_MODEL_PREF=llama3-8b-8192 + +# LLM_PROVIDER='koboldcpp' +# KOBOLD_CPP_BASE_PATH='http://127.0.0.1:5000/v1' +# KOBOLD_CPP_MODEL_PREF='koboldcpp/codellama-7b-instruct.Q4_K_S' +# KOBOLD_CPP_MODEL_TOKEN_LIMIT=4096 + +# LLM_PROVIDER='textgenwebui' +# TEXT_GEN_WEB_UI_BASE_PATH='http://127.0.0.1:5000/v1' +# TEXT_GEN_WEB_UI_TOKEN_LIMIT=4096 +# TEXT_GEN_WEB_UI_API_KEY='sk-123abc' + +# LLM_PROVIDER='generic-openai' +# GENERIC_OPEN_AI_BASE_PATH='http://proxy.url.openai.com/v1' +# GENERIC_OPEN_AI_MODEL_PREF='gpt-4.1-nano' +# GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=4096 +# GENERIC_OPEN_AI_API_KEY=sk-123abc +# GENERIC_OPEN_AI_CUSTOM_HEADERS="X-Custom-Auth:my-secret-key,X-Custom-Header:my-value" (useful if using a proxy that requires authentication or other headers) + +# LLM_PROVIDER='litellm' +# LITE_LLM_MODEL_PREF='gpt-4.1-nano' +# LITE_LLM_MODEL_TOKEN_LIMIT=4096 +# LITE_LLM_BASE_PATH='http://127.0.0.1:4000' +# LITE_LLM_API_KEY='sk-123abc' + +# LLM_PROVIDER='novita' +# NOVITA_LLM_API_KEY='your-novita-api-key-here' check on https://novita.ai/settings/key-management +# NOVITA_LLM_MODEL_PREF='deepseek/deepseek-r1' + +# LLM_PROVIDER='cometapi' +# COMETAPI_LLM_API_KEY='your-cometapi-api-key-here' # Get one at https://api.cometapi.com/console/token +# COMETAPI_LLM_MODEL_PREF='gpt-5-mini' +# COMETAPI_LLM_TIMEOUT_MS=500 # Optional; stream idle timeout in ms (min 500ms) + +# LLM_PROVIDER='cohere' +# COHERE_API_KEY= +# COHERE_MODEL_PREF='command-r' + +# LLM_PROVIDER='bedrock' +# AWS_BEDROCK_LLM_REGION=us-west-2 +# AWS_BEDROCK_API_KEY="xxxxxxx" +# AWS_BEDROCK_LLM_MODEL_PREFERENCE=global.anthropic.claude-haiku-4-5-20251001-v1:0 +# AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT='128000' +# AWS_BEDROCK_STREAMING_DISABLED=1 + +# LLM_PROVIDER='fireworksai' +# FIREWORKS_AI_LLM_API_KEY='my-fireworks-ai-key' +# FIREWORKS_AI_LLM_MODEL_PREF='accounts/fireworks/models/llama-v3p1-8b-instruct' + +# LLM_PROVIDER='apipie' +# APIPIE_LLM_API_KEY='sk-123abc' +# APIPIE_LLM_MODEL_PREF='openrouter/llama-3.1-8b-instruct' + +# LLM_PROVIDER='xai' +# XAI_LLM_API_KEY='xai-your-api-key-here' +# XAI_LLM_MODEL_PREF='grok-beta' + +# LLM_PROVIDER='zai' +# ZAI_API_KEY="your-zai-api-key-here" +# ZAI_MODEL_PREF="glm-4.5" + +# LLM_PROVIDER='nvidia-nim' +# NVIDIA_NIM_LLM_BASE_PATH='http://127.0.0.1:8000' +# NVIDIA_NIM_LLM_MODEL_PREF='meta/llama-3.2-3b-instruct' + +# LLM_PROVIDER='deepseek' +# DEEPSEEK_API_KEY='your-deepseek-api-key-here' +# DEEPSEEK_MODEL_PREF='deepseek-chat' + +# LLM_PROVIDER='ppio' +# PPIO_API_KEY='your-ppio-api-key-here' +# PPIO_MODEL_PREF=deepseek/deepseek-v3/community + +# LLM_PROVIDER='moonshotai' +# MOONSHOT_AI_API_KEY='your-moonshot-api-key-here' +# MOONSHOT_AI_MODEL_PREF='moonshot-v1-32k' + +# LLM_PROVIDER='foundry' +# FOUNDRY_BASE_PATH='http://127.0.0.1:55776' +# FOUNDRY_MODEL_PREF='phi-3.5-mini' +# FOUNDRY_MODEL_TOKEN_LIMIT=4096 + +# LLM_PROVIDER='giteeai' +# GITEE_AI_API_KEY= +# GITEE_AI_MODEL_PREF= +# GITEE_AI_MODEL_TOKEN_LIMIT= + +# LLM_PROVIDER='docker-model-runner' +# DOCKER_MODEL_RUNNER_BASE_PATH='http://127.0.0.1:12434' +# DOCKER_MODEL_RUNNER_LLM_MODEL_PREF='phi-3.5-mini' +# DOCKER_MODEL_RUNNER_LLM_MODEL_TOKEN_LIMIT=4096 + +# LLM_PROVIDER='privatemode' +# PRIVATEMODE_LLM_BASE_PATH='http://127.0.0.1:8080' +# PRIVATEMODE_LLM_MODEL_PREF='gemma-3-27b' + +# LLM_PROVIDER='sambanova' +# SAMBANOVA_LLM_API_KEY='xxx-xxx-xxx' +# SAMBANOVA_LLM_MODEL_PREF='gpt-oss-120b' + +# LLM_PROVIDER='minimax' +# MINIMAX_API_KEY='sk-cp-...' +# MINIMAX_MODEL_PREF='MiniMax-M2.7' + +# LLM_PROVIDER='cerebras' +# CEREBRAS_API_KEY='your-cerebras-api-key-here' +# CEREBRAS_MODEL_PREF='gpt-oss-120b' + +########################################### +######## Embedding API SElECTION ########## +########################################### +# This will be the assumed default embedding seleciton and model +# EMBEDDING_ENGINE='native' +# EMBEDDING_MODEL_PREF='Xenova/all-MiniLM-L6-v2' + +# Only used if you are using an LLM that does not natively support embedding (openai or Azure) +# EMBEDDING_ENGINE='openai' +# OPEN_AI_KEY=sk-xxxx +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' + +# EMBEDDING_ENGINE='azure' +# AZURE_OPENAI_ENDPOINT= +# AZURE_OPENAI_KEY= +# EMBEDDING_MODEL_PREF='my-embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002 + +# EMBEDDING_ENGINE='localai' +# EMBEDDING_BASE_PATH='http://localhost:8080/v1' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=1000 # The max chunk size in chars a string to embed can be + +# EMBEDDING_ENGINE='ollama' +# EMBEDDING_BASE_PATH='http://host.docker.internal:11434' +# EMBEDDING_MODEL_PREF='nomic-embed-text:latest' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +# EMBEDDING_ENGINE='lmstudio' +# EMBEDDING_BASE_PATH='https://host.docker.internal:1234/v1' +# EMBEDDING_MODEL_PREF='nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.Q4_0.gguf' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +# EMBEDDING_ENGINE='cohere' +# COHERE_API_KEY= +# EMBEDDING_MODEL_PREF='embed-english-v3.0' + +# EMBEDDING_ENGINE='voyageai' +# VOYAGEAI_API_KEY= +# EMBEDDING_MODEL_PREF='voyage-large-2-instruct' + +# EMBEDDING_ENGINE='litellm' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 +# LITE_LLM_BASE_PATH='http://127.0.0.1:4000' +# LITE_LLM_API_KEY='sk-123abc' + +# EMBEDDING_ENGINE='generic-openai' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 +# EMBEDDING_BASE_PATH='http://127.0.0.1:4000' +# GENERIC_OPEN_AI_EMBEDDING_API_KEY='sk-123abc' +# GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=500 +# GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=1000 +# GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX= +# GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX= + +# EMBEDDING_ENGINE='gemini' +# GEMINI_EMBEDDING_API_KEY= +# EMBEDDING_MODEL_PREF='text-embedding-004' + +# EMBEDDING_ENGINE='openrouter' +# EMBEDDING_MODEL_PREF='baai/bge-m3' +# OPENROUTER_API_KEY='' + +# EMBEDDING_ENGINE='lemonade' +# EMBEDDING_BASE_PATH='http://127.0.0.1:8000' +# EMBEDDING_MODEL_PREF='Qwen3-embedder' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +########################################### +######## Vector Database Selection ######## +########################################### +# Enable all below if you are using vector database: LanceDB. +# VECTOR_DB="lancedb" + +# Enable all below if you are using vector database: Weaviate. +# VECTOR_DB="pgvector" +# PGVECTOR_CONNECTION_STRING="postgresql://dbuser:dbuserpass@localhost:5432/yourdb" +# PGVECTOR_TABLE_NAME="anythingllm_vectors" # optional, but can be defined + +# Enable all below if you are using vector database: Chroma. +# VECTOR_DB="chroma" +# CHROMA_ENDPOINT='http://host.docker.internal:8000' +# CHROMA_API_HEADER="X-Api-Key" +# CHROMA_API_KEY="sk-123abc" + +# Enable all below if you are using vector database: Chroma Cloud. +# VECTOR_DB="chromacloud" +# CHROMACLOUD_API_KEY="ck-your-api-key" +# CHROMACLOUD_TENANT= +# CHROMACLOUD_DATABASE= + +# Enable all below if you are using vector database: Pinecone. +# VECTOR_DB="pinecone" +# PINECONE_API_KEY= +# PINECONE_INDEX= + +# Enable all below if you are using vector database: Weaviate. +# VECTOR_DB="weaviate" +# WEAVIATE_ENDPOINT="http://localhost:8080" +# WEAVIATE_API_KEY= + +# Enable all below if you are using vector database: Qdrant. +# VECTOR_DB="qdrant" +# QDRANT_ENDPOINT="http://localhost:6333" +# QDRANT_API_KEY= + +# Enable all below if you are using vector database: Milvus. +# VECTOR_DB="milvus" +# MILVUS_ADDRESS="http://localhost:19530" +# MILVUS_USERNAME= +# MILVUS_PASSWORD= + +# Enable all below if you are using vector database: Zilliz Cloud. +# VECTOR_DB="zilliz" +# ZILLIZ_ENDPOINT="https://sample.api.gcp-us-west1.zillizcloud.com" +# ZILLIZ_API_TOKEN=api-token-here + +# Enable all below if you are using vector database: Astra DB. +# VECTOR_DB="astra" +# ASTRA_DB_APPLICATION_TOKEN= +# ASTRA_DB_ENDPOINT= + +########################################### +######## Audio Model Selection ############ +########################################### +# (default) use built-in whisper-small model. +# WHISPER_PROVIDER="local" + +# use openai hosted whisper model. +# WHISPER_PROVIDER="openai" +# OPEN_AI_KEY=sk-xxxxxxxx + +# use any OpenAI compatible transcription endpoint. +# WHISPER_PROVIDER="generic-openai" +# WHISPER_GENERIC_OPEN_AI_BASE_URL="http://localhost:8000/v1" +# WHISPER_GENERIC_OPEN_AI_API_KEY=sk-xxxxxxxx +# WHISPER_GENERIC_OPEN_AI_MODEL="whisper-small" + +########################################### +######## TTS/STT Model Selection ########## +########################################### +# TTS_PROVIDER="native" + +# TTS_PROVIDER="openai" +# TTS_OPEN_AI_KEY=sk-example +# TTS_OPEN_AI_VOICE_MODEL=nova + +# TTS_PROVIDER="generic-openai" +# TTS_OPEN_AI_COMPATIBLE_KEY=sk-example +# TTS_OPEN_AI_COMPATIBLE_MODEL=tts-1 +# TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL=nova +# TTS_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1" + +# TTS_PROVIDER="elevenlabs" +# TTS_ELEVEN_LABS_KEY= +# TTS_ELEVEN_LABS_VOICE_MODEL=21m00Tcm4TlvDq8ikWAM # Rachel + +# Self-hosted Kokoro via kokoro-fastapi (OpenAI-compatible). +# TTS_PROVIDER="kokoro" +# TTS_KOKORO_ENDPOINT="http://host.docker.internal:8880/v1" +# TTS_KOKORO_KEY= # optional, only if you front your server with auth +# TTS_KOKORO_VOICE_MODEL=af_bella + +# STT_PROVIDER="native" + +# Server-side STT via OpenAI. Reuses OPEN_AI_KEY set above. +# STT_PROVIDER="openai" +# STT_OPEN_AI_MODEL=whisper-1 + +# Server-side STT via Lemonade. Reuses LEMONADE_LLM_API_KEY set above. +# STT_PROVIDER="lemonade" +# STT_LEMONADE_BASE_PATH='http://127.0.0.1:8000' +# STT_LEMONADE_MODEL_PREF=whisper-1 + +# STT_PROVIDER="deepgram" +# STT_DEEPGRAM_API_KEY= +# STT_DEEPGRAM_MODEL=nova-3 + +# STT_PROVIDER="generic-openai" +# STT_OPEN_AI_COMPATIBLE_KEY=sk-example +# STT_OPEN_AI_COMPATIBLE_MODEL=whisper-1 +# STT_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1" + +# STT_PROVIDER="groq" +# STT_GROQ_API_KEY=xxxxx +# STT_GROQ_MODEL=whisper-large-v3-turbo + +# CLOUD DEPLOYMENT VARIRABLES ONLY +# AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting. +# DISABLE_TELEMETRY="false" + +########################################### +######## PASSWORD COMPLEXITY ############## +########################################### +# Enforce a password schema for your organization users. +# Documentation on how to use https://github.com/kamronbatman/joi-password-complexity +# Default is only 8 char minimum +# PASSWORDMINCHAR=8 +# PASSWORDMAXCHAR=250 +# PASSWORDLOWERCASE=1 +# PASSWORDUPPERCASE=1 +# PASSWORDNUMERIC=1 +# PASSWORDSYMBOL=1 +# PASSWORDREQUIREMENTS=4 + +########################################### +######## ENABLE HTTPS SERVER ############## +########################################### +# By enabling this and providing the path/filename for the key and cert, +# the server will use HTTPS instead of HTTP. +#ENABLE_HTTPS="true" +#HTTPS_CERT_PATH="sslcert/cert.pem" +#HTTPS_KEY_PATH="sslcert/key.pem" + +########################################### +######## AGENT SERVICE KEYS ############### +########################################### + +#------ SEARCH ENGINES ------- +#============================= +#------ Google Search -------- https://programmablesearchengine.google.com/controlpanel/create +# AGENT_GSE_KEY= +# AGENT_GSE_CTX= + +#------ SearchApi.io ----------- https://www.searchapi.io/ +# AGENT_SEARCHAPI_API_KEY= +# AGENT_SEARCHAPI_ENGINE=google + +#------ SerpApi ----------- https://serpapi.com/ +# AGENT_SERPAPI_API_KEY= +# AGENT_SERPAPI_ENGINE=google + +#------ Serper.dev ----------- https://serper.dev/ +# AGENT_SERPER_DEV_KEY= + +#------ Bing Search ----------- https://portal.azure.com/ +# AGENT_BING_SEARCH_API_KEY= + +#------ Baidu Search ----------- https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5 +# AGENT_BAIDU_SEARCH_API_KEY= + +#------ Serply.io ----------- https://serply.io/ +# AGENT_SERPLY_API_KEY= + +#------ SearXNG ----------- https://github.com/searxng/searxng +# AGENT_SEARXNG_API_URL= + +#------ Tavily ----------- https://www.tavily.com/ +# AGENT_TAVILY_API_KEY= + +#------ Exa Search ----------- https://www.exa.ai/ +# AGENT_EXA_API_KEY= + +#------ Perplexity Search ----------- [https://console.perplexity.ai](https://console.perplexity.ai) +# AGENT_PERPLEXITY_API_KEY= + +#------ fastCRW Search ----------- https://fastcrw.com/ +# AGENT_CRW_API_KEY= +# AGENT_CRW_API_URL= # Optional. Defaults to https://fastcrw.com/api. Set for self-hosted fastCRW. + +########################################### +######## Other Configurations ############ +########################################### + +# Disable viewing chat history from the UI and frontend APIs. +# See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. +# DISABLE_VIEW_CHAT_HISTORY=1 + +# Disable workspace deletion from the UI and APIs when this ENV is present with any value. +# WORKSPACE_DELETION_PROTECTION=1 + +# Enable simple SSO passthrough to pre-authenticate users from a third party service. +# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. +# SIMPLE_SSO_ENABLED=1 +# SIMPLE_SSO_NO_LOGIN=1 +# SIMPLE_SSO_NO_LOGIN_REDIRECT=https://your-custom-login-url.com (optional) + +# Allow scraping of any IP address in collector - must be string "true" to be enabled +# See https://docs.anythingllm.com/configuration#local-ip-address-scraping for more information. +# COLLECTOR_ALLOW_ANY_IP="true" + +# Port the collector listens on. +# COLLECTOR_PORT=8888 + +# Specify the target languages for when using OCR to parse images and PDFs. +# This is a comma separated list of language codes as a string. Unsupported languages will be ignored. +# Default is English. See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html for a list of valid language codes. +# TARGET_OCR_LANG=eng,deu,ita,spa,fra,por,rus,nld,tur,hun,pol,ita,spa,fra,por,rus,nld,tur,hun,pol + +# Runtime flags for built-in pupeeteer Chromium instance +# This is only required on Linux machines running AnythingLLM via Docker +# and do not want to use the --cap-add=SYS_ADMIN docker argument +# ANYTHINGLLM_CHROMIUM_ARGS="--no-sandbox,--disable-setuid-sandbox" + +# Disable Swagger API documentation endpoint. +# Set to "true" to disable the /api/docs endpoint (recommended for production deployments). +# DISABLE_SWAGGER_DOCS="true" + +# Disable MCP cooldown timer for agent calls +# this can lead to infinite recursive calls of the same function +# for some model/provider combinations +# MCP_NO_COOLDOWN="true + +# Force specific providers to use UnTooled (prompt-based) tool calling. +# Native tool calling is enabled by default for all providers that support it. +# Add a provider to this flag only if its native tool calling misbehaves and you want to fall back. +# PROVIDER_DISABLE_NATIVE_TOOL_CALLING="generic-openai,etc..." + +# (optional) Maximum number of tools an agent can chain for a single response. +# This prevents some lower-end models from infinite recursive tool calls. +# AGENT_MAX_TOOL_CALLS=10 + +# Agent tool reranking reduces token usage by selecting only the most relevant tools +# for each query. Uses the native embedding reranker to score tools against the user's prompt. +# This is enabled by default and can reduce token costs by 80% when you have +# many tools/MCP servers enabled. +# Set to "false" to disable. +# AGENT_SKILL_RERANKER_ENABLED="false" +# AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15) + +# (optional) Memory extraction background job settings. +# MEMORY_EXTRACTION_INTERVAL="15m" # How often the extraction job runs (default: 15m) +# MEMORY_IDLE_THRESHOLD_MS=1200000 # Min ms since last chat before extraction runs (default: 1200000 / 20min). Set to 0 to disable the idle check. + +# (optional) Maximum number of scheduled jobs that can run concurrently. +# Default is 1. Increase if using a cloud LLM provider with high rate limits. +# SCHEDULED_JOB_MAX_CONCURRENT=1 + +# (optional) Maximum time in milliseconds a scheduled job can run before being terminated. +# Default is 5 minutes (300000ms). +# SCHEDULED_JOB_TIMEOUT_MS=300000 + +# (optional) Comma-separated list of skills that are auto-approved. +# This will allow the skill to be invoked without user interaction. +# AGENT_AUTO_APPROVED_SKILLS=create-pdf-file,create-word-file + +# (optional) How long (in milliseconds) a watched document waits before it is +# re-synced by the document sync background worker. +# Default is 7 days (604800000ms). A minimum of 1 hour (3600000ms) is enforced. +# DOCUMENT_SYNC_STALE_AFTER_MS=604800000 + +########################################### +######## Embed Widget Security ############ +########################################### +# (Optional, hardening) When set to "true", public chat embed widgets that have +# NO allowed-domains allowlist configured will reject all requests instead of +# answering from any origin. Embeds that have an allowlist set are unaffected. +# Leaving this unset preserves the existing behavior. +# EMBED_REQUIRE_ALLOWLIST="true" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..403a46d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,182 @@ +# Setup base image +FROM ubuntu:noble-20251013 AS base + +# Build arguments +ARG ARG_UID=1000 +ARG ARG_GID=1000 + +FROM base AS build-arm64 +RUN echo "Preparing build of AnythingLLM image for arm64 architecture" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install system dependencies +# hadolint ignore=DL3008,DL3013 +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + unzip curl gnupg libgfortran5 libgbm1 tzdata netcat-openbsd \ + libasound2t64 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ + libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ + libxss1 libxtst6 ca-certificates fonts-liberation libappindicator3-1 libnss3 lsb-release \ + xdg-utils git build-essential ffmpeg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + # Install node and yarn + apt-get install -yq --no-install-recommends nodejs && \ + curl -LO https://github.com/yarnpkg/yarn/releases/download/v1.22.19/yarn_1.22.19_all.deb \ + && dpkg -i yarn_1.22.19_all.deb \ + && rm yarn_1.22.19_all.deb && \ + # Install uvx (pinned to 0.6.10) for MCP support + curl -LsSf https://astral.sh/uv/0.6.10/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + echo "Installed uvx! $(uv --version)" && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create a group and user with specific UID and GID +# First, remove any existing user/group with the target UID/GID to avoid conflicts +RUN (getent passwd "$ARG_UID" && userdel -f "$(getent passwd "$ARG_UID" | cut -d: -f1)") || true && \ + (getent group "$ARG_GID" && groupdel "$(getent group "$ARG_GID" | cut -d: -f1)") || true && \ + groupadd -g "$ARG_GID" anythingllm && \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g anythingllm anythingllm && \ + mkdir -p /app/frontend/ /app/server/ /app/collector/ && chown -R anythingllm:anythingllm /app + +# Copy docker helper scripts +COPY ./docker/docker-entrypoint.sh /usr/local/bin/ +COPY ./docker/docker-healthcheck.sh /usr/local/bin/ +COPY --chown=anythingllm:anythingllm ./docker/.env.example /app/server/.env + +# Ensure the scripts are executable +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-healthcheck.sh + +USER anythingllm +WORKDIR /app + +# Puppeteer does not ship with an ARM86 compatible build for Chromium +# so web-scraping would be broken in arm docker containers unless we patch it +# by manually installing a compatible chromedriver. +RUN echo "Need to patch Puppeteer x Chromium support for ARM86 - installing dep!" && \ + curl -fSL https://webassets.anythingllm.com/chromium-1088-linux-arm64.zip -o chrome-linux.zip && \ + unzip chrome-linux.zip && \ + rm -rf chrome-linux.zip + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV CHROME_PATH=/app/chrome-linux/chrome +ENV PUPPETEER_EXECUTABLE_PATH=/app/chrome-linux/chrome + +RUN echo "Done running arm64 specific installation steps" + +############################################# + +# amd64-specific stage +FROM base AS build-amd64 +RUN echo "Preparing build of AnythingLLM image for non-ARM architecture" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install system dependencies +# hadolint ignore=DL3008,DL3013 +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + curl gnupg libgfortran5 libgbm1 tzdata netcat-openbsd \ + libasound2t64 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ + libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ + libxss1 libxtst6 ca-certificates fonts-liberation libappindicator3-1 libnss3 lsb-release \ + xdg-utils git build-essential ffmpeg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + # Install node and yarn + apt-get install -yq --no-install-recommends nodejs && \ + curl -LO https://github.com/yarnpkg/yarn/releases/download/v1.22.19/yarn_1.22.19_all.deb \ + && dpkg -i yarn_1.22.19_all.deb \ + && rm yarn_1.22.19_all.deb && \ + # Install uvx (pinned to 0.6.10) for MCP support + curl -LsSf https://astral.sh/uv/0.6.10/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + echo "Installed uvx! $(uv --version)" && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create a group and user with specific UID and GID +# First, remove any existing user/group with the target UID/GID to avoid conflicts +RUN (getent passwd "$ARG_UID" && userdel -f "$(getent passwd "$ARG_UID" | cut -d: -f1)") || true && \ + (getent group "$ARG_GID" && groupdel "$(getent group "$ARG_GID" | cut -d: -f1)") || true && \ + groupadd -g "$ARG_GID" anythingllm && \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g anythingllm anythingllm && \ + mkdir -p /app/frontend/ /app/server/ /app/collector/ && chown -R anythingllm:anythingllm /app + +# Copy docker helper scripts +COPY ./docker/docker-entrypoint.sh /usr/local/bin/ +COPY ./docker/docker-healthcheck.sh /usr/local/bin/ +COPY --chown=anythingllm:anythingllm ./docker/.env.example /app/server/.env + +# Ensure the scripts are executable +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-healthcheck.sh + +############################################# +# COMMON BUILD FLOW FOR ALL ARCHS +############################################# + +# hadolint ignore=DL3006 +FROM build-${TARGETARCH} AS build +RUN echo "Running common build flow of AnythingLLM image for all architectures" + +USER anythingllm +WORKDIR /app + +# Install & Build frontend layer +# Use BUILDPLATFORM to run on the native host architecture (not emulated). +# This avoids esbuild crashing under QEMU when cross-compiling. +# The output (static HTML/CSS/JS) is platform-independent. +FROM --platform=$BUILDPLATFORM node:18-slim AS frontend-build +WORKDIR /app/frontend +COPY ./frontend/package.json ./frontend/yarn.lock ./ +RUN yarn install --network-timeout 100000 && yarn cache clean +COPY ./frontend/ ./ +RUN yarn build +WORKDIR /app + +# Install server layer +# Also pull and build collector deps (chromium issues prevent bad bindings) +FROM build AS backend-build +COPY --chown=anythingllm:anythingllm ./server /app/server/ +WORKDIR /app/server +RUN yarn install --production --network-timeout 100000 && yarn cache clean +WORKDIR /app + +# Install collector dependencies +COPY --chown=anythingllm:anythingllm ./collector/ ./collector/ +WORKDIR /app/collector +ENV PUPPETEER_DOWNLOAD_BASE_URL=https://storage.googleapis.com/chrome-for-testing-public +RUN yarn install --production --network-timeout 100000 && yarn cache clean + +WORKDIR /app +USER anythingllm + +# Since we are building from backend-build we just need to move built frontend into server/public +FROM backend-build AS production-build +WORKDIR /app +COPY --chown=anythingllm:anythingllm --from=frontend-build /app/frontend/dist /app/server/public + +# Setup the environment +ENV NODE_ENV=production +ENV ANYTHING_LLM_RUNTIME=docker +ENV DEPLOYMENT_VERSION=1.15.0 + +# Setup the healthcheck +HEALTHCHECK --interval=1m --timeout=10s --start-period=1m \ + CMD /bin/bash /usr/local/bin/docker-healthcheck.sh || exit 1 + +# Run the server +# CMD ["sh", "-c", "tail -f /dev/null"] # For development: keep container open +ENTRYPOINT ["/bin/bash", "/usr/local/bin/docker-entrypoint.sh"] diff --git a/docker/HOW_TO_USE_DOCKER.md b/docker/HOW_TO_USE_DOCKER.md new file mode 100644 index 0000000..1102481 --- /dev/null +++ b/docker/HOW_TO_USE_DOCKER.md @@ -0,0 +1,209 @@ +# How to use Dockerized Anything LLM + +Use the Dockerized version of AnythingLLM for a much faster and complete startup of AnythingLLM. + +### Minimum Requirements + +> [!TIP] +> Running AnythingLLM on AWS/GCP/Azure? +> You should aim for at least 2GB of RAM. Disk storage is proportional to however much data +> you will be storing (documents, vectors, models, etc). Minimum 10GB recommended. + +- `docker` installed on your machine +- `yarn` and `node` on your machine +- access to an LLM running locally or remotely + +\*AnythingLLM by default uses a built-in vector database powered by [LanceDB](https://github.com/lancedb/lancedb) + +\*AnythingLLM by default embeds text on instance privately [Learn More](../server/storage/models/README.md) + +## Recommend way to run dockerized AnythingLLM! + +> [!IMPORTANT] +> If you are running another service on localhost like Chroma, LocalAi, or LMStudio +> you will need to use http://host.docker.internal:xxxx to access the service from within +> the docker container using AnythingLLM as `localhost:xxxx` will not resolve for the host system. +> +> **Requires** Docker v18.03+ on Win/Mac and 20.10+ on Linux/Ubuntu for host.docker.internal to resolve! +> +> _Linux_: add `--add-host=host.docker.internal:host-gateway` to docker run command for this to resolve. +> +> eg: Chroma host URL running on localhost:8000 on host machine needs to be http://host.docker.internal:8000 +> when used in AnythingLLM. + +> [!TIP] +> It is best to mount the containers storage volume to a folder on your host machine +> so that you can pull in future updates without deleting your existing data! + +Pull in the latest image from docker. Supports both `amd64` and `arm64` CPU architectures. + +```shell +docker pull mintplexlabs/anythingllm +``` + + + + + + + + + + + + + + + + + +
Mount the storage locally and run AnythingLLM in Docker
+ Linux/MacOs + + +```shell +export STORAGE_LOCATION=$HOME/anythingllm && \ +mkdir -p $STORAGE_LOCATION && \ +touch "$STORAGE_LOCATION/.env" && \ +docker run -d --rm -p 3001:3001 \ +--cap-add SYS_ADMIN \ +-v ${STORAGE_LOCATION}:/app/server/storage \ +-v ${STORAGE_LOCATION}/.env:/app/server/.env \ +-e STORAGE_DIR="/app/server/storage" \ +mintplexlabs/anythingllm +``` + +
+ Windows + + +```powershell +# Run this in powershell terminal +$env:STORAGE_LOCATION="$HOME\Documents\anythingllm"; ` +If(!(Test-Path $env:STORAGE_LOCATION)) {New-Item $env:STORAGE_LOCATION -ItemType Directory}; ` +If(!(Test-Path "$env:STORAGE_LOCATION\.env")) {New-Item "$env:STORAGE_LOCATION\.env" -ItemType File}; ` +docker run -d --rm -p 3001:3001 ` +--cap-add SYS_ADMIN ` +-v "$env:STORAGE_LOCATION`:/app/server/storage" ` +-v "$env:STORAGE_LOCATION\.env:/app/server/.env" ` +-e STORAGE_DIR="/app/server/storage" ` +mintplexlabs/anythingllm; +``` + +
Docker Compose + + +```yaml +version: '3.8' +services: + anythingllm: + image: mintplexlabs/anythingllm + container_name: anythingllm + ports: + - "3001:3001" + cap_add: + - SYS_ADMIN + environment: + # Adjust for your environment + - STORAGE_DIR=/app/server/storage + - JWT_SECRET="make this a large list of random numbers and letters 20+" + - LLM_PROVIDER=ollama + - OLLAMA_BASE_PATH=http://127.0.0.1:11434 + - OLLAMA_MODEL_PREF=llama2 + - OLLAMA_MODEL_TOKEN_LIMIT=4096 + - EMBEDDING_ENGINE=ollama + - EMBEDDING_BASE_PATH=http://127.0.0.1:11434 + - EMBEDDING_MODEL_PREF=nomic-embed-text:latest + - EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + - VECTOR_DB=lancedb + - WHISPER_PROVIDER=local + - TTS_PROVIDER=native + - PASSWORDMINCHAR=8 + # Add any other keys here for services or settings + # you can find in the docker/.env.example file + volumes: + - anythingllm_storage:/app/server/storage + restart: always + +volumes: + anythingllm_storage: + driver: local + driver_opts: + type: none + o: bind + device: /path/on/local/disk +``` + +
+ +Go to `http://localhost:3001` and you are now using AnythingLLM! All your data and progress will persist between +container rebuilds or pulls from Docker Hub. + +## How to use the user interface + +- To access the full application, visit `http://localhost:3001` in your browser. + +## About UID and GID in the ENV + +- The UID and GID are set to 1000 by default. This is the default user in the Docker container and on most host operating systems. If there is a mismatch between your host user UID and GID and what is set in the `.env` file, you may experience permission issues. + +## Build locally from source _not recommended for casual use_ + +- `git clone` this repo and `cd anything-llm` to get to the root directory. +- `touch server/storage/anythingllm.db` to create empty SQLite DB file. +- `cd docker/` +- `cp .env.example .env` **you must do this before building** +- `docker-compose up -d --build` to build the image - this will take a few moments. + +Your docker host will show the image as online once the build process is completed. This will build the app to `http://localhost:3001`. + +## Integrations and one-click setups + +The integrations below are templates or tooling built by the community to make running the docker experience of AnythingLLM easier. + +### Use the Midori AI Subsystem to Manage AnythingLLM + +Follow the setup found on [Midori AI Subsystem Site](https://io.midori-ai.xyz/subsystem/manager/) for your host OS +After setting that up install the AnythingLLM docker backend to the Midori AI Subsystem. + +Once that is done, you are all set! + +## Common questions and fixes + +### Cannot connect to service running on localhost! + +If you are in docker and cannot connect to a service running on your host machine running on a local interface or loopback: + +- `localhost` +- `127.0.0.1` +- `0.0.0.0` + +> [!IMPORTANT] +> On linux `http://host.docker.internal:xxxx` does not work. +> Use `http://172.17.0.1:xxxx` instead to emulate this functionality. + +Then in docker you need to replace that localhost part with `host.docker.internal`. For example, if running Ollama on the host machine, bound to http://127.0.0.1:11434 you should put `http://host.docker.internal:11434` into the connection URL in AnythingLLM. + + +### API is not working, cannot login, LLM is "offline"? + +You are likely running the docker container on a remote machine like EC2 or some other instance where the reachable URL +is not `http://localhost:3001` and instead is something like `http://193.xx.xx.xx:3001` - in this case all you need to do is add the following to your `frontend/.env.production` before running `docker-compose up -d --build` + +``` +# frontend/.env.production +GENERATE_SOURCEMAP=false +VITE_API_BASE="http://:3001/api" +``` + +For example, if the docker instance is available on `192.186.1.222` your `VITE_API_BASE` would look like `VITE_API_BASE="http://192.186.1.222:3001/api"` in `frontend/.env.production`. + +### Having issues with Ollama? + +If you are getting errors like `llama:streaming - could not stream chat. Error: connect ECONNREFUSED 172.17.0.1:11434` then visit the README below. + +[Fix common issues with Ollama](../server/utils/AiProviders/ollama/README.md) + +### Still not working? + +[Ask for help on Discord](https://discord.gg/6UyHPeGZAC) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..b5782ee --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,31 @@ +name: anythingllm + +networks: + anything-llm: + driver: bridge + +services: + anything-llm: + container_name: anythingllm + build: + context: ../. + dockerfile: ./docker/Dockerfile + args: + ARG_UID: ${UID:-1000} + ARG_GID: ${GID:-1000} + cap_add: + - SYS_ADMIN + volumes: + - "./.env:/app/server/.env" + - "../server/storage:/app/server/storage" + - "../collector/hotdir/:/app/collector/hotdir" + - "../collector/outputs/:/app/collector/outputs" + user: "${UID:-1000}:${GID:-1000}" + ports: + - "3001:3001" + env_file: + - .env + networks: + - anything-llm + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100755 index 0000000..37ee927 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Check if STORAGE_DIR is set +if [ -z "$STORAGE_DIR" ]; then + echo "================================================================" + echo "⚠️ ⚠️ ⚠️ WARNING: STORAGE_DIR environment variable is not set! ⚠️ ⚠️ ⚠️" + echo "" + echo "Not setting this will result in data loss on container restart since" + echo "the application will not have a persistent storage location." + echo "It can also result in weird errors in various parts of the application." + echo "" + echo "Please run the container with the official docker command at" + echo "https://docs.anythingllm.com/installation-docker/quickstart" + echo "" + echo "⚠️ ⚠️ ⚠️ WARNING: STORAGE_DIR environment variable is not set! ⚠️ ⚠️ ⚠️" + echo "================================================================" +fi + +{ + cd /app/server/ && + # Disable Prisma CLI telemetry (https://www.prisma.io/docs/orm/tools/prisma-cli#how-to-opt-out-of-data-collection) + export CHECKPOINT_DISABLE=1 && + npx prisma generate --schema=./prisma/schema.prisma && + npx prisma migrate deploy --schema=./prisma/schema.prisma && + node /app/server/index.js +} & +{ node /app/collector/index.js; } & +wait -n +exit $? \ No newline at end of file diff --git a/docker/docker-healthcheck.sh b/docker/docker-healthcheck.sh new file mode 100644 index 0000000..49bee3e --- /dev/null +++ b/docker/docker-healthcheck.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Send a request to the specified URL +response=$(curl --write-out '%{http_code}' --silent --output /dev/null http://localhost:3001/api/ping) + +# If the HTTP response code is 200 (OK), the server is up +if [ "$response" -eq 200 ]; then + echo "Server is up" + exit 0 +else + echo "Server is down" + exit 1 +fi diff --git a/docker/vex/CVE-2019-10790.vex.json b/docker/vex/CVE-2019-10790.vex.json new file mode 100644 index 0000000..4233fd1 --- /dev/null +++ b/docker/vex/CVE-2019-10790.vex.json @@ -0,0 +1,22 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://openvex.dev/docs/public/vex-6750d79bb005487e11d10f81d0b3ac92c47e6e259292c6b2d02558f9f4bca52d", + "author": "tim@mintplexlabs.com", + "timestamp": "2024-07-22T13:49:12.883675-07:00", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2019-10790" + }, + "timestamp": "2024-07-22T13:49:12.883678-07:00", + "products": [ + { + "@id": "pkg:npm/taffydb@2.6.2" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path" + } + ] +} \ No newline at end of file diff --git a/docker/vex/CVE-2024-29415.vex.json b/docker/vex/CVE-2024-29415.vex.json new file mode 100644 index 0000000..dfe5b46 --- /dev/null +++ b/docker/vex/CVE-2024-29415.vex.json @@ -0,0 +1,22 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://openvex.dev/docs/public/vex-939548c125c5bfebd3fd91e64c1c53bffacbde06b3611b4474ea90fa58045004", + "author": "tim@mintplexlabs.com", + "timestamp": "2024-07-19T16:08:47.147169-07:00", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2024-29415" + }, + "timestamp": "2024-07-19T16:08:47.147172-07:00", + "products": [ + { + "@id": "pkg:npm/ip@2.0.0" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_present" + } + ] +} \ No newline at end of file diff --git a/docker/vex/CVE-2024-37890.vex.json b/docker/vex/CVE-2024-37890.vex.json new file mode 100644 index 0000000..13498ec --- /dev/null +++ b/docker/vex/CVE-2024-37890.vex.json @@ -0,0 +1,22 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://openvex.dev/docs/public/vex-939548c125c5bfebd3fd91e64c1c53bffacbde06b3611b4474ea90fa58045004", + "author": "tim@mintplexlabs.com", + "timestamp": "2024-07-19T16:08:47.147169-07:00", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2024-37890" + }, + "timestamp": "2024-07-19T16:08:47.147172-07:00", + "products": [ + { + "@id": "pkg:npm/ws@8.14.2" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path" + } + ] +} \ No newline at end of file diff --git a/docker/vex/CVE-2024-4068.vex.json b/docker/vex/CVE-2024-4068.vex.json new file mode 100644 index 0000000..41f73ed --- /dev/null +++ b/docker/vex/CVE-2024-4068.vex.json @@ -0,0 +1,22 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://openvex.dev/docs/public/vex-939548c125c5bfebd3fd91e64c1c53bffacbde06b3611b4474ea90fa58045004", + "author": "tim@mintplexlabs.com", + "timestamp": "2024-07-19T16:08:47.147169-07:00", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2024-4068" + }, + "timestamp": "2024-07-19T16:08:47.147172-07:00", + "products": [ + { + "@id": "pkg:npm/braces@3.0.2" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_present" + } + ] +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ecdf58d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,94 @@ +import globals from "./server/node_modules/globals/index.js" +import eslintRecommended from "./server/node_modules/@eslint/js/src/index.js" +import eslintConfigPrettier from "./server/node_modules/eslint-config-prettier/index.js" +import prettier from "./server/node_modules/eslint-plugin-prettier/eslint-plugin-prettier.js" +import react from "./server/node_modules/eslint-plugin-react/index.js" +import reactRefresh from "./server/node_modules/eslint-plugin-react-refresh/index.js" +import reactHooks from "./server/node_modules/eslint-plugin-react-hooks/index.js" +import ftFlow from "./server/node_modules/eslint-plugin-ft-flow/dist/index.js" +import hermesParser from "./server/node_modules/hermes-eslint/dist/index.js" + +const reactRecommended = react.configs.recommended +const jsxRuntime = react.configs["jsx-runtime"] + +export default [ + { + ignores: ["**/node_modules/**", "**/dist/**", "**/storage/**"] + }, + eslintRecommended.configs.recommended, + eslintConfigPrettier, + { + ignores: ["**/*.test.js"], + languageOptions: { + parser: hermesParser, + parserOptions: { + ecmaFeatures: { jsx: true } + }, + ecmaVersion: 2020, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.es2020, + ...globals.node + } + }, + linterOptions: { reportUnusedDisableDirectives: true }, + settings: { react: { version: "18.2" } }, + plugins: { + ftFlow, + react, + "jsx-runtime": jsxRuntime, + "react-hooks": reactHooks, + prettier + }, + rules: { + ...reactRecommended.rules, + ...reactHooks.configs.recommended.rules, + ...ftFlow.recommended, + "no-unused-vars": "warn", + "no-undef": "warn", + "no-empty": "warn", + "no-extra-boolean-cast": "warn", + "no-prototype-builtins": "off", + "prettier/prettier": "warn" + } + }, + { + files: ["frontend/src/**/*.js"], + plugins: { + ftFlow, + prettier + }, + rules: { + "prettier/prettier": "warn" + } + }, + { + files: [ + "server/endpoints/**/*.js", + "server/models/**/*.js", + "server/swagger/**/*.js", + "server/utils/**/*.js", + "server/index.js" + ], + rules: { + "no-undef": "warn" + } + }, + { + files: ["frontend/src/**/*.jsx"], + plugins: { + ftFlow, + react, + "jsx-runtime": jsxRuntime, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + prettier + }, + rules: { + ...jsxRuntime.rules, + "react/prop-types": "off", // FIXME + "react-refresh/only-export-components": "warn" + } + } +] diff --git a/extras/scripts/verifyPackageVersions.mjs b/extras/scripts/verifyPackageVersions.mjs new file mode 100644 index 0000000..fad015a --- /dev/null +++ b/extras/scripts/verifyPackageVersions.mjs @@ -0,0 +1,34 @@ +import serverPackageJson from '../../server/package.json' assert { type: 'json' }; +import collectorPackageJson from '../../collector/package.json' assert { type: 'json' }; +const { dependencies: serverDependencies } = serverPackageJson; +const { dependencies: collectorDependencies } = collectorPackageJson; + +const serverDependenciesKeys = Object.keys(serverDependencies); +const collectorDependenciesKeys = Object.keys(collectorDependencies); +const commonDependencies = Array.from(new Set([ + ...serverDependenciesKeys.filter((key) => collectorDependenciesKeys.includes(key)), + ...collectorDependenciesKeys.filter((key) => serverDependenciesKeys.includes(key)), +])); + +const ignores = [ + "@langchain/community" // We are slowly removing this dependency from the app - its use is not critical +] + +console.log(`${commonDependencies.length} common dependencies found`, commonDependencies); +console.log(`Verifying (serverVersion == collectorVersion) for each common dependency`); + +const failed = []; +commonDependencies.forEach((dependency) => { + console.log(`Verifying ${dependency}: ${serverDependencies[dependency]} == ${collectorDependencies[dependency]}`); + if (serverDependencies[dependency] !== collectorDependencies[dependency]) { + if (ignores.includes(dependency)) console.log(`${dependency} is in ignore list.`); + else failed.push({ dependency, serverVersion: serverDependencies[dependency], collectorVersion: collectorDependencies[dependency] }); + } +}); + +if (failed.length > 0) { + console.log(`❌ ${failed.length} dependencies failed to verify`, JSON.stringify(failed, null, 2)); + throw new Error(`${failed.length} dependencies failed to verify!`); +} + +console.log(`👍 All dependencies match between server and collector!`); diff --git a/extras/support/announcements/2025-04-08.json b/extras/support/announcements/2025-04-08.json new file mode 100644 index 0000000..58535ec --- /dev/null +++ b/extras/support/announcements/2025-04-08.json @@ -0,0 +1,26 @@ +[ + { + "thumbnail_url": "https://cdn.anythingllm.com/support/announcements/assets/mcp.jpg", + "title": "MCP Support", + "short_description": "Import and leverage MCP tools using AnythingLLM.", + "goto": "https://docs.anythingllm.com/mcp-compatibility/overview", + "author": "AnythingLLM", + "date": "April 8, 2025" + }, + { + "thumbnail_url": "https://blogs.nvidia.com/wp-content/uploads/2025/03/nv-raig-032525-nv-blog-1280x680-1-scaled.jpg", + "title": "NVIDIA NIM Support", + "short_description": "Unlock the power of NVIDIA NIM on Windows with RTX GPU in our latest update via the NVIDIA NIM LLM provider.", + "goto": "https://blogs.nvidia.com/blog/rtx-ai-garage-nim-blueprints-g-assist", + "author": "NVIDIA", + "date": "March 25, 2025" + }, + { + "thumbnail_url": null, + "title": "Community Hub Updates", + "short_description": "We refreshed the Community Hub with a new look and feel. Check it out!", + "goto": "https://hub.anythingllm.com", + "author": "AnythingLLM", + "date": "March 12, 2025" + } +] \ No newline at end of file diff --git a/extras/support/announcements/2025-07-08.json b/extras/support/announcements/2025-07-08.json new file mode 100644 index 0000000..2fb306e --- /dev/null +++ b/extras/support/announcements/2025-07-08.json @@ -0,0 +1,25 @@ +[ + { + "thumbnail_url": "https://cdn.anythingllm.com/support/announcements/assets/private-browsing.png", + "title": "Private Web Scraping", + "short_description": "Scrape private, gated, or authenticated websites using the browser tool.", + "goto": "https://docs.anythingllm.com/features/browser-tool", + "author": "AnythingLLM", + "date": "July 8, 2025" + }, + { + "thumbnail_url": "https://cdn.anythingllm.com/support/announcements/assets/mobile.png", + "title": "AnythingLLM Mobile Beta", + "short_description": "AnythingLLM on-device, offline, and private. Syncs with AnythingLLM.", + "goto": "https://docs.google.com/forms/d/e/1FAIpQLSdrMRCUXVDWKrtNTcHzVcHQk_SRoiT8X8tiawTFtAhjMq_L6Q/viewform", + "author": "AnythingLLM", + "date": "July 3, 2025" + }, + { + "title": "Community Hub updates", + "short_description": "You can now easily push and pull Agent Flows, System Prompts, and more to the AnythingLLM Community Hub.", + "goto": "https://hub.anythingllm.com", + "author": "AnythingLLM", + "date": "June 25, 2025" + } +] \ No newline at end of file diff --git a/extras/support/announcements/2026-01-12.json b/extras/support/announcements/2026-01-12.json new file mode 100644 index 0000000..fd1f6ab --- /dev/null +++ b/extras/support/announcements/2026-01-12.json @@ -0,0 +1,25 @@ +[ + { + "thumbnail_url": "https://cdn.anythingllm.com/support/announcements/assets/meeting-assistant.png", + "title": "Meeting Assistant", + "short_description": "Transcribe meetings and generate meeting notes entirely on device.", + "goto": "https://docs.anythingllm.com/meeting-assistant/introduction", + "author": "AnythingLLM", + "date": "January 21, 2026" + }, + { + "thumbnail_url": "https://cdn.anythingllm.com/support/announcements/assets/mobile.png", + "title": "AnythingLLM Mobile", + "short_description": "AnythingLLM Mobile is now available on the Google Play Store.", + "goto": "https://play.google.com/store/apps/details?id=com.anythingllm", + "author": "AnythingLLM", + "date": "January 5, 2026" + }, + { + "title": "50K Stars on Github", + "short_description": "AnythingLLM broke 50K stars on Github!", + "goto": "https://github.com/mintplex-labs/anything-llm", + "author": "AnythingLLM", + "date": "October 21, 2025" + } +] \ No newline at end of file diff --git a/extras/support/announcements/assets/mcp.jpg b/extras/support/announcements/assets/mcp.jpg new file mode 100644 index 0000000..b0abd8a Binary files /dev/null and b/extras/support/announcements/assets/mcp.jpg differ diff --git a/extras/support/announcements/assets/meeting-assistant.png b/extras/support/announcements/assets/meeting-assistant.png new file mode 100644 index 0000000..750ddec Binary files /dev/null and b/extras/support/announcements/assets/meeting-assistant.png differ diff --git a/extras/support/announcements/assets/mobile.png b/extras/support/announcements/assets/mobile.png new file mode 100644 index 0000000..f11dd38 Binary files /dev/null and b/extras/support/announcements/assets/mobile.png differ diff --git a/extras/support/announcements/assets/private-browsing.png b/extras/support/announcements/assets/private-browsing.png new file mode 100644 index 0000000..966a7c4 Binary files /dev/null and b/extras/support/announcements/assets/private-browsing.png differ diff --git a/extras/support/announcements/list.txt b/extras/support/announcements/list.txt new file mode 100644 index 0000000..c1b6242 --- /dev/null +++ b/extras/support/announcements/list.txt @@ -0,0 +1,3 @@ +2026-01-12.json +2025-07-08.json +2025-04-08.json \ No newline at end of file diff --git a/extras/translator/.env.example b/extras/translator/.env.example new file mode 100644 index 0000000..b3cd6a4 --- /dev/null +++ b/extras/translator/.env.example @@ -0,0 +1,2 @@ +DOCKER_MODEL_RUNNER_BASE_PATH='http://127.0.0.1:12434/engines/llama.cpp/v1' +DOCKER_MODEL_RUNNER_LLM_MODEL_TOKEN_LIMIT=128000 \ No newline at end of file diff --git a/extras/translator/README.md b/extras/translator/README.md new file mode 100644 index 0000000..1f6e9d8 --- /dev/null +++ b/extras/translator/README.md @@ -0,0 +1,28 @@ +# AnythingLLM Auto-translater + +The AnythingLLM Auto-translator is a way for us to translate our locales but at no cost or overhead. However these are expected to be run manually and while they may not be 100% accurate improvement in models have made this work trivial without the need for an api. + +## Getting started + +- Install Ollama [w/Docker Model Runner](https://ollama.com) +- `ollama pull translategemma:4b` +- `cd extras/translator && cp .env.example .env` + +## Run the script + +All translations are based on english dictionary. So the English dictionary must have an entry for it to be supported. + +`cd extras/translator` + +**Target a specific language** +`node index.mjs ` eg: `node index.mjs es` for Spanish. + +**Do all languages** +`node index.mjs --all` _this is NOT recommended_ + + +## Gotchas + +- Sometimes translations go on for a while until the token window is exceeded - you can see this by the massive lines extending beyond the page. +- Some languages operate different with single words or special symbols like "@" and will go off the rails. If the English text is one word and the translated text is 100 words, you dont need to be linguist to know that it is probably wrong. +- You should always review every line for discrepencies or removal of `{{}}` inputs or brand name hallunications eg: `AnyLLM` instead of `AnythingLLM` \ No newline at end of file diff --git a/extras/translator/index.mjs b/extras/translator/index.mjs new file mode 100644 index 0000000..478384c --- /dev/null +++ b/extras/translator/index.mjs @@ -0,0 +1,319 @@ +import fs from 'fs'; +import {resources} from '../../frontend/src/locales/resources.js'; +import "../../server/node_modules/dotenv/lib/main.js"; + +function getNestedValue(obj, path) { + const keys = path.split('.'); + let result = obj; + for (const key of keys) { + if (result == null) return undefined; + result = result[key]; + } + return result; + } + +function setNestedValue(obj, path, value) { + const keys = path.split('.'); + let result = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (result[key] == null) result[key] = {}; + result = result[key]; + } + result[keys[keys.length - 1]] = value; +} + +/** + * Extract {{variableName}} placeholders from text and replace with tokens. + * Returns the modified text and a map to restore the originals. + * @param {string} text + * @returns {{ text: string, placeholders: string[] }} + */ +function extractPlaceholders(text) { + const placeholders = []; + const modifiedText = text.replace(/\{\{([^}]+)\}\}/g, (match) => { + const index = placeholders.length; + placeholders.push(match); + return `__PLACEHOLDER_${index}__`; + }); + return { text: modifiedText, placeholders }; +} + +/** + * Restore original {{variableName}} placeholders from tokens. + * @param {string} text + * @param {string[]} placeholders + * @returns {string} + */ +function restorePlaceholders(text, placeholders) { + return text.replace(/__PLACEHOLDER_(\d+)__/g, (_, index) => { + return placeholders[parseInt(index, 10)] || `__PLACEHOLDER_${index}__`; + }); +} + +/** + * Extract Trans component tags like , , , , etc. + * These are used by react-i18next Trans component for rich text formatting. + * @param {string} text + * @returns {{ text: string, tags: string[] }} + */ +function extractTransTags(text) { + const tags = []; + // Match opening tags and closing tags + // Also matches self-closing tags + const modifiedText = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g, (match) => { + const index = tags.length; + tags.push(match); + return `__TAG_${index}__`; + }); + return { text: modifiedText, tags }; +} + +/** + * Restore original Trans component tags from tokens. + * @param {string} text + * @param {string[]} tags + * @returns {string} + */ +function restoreTransTags(text, tags) { + return text.replace(/__TAG_(\d+)__/g, (_, index) => { + return tags[parseInt(index, 10)] || `__TAG_${index}__`; + }); +} + +/** + * Validate that all placeholders from source exist in translated text. + * @param {string} sourceText + * @param {string} translatedText + * @returns {{ valid: boolean, missing: string[] }} + */ +function validatePlaceholders(sourceText, translatedText) { + const sourceMatches = sourceText.match(/\{\{([^}]+)\}\}/g) || []; + const translatedMatches = translatedText.match(/\{\{([^}]+)\}\}/g) || []; + const missing = sourceMatches.filter(p => !translatedMatches.includes(p)); + return { valid: missing.length === 0, missing }; +} + +/** + * Validate that all Trans component tags from source exist in translated text. + * @param {string} sourceText + * @param {string} translatedText + * @returns {{ valid: boolean, missing: string[] }} + */ +function validateTransTags(sourceText, translatedText) { + const sourceMatches = sourceText.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g) || []; + const translatedMatches = translatedText.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g) || []; + const missing = sourceMatches.filter(t => !translatedMatches.includes(t)); + return { valid: missing.length === 0, missing }; +} + +class Translator { + static modelTag = 'translategemma:4b' + constructor() { + this.localeObj = new Intl.DisplayNames(Object.keys(resources), { type: 'language' }); + } + + getLanguageName(localeCode) { + try { + return this.localeObj.of(localeCode); + } catch (error) { + console.error("Error getting language name:", error); + return null; + } + } + + #log(text, ...args) { + console.log(`\x1b[32m[Translator]\x1b[0m ${text}`, ...args); + } + + static slog(text, ...args) { + console.log(`\x1b[32m[Translator]\x1b[0m ${text}`, ...args); + } + + buildPrompt(text, sourceLangCode, targetLangCode, { hasPlaceholders = false, hasTags = false } = {}) { + const sourceLanguage = this.getLanguageName(sourceLangCode); + const targetLanguage = this.getLanguageName(targetLangCode); + + let specialInstructions = ''; + if (hasPlaceholders || hasTags) { + const items = []; + if (hasPlaceholders) items.push('__PLACEHOLDER_0__, __PLACEHOLDER_1__'); + if (hasTags) items.push('__TAG_0__, __TAG_1__'); + specialInstructions = `\nIMPORTANT: The text contains tokens like ${items.join(', ')}, etc. You MUST keep these tokens exactly as they are in the translation - do not translate, modify, or remove them.`; + } + + return `You are a professional ${sourceLanguage} (${sourceLangCode.toLowerCase()}) to ${targetLanguage} (${targetLangCode.toLowerCase()}) translator. Your goal is to accurately convey the meaning and nuances of the original ${sourceLanguage} text while adhering to ${targetLanguage} grammar, vocabulary, and cultural sensitivities.${specialInstructions} +Produce only the ${targetLanguage} translation, without any additional explanations or commentary. Please translate the following ${sourceLanguage} text into ${targetLanguage}: + + +${text}` + } + + /** + * Clean the output text from the model + * Output text: `在助手回复中呈现 HTML 响应。这可以显著提高回复的质量,但也可能带来潜在的安全风险。<|im_end|>` + * We want to remove the <|im_end|> or im_start tags + * @param {*} text + * @returns + */ + cleanOutputText(text) { + return text.replace(/<\|im_end\|>|<\|im_start\|>/g, '').trim(); + } + + async translate(text, sourceLangCode, targetLangCode) { + // Extract placeholders like {{variableName}} and replace with tokens + const { text: textWithPlaceholders, placeholders } = extractPlaceholders(text); + const hasPlaceholders = placeholders.length > 0; + + // Extract Trans component tags like , , etc. + const { text: textWithTokens, tags } = extractTransTags(textWithPlaceholders); + const hasTags = tags.length > 0; + + const prompt = this.buildPrompt(textWithTokens, sourceLangCode, targetLangCode, { hasPlaceholders, hasTags }); + const response = await fetch(`http://127.0.0.1:11434/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: Translator.modelTag, + messages: [{ role: 'user', content: prompt }], + temperature: 0.1, + stream: false, + }), + }); + + if(!response.ok) throw new Error(`Failed to translate: ${response.statusText}`); + const data = await response.json(); + let translatedText = this.cleanOutputText(data.message.content); + + // Restore Trans component tags first (order matters since tags may contain placeholders) + if (hasTags) { + translatedText = restoreTransTags(translatedText, tags); + + // Validate all tags were preserved + const tagValidation = validateTransTags(text, translatedText); + if (!tagValidation.valid) { + console.warn(`Warning: Missing Trans tags in translation: ${tagValidation.missing.join(', ')}`); + for (let i = 0; i < tags.length; i++) { + if (!translatedText.includes(tags[i])) { + console.warn(` Tag ${tags[i]} was lost in translation`); + } + } + } + } + + // Restore original placeholders + if (hasPlaceholders) { + translatedText = restorePlaceholders(translatedText, placeholders); + + // Validate all placeholders were preserved + const validation = validatePlaceholders(text, translatedText); + if (!validation.valid) { + console.warn(`Warning: Missing placeholders in translation: ${validation.missing.join(', ')}`); + // Attempt to fix by checking if tokens remain untranslated + for (let i = 0; i < placeholders.length; i++) { + if (!translatedText.includes(placeholders[i])) { + console.warn(` Placeholder ${placeholders[i]} was lost in translation`); + } + } + } + } + + return translatedText; + } + + writeTranslations(langCode, translations) { + let langFilename = langCode.toLowerCase(); + // Special cases + if(langCode === 'pt') langFilename = 'pt_BR'; + if(langCode === 'zh-tw') langFilename = 'zh_TW'; + if(langCode === 'vi') langFilename = 'vn'; + + fs.writeFileSync( + `../../frontend/src/locales/${langFilename}/common.js`, + `// Anything with "null" requires a translation. Contribute to translation via a PR! +const TRANSLATIONS = ${JSON.stringify(translations, null, 2)} + +export default TRANSLATIONS;` + ); + console.log(`Updated ${langCode} translations file`); + } +} + + +// Deep traverse the english translations and get all the path to any all keys +const translator = new Translator(); +const englishTranslations = resources.en.common; +const allKeys = []; +function traverseTranslations(translations, parentKey = '') { + for(const [key, value] of Object.entries(translations)) { + const fullKey = !parentKey ? key : `${parentKey}.${key}`; + if(typeof value === 'object' && value !== null) { + traverseTranslations(value, fullKey); + } else { + allKeys.push(fullKey); + } + } +} +traverseTranslations(englishTranslations); +delete resources.en; + +async function translateAllLanguages() { + for(const [langCode, { common }] of Object.entries(resources)) { + console.log(`Translating ${translator.getLanguageName(langCode)}(${langCode}) to all languages`); + await translateSingleLanguage(langCode); + } +} + +async function translateSingleLanguage(langCode) { + let totalTranslations = 0; + for(const key of allKeys) { + const sourceText = getNestedValue(englishTranslations, key); + if(!sourceText) continue; + + // If the source text is @agent, set the translation to @agent - this has no + // direct translation and must be handled manually + if(sourceText === '@agent') { + setNestedValue(resources[langCode].common, key, '@agent'); + continue; + } + if(sourceText === '/reset') { + setNestedValue(resources[langCode].common, key, '/reset'); + continue; + } + + const value = getNestedValue(resources[langCode].common, key); + if(!!value) continue; // If the translation is already present, skip it + + console.log(`Translation not found for ${translator.getLanguageName(langCode)}(${langCode})`, { + key, + sourceText, + }); + const outputText = await translator.translate(sourceText, 'en', langCode); + if(!outputText) { + console.log('No output text - skipping'); + continue; + } + + console.log(`Output text: ${outputText}`); + setNestedValue(resources[langCode].common, key, outputText); + console.log(`--------------------------------`); + totalTranslations++; + } + + if(totalTranslations === 0) return console.log('No translations performed!'); + console.log(`--------------------------------`); + console.log(`Translated ${totalTranslations} translations for ${langCode}`); + translator.writeTranslations(langCode, resources[langCode].common); + console.log(`--------------------------------`); +} + +let langArg = process.argv[2]; +if(langArg) { + if(langArg.toLowerCase() === '--all') await translateAllLanguages(); + else { + if(!Object.keys(resources).includes(langArg)) throw new Error(`Language ${langArg} not found in resources`); + await translateSingleLanguage(langArg); + } +} else { + throw new Error('Please provide a language code as an argument or --all to translate all languages'); +} \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..05619d2 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE='http://localhost:3001/api' # Use this URL when developing locally +# VITE_API_BASE="https://$CODESPACE_NAME-3001.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/api" # for GitHub Codespaces +# VITE_API_BASE='/api' # Use this URL deploying on non-localhost address OR in docker. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..77e294d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,27 @@ +# 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? +bundleinspector.html +.env.production +flow-typed diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..b714151 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +v18.18.0 \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..913297c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,80 @@ +import js from "@eslint/js" +import globals from "globals" +import pluginReact from "eslint-plugin-react" +import pluginReactHooks from "eslint-plugin-react-hooks" +import pluginPrettier from "eslint-plugin-prettier" +import configPrettier from "eslint-config-prettier" +import unusedImports from "eslint-plugin-unused-imports" + +export default [ + { + ignores: ["**/*.min.js", "src/media/**/*"] + }, + + // Base JS recommended rules + js.configs.recommended, + + // Your React/JSX files + { + files: ["src/**/*.{js,jsx,mjs,cjs}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + parserOptions: { + ecmaFeatures: { jsx: true } + }, + globals: globals.browser + }, + plugins: { + react: pluginReact, + "react-hooks": pluginReactHooks, + "unused-imports": unusedImports, + prettier: pluginPrettier + }, + settings: { + react: { version: "detect" } + }, + rules: { + // React recommended rules (inline, since we're not "extending" in flat config) + ...pluginReact.configs.flat.recommended.rules, + + // If you want hooks rules, add these (recommended) + ...pluginReactHooks.configs.recommended.rules, + + // Prettier: disable conflicting stylistic rules + optionally enforce formatting + ...configPrettier.rules, + "prettier/prettier": "error", + + // Your overrides + "react/react-in-jsx-scope": "off", + "react-hooks/exhaustive-deps": "off", + "react/prop-types": "off", + "react-hooks/set-state-in-effect": "off", + "react/jsx-no-target-blank": "error", + "react/no-unescaped-entities": "off", + "react/display-name": "off", + "react-hooks/immutability": "off", + "react-hooks/preserve-manual-memoization": "off", + "no-extra-boolean-cast": "off", + "no-prototype-builtins": "off", + "no-empty": "off", + "no-useless-escape": "off", + "no-undef": "error", + "no-unsafe-optional-chaining": "off", + "no-constant-binary-expression": "off", + + // Unused cleanup + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_" + } + ] + } + } +] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6eb5b43 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,43 @@ + + + + + + + + AnythingLLM | Your personal LLM trained on anything + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..4cc5167 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "esnext", + "jsx": "react", + "paths": { + "@/*": [ + "./src/*" + ] + } + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..dec636b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,84 @@ +{ + "name": "anything-llm-frontend", + "private": false, + "license": "MIT", + "type": "module", + "scripts": { + "start": "vite --open", + "dev": "cross-env NODE_ENV=development vite --debug --host=0.0.0.0", + "build": "vite build && node scripts/postbuild.js", + "lint:check": "eslint src", + "lint": "eslint --fix src", + "preview": "vite preview" + }, + "dependencies": { + "@lobehub/icons": "^4.0.3", + "@microsoft/fetch-event-source": "^2.0.1", + "@mintplex-labs/piper-tts-web": "^1.0.4", + "@phosphor-icons/react": "^2.1.7", + "@tremor/react": "^3.15.1", + "cronstrue": "^2.50.0", + "dompurify": "^3.0.8", + "file-saver": "^2.0.5", + "he": "^1.2.0", + "highlight.js": "^11.9.0", + "i18next": "^23.11.3", + "i18next-browser-languagedetector": "^7.2.1", + "js-levenshtein": "^1.1.6", + "katex": "0.16.11", + "lodash.debounce": "^4.0.8", + "markdown-it": "^13.0.1", + "moment": "^2.30.1", + "onnxruntime-web": "^1.18.0", + "pluralize": "^8.0.0", + "qrcode.react": "^4.2.0", + "react": "^18.2.0", + "react-beautiful-dnd": "13.1.1", + "react-confetti-explosion": "^2.1.2", + "react-device-detect": "^2.2.2", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-error-boundary": "^6.0.0", + "react-highlight-words": "^0.21.0", + "react-i18next": "^14.1.1", + "react-loading-skeleton": "^3.1.0", + "react-router-dom": "^6.3.0", + "react-speech-recognition": "^3.10.0", + "react-tag-input-component": "^2.0.2", + "react-toastify": "^9.1.3", + "react-tooltip": "^5.25.2", + "recharts": "^2.12.5", + "recharts-to-png": "^2.3.1", + "text-case": "^1.0.9", + "truncate": "^3.0.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@esbuild-plugins/node-globals-polyfill": "^0.1.1", + "@eslint/js": "^9.39.2", + "@types/react": "^18.2.23", + "@types/react-dom": "^18.2.8", + "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react": "^4.0.0-beta.0", + "autoprefixer": "^10.4.14", + "buffer": "^6.0.3", + "cross-env": "^7.0.3", + "eslint": "^9.39.2", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-ft-flow": "^3.0.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.3", + "eslint-plugin-unused-imports": "^4.3.0", + "flow-bin": "^0.217.0", + "flow-remove-types": "^2.217.1", + "globals": "^16.5.0", + "hermes-eslint": "^0.15.0", + "postcss": "^8.4.23", + "prettier": "^3.0.3", + "rollup-plugin-visualizer": "^5.9.0", + "tailwindcss": "^3.3.1", + "vite": "^4.3.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..869bdc1 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +import tailwind from 'tailwindcss' +import autoprefixer from 'autoprefixer' +import tailwindConfig from './tailwind.config.js' + +export default { + plugins: [tailwind(tailwindConfig), autoprefixer], +} \ No newline at end of file diff --git a/frontend/public/anything-llm-dark.png b/frontend/public/anything-llm-dark.png new file mode 100644 index 0000000..ec14d17 Binary files /dev/null and b/frontend/public/anything-llm-dark.png differ diff --git a/frontend/public/anything-llm-light.png b/frontend/public/anything-llm-light.png new file mode 100644 index 0000000..96a079c Binary files /dev/null and b/frontend/public/anything-llm-light.png differ diff --git a/frontend/public/embed/anythingllm-chat-widget.min.css b/frontend/public/embed/anythingllm-chat-widget.min.css new file mode 100644 index 0000000..fc068d5 --- /dev/null +++ b/frontend/public/embed/anythingllm-chat-widget.min.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.allm-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.allm-fixed{position:fixed}.allm-absolute{position:absolute}.allm-relative{position:relative}.allm-sticky{position:sticky}.allm-inset-0{top:0;right:0;bottom:0;left:0}.allm-bottom-0{bottom:0}.allm-bottom-\[10rem\]{bottom:10rem}.allm-left-0{left:0}.allm-right-0{right:0}.allm-right-\[46px\]{right:46px}.allm-right-\[50px\]{right:50px}.allm-top-0{top:0}.allm-top-\[64px\]{top:64px}.allm-z-10{z-index:10}.allm-z-50{z-index:50}.allm-m-0{margin:0}.allm-mx-2{margin-left:.5rem;margin-right:.5rem}.allm-mx-4{margin-left:1rem;margin-right:1rem}.allm-mx-\[20px\]{margin-left:20px;margin-right:20px}.allm-my-1{margin-top:.25rem;margin-bottom:.25rem}.allm-my-3{margin-top:.75rem;margin-bottom:.75rem}.-allm-mt-10{margin-top:-2.5rem}.allm-mb-1{margin-bottom:.25rem}.allm-mb-2{margin-bottom:.5rem}.allm-mb-3{margin-bottom:.75rem}.allm-mb-4{margin-bottom:1rem}.allm-mb-8{margin-bottom:2rem}.allm-ml-2{margin-left:.5rem}.allm-ml-4{margin-left:1rem}.allm-ml-\[54px\]{margin-left:54px}.allm-ml-\[9px\]{margin-left:9px}.allm-mr-4{margin-right:1rem}.allm-mr-6{margin-right:1.5rem}.allm-mr-\[37px\]{margin-right:37px}.allm-mt-2{margin-top:.5rem}.allm-mt-3{margin-top:.75rem}.allm-mt-4{margin-top:1rem}.allm-mt-auto{margin-top:auto}.allm-box-border{box-sizing:border-box}.allm-block{display:block}.allm-inline-block{display:inline-block}.allm-flex{display:flex}.allm-inline-flex{display:inline-flex}.allm-hidden{display:none}.allm-h-3{height:.75rem}.allm-h-4{height:1rem}.allm-h-8{height:2rem}.allm-h-9{height:2.25rem}.allm-h-\[22px\]{height:22px}.allm-h-\[76px\]{height:76px}.allm-h-fit{height:-moz-fit-content;height:fit-content}.allm-h-full{height:100%}.allm-max-h-\[100px\]{max-height:100px}.allm-max-h-\[82vh\]{max-height:82vh}.allm-min-h-0{min-height:0}.allm-w-3{width:.75rem}.allm-w-4{width:1rem}.allm-w-8{width:2rem}.allm-w-9{width:2.25rem}.allm-w-\[75\%\]{width:75%}.allm-w-full{width:100%}.allm-min-w-\[52px\]{min-width:52px}.allm-max-w-full{max-width:100%}.allm-flex-1{flex:1 1 0%}.allm-flex-shrink-0{flex-shrink:0}.allm-flex-grow{flex-grow:1}.allm-rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes allm-pulse{50%{opacity:.5}}.allm-animate-pulse{animation:allm-pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes allm-spin{to{transform:rotate(360deg)}}.allm-animate-spin{animation:allm-spin 1s linear infinite}.allm-cursor-pointer{cursor:pointer}.allm-cursor-text{cursor:text}.allm-resize-none{resize:none}.allm-flex-col{flex-direction:column}.allm-items-start{align-items:flex-start}.allm-items-center{align-items:center}.allm-justify-start{justify-content:flex-start}.allm-justify-end{justify-content:flex-end}.allm-justify-center{justify-content:center}.allm-justify-between{justify-content:space-between}.allm-gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.allm-gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.allm-gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.allm-gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.allm-gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.allm-gap-x-\[12px\]{-moz-column-gap:12px;column-gap:12px}.allm-gap-y-1{row-gap:.25rem}.allm-gap-y-2{row-gap:.5rem}.allm-gap-y-4{row-gap:1rem}.allm-overflow-hidden{overflow:hidden}.allm-overflow-x-auto{overflow-x:auto}.allm-overflow-y-auto{overflow-y:auto}.allm-overflow-y-scroll{overflow-y:scroll}.allm-whitespace-pre-line{white-space:pre-line}.allm-whitespace-pre-wrap{white-space:pre-wrap}.allm-break-words{overflow-wrap:break-word}.allm-rounded{border-radius:.25rem}.allm-rounded-2xl{border-radius:1rem}.allm-rounded-full{border-radius:9999px}.allm-rounded-lg{border-radius:.5rem}.allm-rounded-sm{border-radius:.125rem}.allm-rounded-xl{border-radius:.75rem}.allm-rounded-t-2xl{border-top-left-radius:1rem;border-top-right-radius:1rem}.allm-rounded-t-\[18px\]{border-top-left-radius:18px;border-top-right-radius:18px}.allm-rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.allm-rounded-bl-\[18px\]{border-bottom-left-radius:18px}.allm-rounded-bl-\[4px\]{border-bottom-left-radius:4px}.allm-rounded-br-\[18px\]{border-bottom-right-radius:18px}.allm-rounded-br-\[4px\]{border-bottom-right-radius:4px}.allm-border{border-width:1px}.allm-border-l-2{border-left-width:2px}.allm-border-none{border-style:none}.allm-border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.allm-border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219 / var(--tw-border-opacity))}.allm-border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68 / var(--tw-border-opacity))}.allm-border-white\/10{border-color:#ffffff1a}.allm-bg-\[\#1e1e1e\]{--tw-bg-opacity:1;background-color:rgb(30 30 30 / var(--tw-bg-opacity))}.allm-bg-\[\#2d2d2d\]{--tw-bg-opacity:1;background-color:rgb(45 45 45 / var(--tw-bg-opacity))}.allm-bg-\[\#ffffff14\]{background-color:#ffffff14}.allm-bg-black\/20{background-color:#0003}.allm-bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.allm-bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202 / var(--tw-bg-opacity))}.allm-bg-red-300{--tw-bg-opacity:1;background-color:rgb(252 165 165 / var(--tw-bg-opacity))}.allm-bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242 / var(--tw-bg-opacity))}.allm-bg-transparent{background-color:transparent}.allm-bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.allm-p-2{padding:.5rem}.allm-p-4{padding:1rem}.allm-px-0{padding-left:0;padding-right:0}.allm-px-1{padding-left:.25rem;padding-right:.25rem}.allm-px-1\.5{padding-left:.375rem;padding-right:.375rem}.allm-px-2{padding-left:.5rem;padding-right:.5rem}.allm-px-4{padding-left:1rem;padding-right:1rem}.allm-px-\[22px\]{padding-left:22px;padding-right:22px}.allm-py-1{padding-top:.25rem;padding-bottom:.25rem}.allm-py-2{padding-top:.5rem;padding-bottom:.5rem}.allm-py-4{padding-top:1rem;padding-bottom:1rem}.allm-py-\[11px\]{padding-top:11px;padding-bottom:11px}.allm-py-\[5px\]{padding-top:5px;padding-bottom:5px}.allm-pb-2{padding-bottom:.5rem}.allm-pb-4{padding-bottom:1rem}.allm-pb-8{padding-bottom:2rem}.allm-pb-\[100px\]{padding-bottom:100px}.allm-pl-0{padding-left:0}.allm-pl-2{padding-left:.5rem}.allm-pt-4{padding-top:1rem}.allm-pt-\[5px\]{padding-top:5px}.allm-text-left{text-align:left}.allm-text-center{text-align:center}.allm-text-right{text-align:right}.allm-font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.allm-font-sans{font-family:plus-jakarta-sans,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.\!allm-text-xs{font-size:.75rem!important;line-height:1rem!important}.allm-text-2xl{font-size:1.5rem;line-height:2rem}.allm-text-\[10px\]{font-size:10px}.allm-text-\[14px\]{font-size:14px}.allm-text-base{font-size:1rem;line-height:1.5rem}.allm-text-sm{font-size:.875rem;line-height:1.25rem}.allm-text-xs{font-size:.75rem;line-height:1rem}.allm-font-bold{font-weight:700}.allm-font-medium{font-weight:500}.allm-font-normal{font-weight:400}.allm-leading-5{line-height:1.25rem}.allm-leading-\[20px\]{line-height:20px}.allm-text-\[\#22262899\]\/60{color:#22262899}.allm-text-\[\#222628\]{--tw-text-opacity:1;color:rgb(34 38 40 / var(--tw-text-opacity))}.allm-text-\[\#7A7D7E\]{--tw-text-opacity:1;color:rgb(122 125 126 / var(--tw-text-opacity))}.allm-text-\[\#858585\]{--tw-text-opacity:1;color:rgb(133 133 133 / var(--tw-text-opacity))}.allm-text-\[\#d4d4d4\]{--tw-text-opacity:1;color:rgb(212 212 212 / var(--tw-text-opacity))}.allm-text-black{--tw-text-opacity:1;color:rgb(0 0 0 / var(--tw-text-opacity))}.allm-text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity))}.allm-text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.allm-text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.allm-text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99 / var(--tw-text-opacity))}.allm-text-green-500{--tw-text-opacity:1;color:rgb(34 197 94 / var(--tw-text-opacity))}.allm-text-red-500{--tw-text-opacity:1;color:rgb(239 68 68 / var(--tw-text-opacity))}.allm-text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184 / var(--tw-text-opacity))}.allm-text-slate-800\/60{color:#1e293b99}.allm-text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.allm-text-white\/50{color:#ffffff80}.allm-text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216 / var(--tw-text-opacity))}.allm-no-underline{text-decoration-line:none}.allm-shadow-\[0_4px_14px_rgba\(0\,0\,0\,0\.25\)\]{--tw-shadow:0 4px 14px rgba(0,0,0,.25);--tw-shadow-colored:0 4px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.allm-shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.allm-transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.allm-transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.msg-suggestion{animation-name:fadeIn;animation-duration:.3s;animation-timing-function:linear;animation-fill-mode:forwards}@keyframes fadeIn{0%{opacity:0%}25%{opacity:25%}50%{opacity:50%}75%{opacity:75%}to{opacity:100%}}.placeholder\:allm-text-slate-800\/60::-moz-placeholder{color:#1e293b99}.placeholder\:allm-text-slate-800\/60::placeholder{color:#1e293b99}.hover\:allm-cursor-pointer:hover{cursor:pointer}.hover\:allm-bg-\[\#ffffff20\]:hover{background-color:#ffffff20}.hover\:allm-bg-black\/50:hover{background-color:#00000080}.hover\:allm-bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.hover\:allm-text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.hover\:allm-text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.hover\:allm-underline:hover{text-decoration-line:underline}.hover\:allm-opacity-80:hover{opacity:.8}.hover\:allm-opacity-95:hover{opacity:.95}.hover\:allm-shadow-\[0_4px_14px_rgba\(0\,0\,0\,0\.5\)\]:hover{--tw-shadow:0 4px 14px rgba(0,0,0,.5);--tw-shadow-colored:0 4px 14px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:allm-outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:allm-outline-none:active{outline:2px solid transparent;outline-offset:2px}.allm-group:hover .group-hover\:allm-text-\[\#22262899\]\/90{color:#222628e6} \ No newline at end of file diff --git a/frontend/public/embed/anythingllm-chat-widget.min.js b/frontend/public/embed/anythingllm-chat-widget.min.js new file mode 100644 index 0000000..0f8a9dd --- /dev/null +++ b/frontend/public/embed/anythingllm-chat-widget.min.js @@ -0,0 +1,37 @@ +!function(_t,xe){"object"==typeof exports&&typeof module<"u"?xe(exports):"function"==typeof define&&define.amd?define(["exports"],xe):xe((_t=typeof globalThis<"u"?globalThis:_t||self).EmbeddedAnythingLLM={})}(this,(function(_t){"use strict";var Hg,Vg,aN=Object.defineProperty,r1=(_t,xe,Ht)=>(((_t,xe,Ht)=>{xe in _t?aN(_t,xe,{enumerable:!0,configurable:!0,writable:!0,value:Ht}):_t[xe]=Ht})(_t,"symbol"!=typeof xe?xe+"":xe,Ht),Ht),xe=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Ht(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var Sc={exports:{}},ia={},Nc={exports:{}},te={},Yr=Symbol.for("react.element"),a1=Symbol.for("react.portal"),s1=Symbol.for("react.fragment"),i1=Symbol.for("react.strict_mode"),l1=Symbol.for("react.profiler"),u1=Symbol.for("react.provider"),c1=Symbol.for("react.context"),d1=Symbol.for("react.forward_ref"),p1=Symbol.for("react.suspense"),f1=Symbol.for("react.memo"),m1=Symbol.for("react.lazy"),Tc=Symbol.iterator; +/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var wc={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},xc=Object.assign,Rc={};function ar(t,e,n){this.props=t,this.context=e,this.refs=Rc,this.updater=n||wc}function Lc(){}function li(t,e,n){this.props=t,this.context=e,this.refs=Rc,this.updater=n||wc}ar.prototype.isReactComponent={},ar.prototype.setState=function(t,e){if("object"!=typeof t&&"function"!=typeof t&&null!=t)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,t,e,"setState")},ar.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")},Lc.prototype=ar.prototype;var ui=li.prototype=new Lc;ui.constructor=li,xc(ui,ar.prototype),ui.isPureReactComponent=!0;var Oc=Array.isArray,kc=Object.prototype.hasOwnProperty,ci={current:null},Ic={key:!0,ref:!0,__self:!0,__source:!0};function Mc(t,e,n){var r,o={},a=null,s=null;if(null!=e)for(r in void 0!==e.ref&&(s=e.ref),void 0!==e.key&&(a=""+e.key),e)kc.call(e,r)&&!Ic.hasOwnProperty(r)&&(o[r]=e[r]);var i=arguments.length-2;if(1===i)o.children=n;else if(1>>1,$=S[k];if(!(0>>1;ko(pe,L))ue<$&&0>o(ce,pe)?(S[k]=ce,S[ue]=L,k=ue):(S[k]=pe,S[ge]=L,k=ge);else{if(!(ue<$&&0>o(ce,L)))break e;S[k]=ce,S[ue]=L,k=ue}}}return C}function o(S,C){var L=S.sortIndex-C.sortIndex;return 0!==L?L:S.id-C.id}if("object"==typeof performance&&"function"==typeof performance.now){var a=performance;t.unstable_now=function(){return a.now()}}else{var s=Date,i=s.now();t.unstable_now=function(){return s.now()-i}}var l=[],u=[],c=1,p=null,d=3,f=!1,b=!1,A=!1,y="function"==typeof setTimeout?setTimeout:null,h="function"==typeof clearTimeout?clearTimeout:null,g=typeof setImmediate<"u"?setImmediate:null;function E(S){for(var C=n(u);null!==C;){if(null===C.callback)r(u);else{if(!(C.startTime<=S))break;r(u),C.sortIndex=C.expirationTime,e(l,C)}C=n(u)}}function _(S){if(A=!1,E(S),!b)if(null!==n(l))b=!0,B(N);else{var C=n(u);null!==C&&x(_,C.startTime-S)}}function N(S,C){b=!1,A&&(A=!1,h(R),R=-1),f=!0;var L=d;try{for(E(C),p=n(l);null!==p&&(!(p.expirationTime>C)||S&&!W());){var k=p.callback;if("function"==typeof k){p.callback=null,d=p.priorityLevel;var $=k(p.expirationTime<=C);C=t.unstable_now(),"function"==typeof $?p.callback=$:p===n(l)&&r(l),E(C)}else r(l);p=n(l)}if(null!==p)var j=!0;else{var ge=n(u);null!==ge&&x(_,ge.startTime-C),j=!1}return j}finally{p=null,d=L,f=!1}}typeof navigator<"u"&&void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var K,v=!1,T=null,R=-1,H=5,I=-1;function W(){return!(t.unstable_now()-IS||125k?(S.sortIndex=L,e(u,S),null===n(l)&&S===n(u)&&(A?(h(R),R=-1):A=!0,x(_,L-k))):(S.sortIndex=$,e(l,S),b||f||(b=!0,B(N))),S},t.unstable_shouldYield=W,t.unstable_wrapCallback=function(S){var C=d;return function(){var L=d;d=C;try{return S.apply(this,arguments)}finally{d=L}}}})(qc),Uc.exports=qc;var N1=Uc.exports,Hc=U,dt=N1; +/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */function M(t){for(var e="https://reactjs.org/docs/error-decoder.html?invariant="+t,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),mi=Object.prototype.hasOwnProperty,T1=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,$c={},zc={};function Qe(t,e,n,r,o,a,s){this.acceptsBooleans=2===e||3===e||4===e,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=t,this.type=e,this.sanitizeURL=a,this.removeEmptyString=s}var Ue={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach((function(t){Ue[t]=new Qe(t,0,!1,t,null,!1,!1)})),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach((function(t){var e=t[0];Ue[e]=new Qe(e,1,!1,t[1],null,!1,!1)})),["contentEditable","draggable","spellCheck","value"].forEach((function(t){Ue[t]=new Qe(t,2,!1,t.toLowerCase(),null,!1,!1)})),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach((function(t){Ue[t]=new Qe(t,2,!1,t,null,!1,!1)})),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach((function(t){Ue[t]=new Qe(t,3,!1,t.toLowerCase(),null,!1,!1)})),["checked","multiple","muted","selected"].forEach((function(t){Ue[t]=new Qe(t,3,!0,t,null,!1,!1)})),["capture","download"].forEach((function(t){Ue[t]=new Qe(t,4,!1,t,null,!1,!1)})),["cols","rows","size","span"].forEach((function(t){Ue[t]=new Qe(t,6,!1,t,null,!1,!1)})),["rowSpan","start"].forEach((function(t){Ue[t]=new Qe(t,5,!1,t.toLowerCase(),null,!1,!1)}));var gi=/[\-:]([a-z])/g;function hi(t){return t[1].toUpperCase()}function Ei(t,e,n,r){var o=Ue.hasOwnProperty(e)?Ue[e]:null;(null!==o?0!==o.type:r||!(2"u"||function(t,e,n,r){if(null!==n&&0===n.type)return!1;switch(typeof e){case"function":case"symbol":return!0;case"boolean":return!r&&(null!==n?!n.acceptsBooleans:"data-"!==(t=t.toLowerCase().slice(0,5))&&"aria-"!==t);default:return!1}}(t,e,n,r))return!0;if(r)return!1;if(null!==n)switch(n.type){case 3:return!e;case 4:return!1===e;case 5:return isNaN(e);case 6:return isNaN(e)||1>e}return!1}(e,n,o,r)&&(n=null),r||null===o?function(t){return!!mi.call(zc,t)||!mi.call($c,t)&&(T1.test(t)?zc[t]=!0:($c[t]=!0,!1))}(e)&&(null===n?t.removeAttribute(e):t.setAttribute(e,""+n)):o.mustUseProperty?t[o.propertyName]=null===n?3!==o.type&&"":n:(e=o.attributeName,r=o.attributeNamespace,null===n?t.removeAttribute(e):(n=3===(o=o.type)||4===o&&!0===n?"":""+n,r?t.setAttributeNS(r,e,n):t.setAttribute(e,n))))}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach((function(t){var e=t.replace(gi,hi);Ue[e]=new Qe(e,1,!1,t,null,!1,!1)})),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach((function(t){var e=t.replace(gi,hi);Ue[e]=new Qe(e,1,!1,t,"http://www.w3.org/1999/xlink",!1,!1)})),["xml:base","xml:lang","xml:space"].forEach((function(t){var e=t.replace(gi,hi);Ue[e]=new Qe(e,1,!1,t,"http://www.w3.org/XML/1998/namespace",!1,!1)})),["tabIndex","crossOrigin"].forEach((function(t){Ue[t]=new Qe(t,1,!1,t.toLowerCase(),null,!1,!1)})),Ue.xlinkHref=new Qe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach((function(t){Ue[t]=new Qe(t,1,!1,t.toLowerCase(),null,!0,!0)}));var en=Hc.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,da=Symbol.for("react.element"),ir=Symbol.for("react.portal"),lr=Symbol.for("react.fragment"),bi=Symbol.for("react.strict_mode"),Ai=Symbol.for("react.profiler"),Gc=Symbol.for("react.provider"),Zc=Symbol.for("react.context"),_i=Symbol.for("react.forward_ref"),vi=Symbol.for("react.suspense"),yi=Symbol.for("react.suspense_list"),Di=Symbol.for("react.memo"),pn=Symbol.for("react.lazy"),jc=Symbol.for("react.offscreen"),Wc=Symbol.iterator;function Qr(t){return null===t||"object"!=typeof t?null:"function"==typeof(t=Wc&&t[Wc]||t["@@iterator"])?t:null}var Ci,ve=Object.assign;function Jr(t){if(void 0===Ci)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);Ci=e&&e[1]||""}return"\n"+Ci+t}var Si=!1;function Ni(t,e){if(!t||Si)return"";Si=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(e)if(e=function(){throw Error()},Object.defineProperty(e.prototype,"props",{set:function(){throw Error()}}),"object"==typeof Reflect&&Reflect.construct){try{Reflect.construct(e,[])}catch(u){var r=u}Reflect.construct(t,[],e)}else{try{e.call()}catch(u){r=u}t.call(e.prototype)}else{try{throw Error()}catch(u){r=u}t()}}catch(u){if(u&&r&&"string"==typeof u.stack){for(var o=u.stack.split("\n"),a=r.stack.split("\n"),s=o.length-1,i=a.length-1;1<=s&&0<=i&&o[s]!==a[i];)i--;for(;1<=s&&0<=i;s--,i--)if(o[s]!==a[i]){if(1!==s||1!==i)do{if(s--,0>--i||o[s]!==a[i]){var l="\n"+o[s].replace(" at new "," at ");return t.displayName&&l.includes("")&&(l=l.replace("",t.displayName)),l}}while(1<=s&&0<=i);break}}}finally{Si=!1,Error.prepareStackTrace=n}return(t=t?t.displayName||t.name:"")?Jr(t):""}function L1(t){switch(t.tag){case 5:return Jr(t.type);case 16:return Jr("Lazy");case 13:return Jr("Suspense");case 19:return Jr("SuspenseList");case 0:case 2:case 15:return t=Ni(t.type,!1);case 11:return t=Ni(t.type.render,!1);case 1:return t=Ni(t.type,!0);default:return""}}function Ti(t){if(null==t)return null;if("function"==typeof t)return t.displayName||t.name||null;if("string"==typeof t)return t;switch(t){case lr:return"Fragment";case ir:return"Portal";case Ai:return"Profiler";case bi:return"StrictMode";case vi:return"Suspense";case yi:return"SuspenseList"}if("object"==typeof t)switch(t.$$typeof){case Zc:return(t.displayName||"Context")+".Consumer";case Gc:return(t._context.displayName||"Context")+".Provider";case _i:var e=t.render;return(t=t.displayName)||(t=""!==(t=e.displayName||e.name||"")?"ForwardRef("+t+")":"ForwardRef"),t;case Di:return null!==(e=t.displayName||null)?e:Ti(t.type)||"Memo";case pn:e=t._payload,t=t._init;try{return Ti(t(e))}catch{}}return null}function O1(t){var e=t.type;switch(t.tag){case 24:return"Cache";case 9:return(e.displayName||"Context")+".Consumer";case 10:return(e._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return t=(t=e.render).displayName||t.name||"",e.displayName||(""!==t?"ForwardRef("+t+")":"ForwardRef");case 7:return"Fragment";case 5:return e;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Ti(e);case 8:return e===bi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"==typeof e)return e.displayName||e.name||null;if("string"==typeof e)return e}return null}function fn(t){switch(typeof t){case"boolean":case"number":case"string":case"undefined":case"object":return t;default:return""}}function Kc(t){var e=t.type;return(t=t.nodeName)&&"input"===t.toLowerCase()&&("checkbox"===e||"radio"===e)}function pa(t){t._valueTracker||(t._valueTracker=function(t){var e=Kc(t)?"checked":"value",n=Object.getOwnPropertyDescriptor(t.constructor.prototype,e),r=""+t[e];if(!t.hasOwnProperty(e)&&typeof n<"u"&&"function"==typeof n.get&&"function"==typeof n.set){var o=n.get,a=n.set;return Object.defineProperty(t,e,{configurable:!0,get:function(){return o.call(this)},set:function(s){r=""+s,a.call(this,s)}}),Object.defineProperty(t,e,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){t._valueTracker=null,delete t[e]}}}}(t))}function Yc(t){if(!t)return!1;var e=t._valueTracker;if(!e)return!0;var n=e.getValue(),r="";return t&&(r=Kc(t)?t.checked?"true":"false":t.value),(t=r)!==n&&(e.setValue(t),!0)}function fa(t){if(typeof(t=t||(typeof document<"u"?document:void 0))>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}function wi(t,e){var n=e.checked;return ve({},e,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??t._wrapperState.initialChecked})}function Xc(t,e){var n=null==e.defaultValue?"":e.defaultValue,r=null!=e.checked?e.checked:e.defaultChecked;n=fn(null!=e.value?e.value:n),t._wrapperState={initialChecked:r,initialValue:n,controlled:"checkbox"===e.type||"radio"===e.type?null!=e.checked:null!=e.value}}function Qc(t,e){null!=(e=e.checked)&&Ei(t,"checked",e,!1)}function xi(t,e){Qc(t,e);var n=fn(e.value),r=e.type;if(null!=n)"number"===r?(0===n&&""===t.value||t.value!=n)&&(t.value=""+n):t.value!==""+n&&(t.value=""+n);else if("submit"===r||"reset"===r)return void t.removeAttribute("value");e.hasOwnProperty("value")?Ri(t,e.type,n):e.hasOwnProperty("defaultValue")&&Ri(t,e.type,fn(e.defaultValue)),null==e.checked&&null!=e.defaultChecked&&(t.defaultChecked=!!e.defaultChecked)}function Jc(t,e,n){if(e.hasOwnProperty("value")||e.hasOwnProperty("defaultValue")){var r=e.type;if(!("submit"!==r&&"reset"!==r||void 0!==e.value&&null!==e.value))return;e=""+t._wrapperState.initialValue,n||e===t.value||(t.value=e),t.defaultValue=e}""!==(n=t.name)&&(t.name=""),t.defaultChecked=!!t._wrapperState.initialChecked,""!==n&&(t.name=n)}function Ri(t,e,n){("number"!==e||fa(t.ownerDocument)!==t)&&(null==n?t.defaultValue=""+t._wrapperState.initialValue:t.defaultValue!==""+n&&(t.defaultValue=""+n))}var eo=Array.isArray;function ur(t,e,n,r){if(t=t.options,e){e={};for(var o=0;o"+e.valueOf().toString()+"",e=ma.firstChild;t.firstChild;)t.removeChild(t.firstChild);for(;e.firstChild;)t.appendChild(e.firstChild)}},typeof MSApp<"u"&&MSApp.execUnsafeLocalFunction?function(e,n,r,o){MSApp.execUnsafeLocalFunction((function(){return t(e,n)}))}:t);function to(t,e){if(e){var n=t.firstChild;if(n&&n===t.lastChild&&3===n.nodeType)return void(n.nodeValue=e)}t.textContent=e}var no={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},I1=["Webkit","ms","Moz","O"];function ad(t,e,n){return null==e||"boolean"==typeof e||""===e?"":n||"number"!=typeof e||0===e||no.hasOwnProperty(t)&&no[t]?(""+e).trim():e+"px"}function sd(t,e){for(var n in t=t.style,e)if(e.hasOwnProperty(n)){var r=0===n.indexOf("--"),o=ad(n,e[n],r);"float"===n&&(n="cssFloat"),r?t.setProperty(n,o):t[n]=o}}Object.keys(no).forEach((function(t){I1.forEach((function(e){e=e+t.charAt(0).toUpperCase()+t.substring(1),no[e]=no[t]}))}));var M1=ve({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ki(t,e){if(e){if(M1[t]&&(null!=e.children||null!=e.dangerouslySetInnerHTML))throw Error(M(137,t));if(null!=e.dangerouslySetInnerHTML){if(null!=e.children)throw Error(M(60));if("object"!=typeof e.dangerouslySetInnerHTML||!("__html"in e.dangerouslySetInnerHTML))throw Error(M(61))}if(null!=e.style&&"object"!=typeof e.style)throw Error(M(62))}}function Ii(t,e){if(-1===t.indexOf("-"))return"string"==typeof e.is;switch(t){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Mi=null;function Fi(t){return(t=t.target||t.srcElement||window).correspondingUseElement&&(t=t.correspondingUseElement),3===t.nodeType?t.parentNode:t}var Bi=null,cr=null,dr=null;function id(t){if(t=So(t)){if("function"!=typeof Bi)throw Error(M(280));var e=t.stateNode;e&&(e=Ba(e),Bi(t.stateNode,t.type,e))}}function ld(t){cr?dr?dr.push(t):dr=[t]:cr=t}function ud(){if(cr){var t=cr,e=dr;if(dr=cr=null,id(t),e)for(t=0;t>>=0,0===t?32:31-(Z1(t)/j1|0)|0},Z1=Math.log,j1=Math.LN2;var Aa=64,_a=4194304;function so(t){switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return 4194240&t;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return 130023424&t;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return t}}function va(t,e){var n=t.pendingLanes;if(0===n)return 0;var r=0,o=t.suspendedLanes,a=t.pingedLanes,s=268435455&n;if(0!==s){var i=s&~o;0!==i?r=so(i):0!==(a&=s)&&(r=so(a))}else 0!==(s=n&~o)?r=so(s):0!==a&&(r=so(a));if(0===r)return 0;if(0!==e&&e!==r&&!(e&o)&&((o=r&-r)>=(a=e&-e)||16===o&&0!=(4194240&a)))return e;if(4&r&&(r|=16&n),0!==(e=t.entangledLanes))for(t=t.entanglements,e&=r;0n;n++)e.push(t);return e}function io(t,e,n){t.pendingLanes|=e,536870912!==e&&(t.suspendedLanes=0,t.pingedLanes=0),(t=t.eventTimes)[e=31-Rt(e)]=n}function zi(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var r=31-Rt(n),o=1<=ho),Bd=" ",Pd=!1;function Ud(t,e){switch(t){case"keyup":return-1!==Sh.indexOf(e.keyCode);case"keydown":return 229!==e.keyCode;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function qd(t){return"object"==typeof(t=t.detail)&&"data"in t?t.data:null}var mr=!1;var xh={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Hd(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return"input"===e?!!xh[t.type]:"textarea"===e}function Vd(t,e,n,r){ld(r),0<(e=Ia(e,"onChange")).length&&(n=new Yi("onChange","change",null,n,r),t.push({event:n,listeners:e}))}var Eo=null,bo=null;function Rh(t){sp(t,0)}function Ra(t){if(Yc(Ar(t)))return t}function Lh(t,e){if("change"===t)return e}var $d=!1;if(Jt){var nl;if(Jt){var rl="oninput"in document;if(!rl){var zd=document.createElement("div");zd.setAttribute("oninput","return;"),rl="function"==typeof zd.oninput}nl=rl}else nl=!1;$d=nl&&(!document.documentMode||9=e)return{node:n,offset:e-t};t=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=jd(n)}}function Kd(t,e){return!(!t||!e)&&(t===e||(!t||3!==t.nodeType)&&(e&&3===e.nodeType?Kd(t,e.parentNode):"contains"in t?t.contains(e):!!t.compareDocumentPosition&&!!(16&t.compareDocumentPosition(e))))}function Yd(){for(var t=window,e=fa();e instanceof t.HTMLIFrameElement;){try{var n="string"==typeof e.contentWindow.location.href}catch{n=!1}if(!n)break;e=fa((t=e.contentWindow).document)}return e}function ol(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&("input"===e&&("text"===t.type||"search"===t.type||"tel"===t.type||"url"===t.type||"password"===t.type)||"textarea"===e||"true"===t.contentEditable)}function Bh(t){var e=Yd(),n=t.focusedElem,r=t.selectionRange;if(e!==n&&n&&n.ownerDocument&&Kd(n.ownerDocument.documentElement,n)){if(null!==r&&ol(n))if(e=r.start,void 0===(t=r.end)&&(t=e),"selectionStart"in n)n.selectionStart=e,n.selectionEnd=Math.min(t,n.value.length);else if((t=(e=n.ownerDocument||document)&&e.defaultView||window).getSelection){t=t.getSelection();var o=n.textContent.length,a=Math.min(r.start,o);r=void 0===r.end?a:Math.min(r.end,o),!t.extend&&a>r&&(o=r,r=a,a=o),o=Wd(n,a);var s=Wd(n,r);o&&s&&(1!==t.rangeCount||t.anchorNode!==o.node||t.anchorOffset!==o.offset||t.focusNode!==s.node||t.focusOffset!==s.offset)&&((e=e.createRange()).setStart(o.node,o.offset),t.removeAllRanges(),a>r?(t.addRange(e),t.extend(s.node,s.offset)):(e.setEnd(s.node,s.offset),t.addRange(e)))}for(e=[],t=n;t=t.parentNode;)1===t.nodeType&&e.push({element:t,left:t.scrollLeft,top:t.scrollTop});for("function"==typeof n.focus&&n.focus(),n=0;n=document.documentMode,gr=null,al=null,_o=null,sl=!1;function Xd(t,e,n){var r=n.window===n?n.document:9===n.nodeType?n:n.ownerDocument;sl||null==gr||gr!==fa(r)||("selectionStart"in(r=gr)&&ol(r)?r={start:r.selectionStart,end:r.selectionEnd}:r={anchorNode:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset},_o&&Ao(_o,r)||(_o=r,0<(r=Ia(al,"onSelect")).length&&(e=new Yi("onSelect","select",null,e,n),t.push({event:e,listeners:r}),e.target=gr)))}function La(t,e){var n={};return n[t.toLowerCase()]=e.toLowerCase(),n["Webkit"+t]="webkit"+e,n["Moz"+t]="moz"+e,n}var hr={animationend:La("Animation","AnimationEnd"),animationiteration:La("Animation","AnimationIteration"),animationstart:La("Animation","AnimationStart"),transitionend:La("Transition","TransitionEnd")},il={},Qd={};function Oa(t){if(il[t])return il[t];if(!hr[t])return t;var n,e=hr[t];for(n in e)if(e.hasOwnProperty(n)&&n in Qd)return il[t]=e[n];return t}Jt&&(Qd=document.createElement("div").style,"AnimationEvent"in window||(delete hr.animationend.animation,delete hr.animationiteration.animation,delete hr.animationstart.animation),"TransitionEvent"in window||delete hr.transitionend.transition);var Jd=Oa("animationend"),ep=Oa("animationiteration"),tp=Oa("animationstart"),np=Oa("transitionend"),rp=new Map,op="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");function An(t,e){rp.set(t,e),Mn(e,[t])}for(var ll=0;ll_r||(t.current=bl[_r],bl[_r]=null,_r--)}function me(t,e){_r++,bl[_r]=t.current,t.current=e}var yn={},$e=vn(yn),nt=vn(!1),Pn=yn;function vr(t,e){var n=t.type.contextTypes;if(!n)return yn;var r=t.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===e)return r.__reactInternalMemoizedMaskedChildContext;var a,o={};for(a in n)o[a]=e[a];return r&&((t=t.stateNode).__reactInternalMemoizedUnmaskedChildContext=e,t.__reactInternalMemoizedMaskedChildContext=o),o}function rt(t){return null!=(t=t.childContextTypes)}function Pa(){Ae(nt),Ae($e)}function pp(t,e,n){if($e.current!==yn)throw Error(M(168));me($e,e),me(nt,n)}function fp(t,e,n){var r=t.stateNode;if(e=e.childContextTypes,"function"!=typeof r.getChildContext)return n;for(var o in r=r.getChildContext())if(!(o in e))throw Error(M(108,O1(t)||"Unknown",o));return ve({},n,r)}function Ua(t){return t=(t=t.stateNode)&&t.__reactInternalMemoizedMergedChildContext||yn,Pn=$e.current,me($e,t),me(nt,nt.current),!0}function mp(t,e,n){var r=t.stateNode;if(!r)throw Error(M(169));n?(t=fp(t,e,Pn),r.__reactInternalMemoizedMergedChildContext=t,Ae(nt),Ae($e),me($e,t)):Ae(nt),me(nt,n)}var nn=null,qa=!1,Al=!1;function gp(t){null===nn?nn=[t]:nn.push(t)}function Dn(){if(!Al&&null!==nn){Al=!0;var t=0,e=de;try{var n=nn;for(de=1;t>=s,o-=s,rn=1<<32-Rt(e)+o|n<R?(H=T,T=null):H=T.sibling;var I=d(h,T,E[R],_);if(null===I){null===T&&(T=H);break}t&&T&&null===I.alternate&&e(h,T),g=a(I,g,R),null===v?N=I:v.sibling=I,v=I,T=H}if(R===E.length)return n(h,T),_e&&qn(h,R),N;if(null===T){for(;RR?(H=T,T=null):H=T.sibling;var W=d(h,T,I.value,_);if(null===W){null===T&&(T=H);break}t&&T&&null===W.alternate&&e(h,T),g=a(W,g,R),null===v?N=W:v.sibling=W,v=W,T=H}if(I.done)return n(h,T),_e&&qn(h,R),N;if(null===T){for(;!I.done;R++,I=E.next())null!==(I=p(h,I.value,_))&&(g=a(I,g,R),null===v?N=I:v.sibling=I,v=I);return _e&&qn(h,R),N}for(T=r(h,T);!I.done;R++,I=E.next())null!==(I=f(T,h,R,I.value,_))&&(t&&null!==I.alternate&&T.delete(null===I.key?R:I.key),g=a(I,g,R),null===v?N=I:v.sibling=I,v=I);return t&&T.forEach((function(le){return e(h,le)})),_e&&qn(h,R),N}(h,g,E,_);Ka(h,E)}return"string"==typeof E&&""!==E||"number"==typeof E?(E=""+E,null!==g&&6===g.tag?(n(h,g.sibling),(g=o(g,E)).return=h,h=g):(n(h,g),(g=gu(E,h.mode,_)).return=h,h=g),s(h)):n(h,g)}}var Tr=Rp(!0),Lp=Rp(!1),To={},zt=vn(To),wo=vn(To),xo=vn(To);function Vn(t){if(t===To)throw Error(M(174));return t}function kl(t,e){switch(me(xo,e),me(wo,t),me(zt,To),t=e.nodeType){case 9:case 11:e=(e=e.documentElement)?e.namespaceURI:Oi(null,"");break;default:e=Oi(e=(t=8===t?e.parentNode:e).namespaceURI||null,t=t.tagName)}Ae(zt),me(zt,e)}function wr(){Ae(zt),Ae(wo),Ae(xo)}function Op(t){Vn(xo.current);var e=Vn(zt.current),n=Oi(e,t.type);e!==n&&(me(wo,t),me(zt,n))}function Il(t){wo.current===t&&(Ae(zt),Ae(wo))}var ye=vn(0);function Ya(t){for(var e=t;null!==e;){if(13===e.tag){var n=e.memoizedState;if(null!==n&&(null===(n=n.dehydrated)||"$?"===n.data||"$!"===n.data))return e}else if(19===e.tag&&void 0!==e.memoizedProps.revealOrder){if(128&e.flags)return e}else if(null!==e.child){e.child.return=e,e=e.child;continue}if(e===t)break;for(;null===e.sibling;){if(null===e.return||e.return===t)return null;e=e.return}e.sibling.return=e.return,e=e.sibling}return null}var Ml=[];function Fl(){for(var t=0;tn?n:4,t(!0);var r=Bl.transition;Bl.transition={};try{t(!1),e()}finally{de=n,Bl.transition=r}}function Yp(){return Ct().memoizedState}function Jh(t,e,n){var r=xn(t);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Xp(t))Qp(e,n);else if(null!==(n=vp(t,e,n,r))){Ft(n,t,r,et()),Jp(n,e,r)}}function eE(t,e,n){var r=xn(t),o={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Xp(t))Qp(e,o);else{var a=t.alternate;if(0===t.lanes&&(null===a||0===a.lanes)&&null!==(a=e.lastRenderedReducer))try{var s=e.lastRenderedState,i=a(s,n);if(o.hasEagerState=!0,o.eagerState=i,Lt(i,s)){var l=e.interleaved;return null===l?(o.next=o,xl(e)):(o.next=l.next,l.next=o),void(e.interleaved=o)}}catch{}null!==(n=vp(t,e,o,r))&&(Ft(n,t,r,o=et()),Jp(n,e,r))}}function Xp(t){var e=t.alternate;return t===De||null!==e&&e===De}function Qp(t,e){Ro=Qa=!0;var n=t.pending;null===n?e.next=e:(e.next=n.next,n.next=e),t.pending=e}function Jp(t,e,n){if(4194240&n){var r=e.lanes;n|=r&=t.pendingLanes,e.lanes=n,zi(t,n)}}var ts={readContext:Dt,useCallback:ze,useContext:ze,useEffect:ze,useImperativeHandle:ze,useInsertionEffect:ze,useLayoutEffect:ze,useMemo:ze,useReducer:ze,useRef:ze,useState:ze,useDebugValue:ze,useDeferredValue:ze,useTransition:ze,useMutableSource:ze,useSyncExternalStore:ze,useId:ze,unstable_isNewReconciler:!1},tE={readContext:Dt,useCallback:function(t,e){return Gt().memoizedState=[t,void 0===e?null:e],t},useContext:Dt,useEffect:Vp,useImperativeHandle:function(t,e,n){return n=null!=n?n.concat([t]):null,Ja(4194308,4,Gp.bind(null,e,t),n)},useLayoutEffect:function(t,e){return Ja(4194308,4,t,e)},useInsertionEffect:function(t,e){return Ja(4,2,t,e)},useMemo:function(t,e){var n=Gt();return e=void 0===e?null:e,t=t(),n.memoizedState=[t,e],t},useReducer:function(t,e,n){var r=Gt();return e=void 0!==n?n(e):e,r.memoizedState=r.baseState=e,t={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:e},r.queue=t,t=t.dispatch=Jh.bind(null,De,t),[r.memoizedState,t]},useRef:function(t){return t={current:t},Gt().memoizedState=t},useState:qp,useDebugValue:zl,useDeferredValue:function(t){return Gt().memoizedState=t},useTransition:function(){var t=qp(!1),e=t[0];return t=Qh.bind(null,t[1]),Gt().memoizedState=t,[e,t]},useMutableSource:function(){},useSyncExternalStore:function(t,e,n){var r=De,o=Gt();if(_e){if(void 0===n)throw Error(M(407));n=n()}else{if(n=e(),null===Ie)throw Error(M(349));30&$n||Mp(r,e,n)}o.memoizedState=n;var a={value:n,getSnapshot:e};return o.queue=a,Vp(Bp.bind(null,r,a,t),[t]),r.flags|=2048,ko(9,Fp.bind(null,r,a,n,e),void 0,null),n},useId:function(){var t=Gt(),e=Ie.identifierPrefix;if(_e){var n=on;e=":"+e+"R"+(n=(rn&~(1<<32-Rt(rn)-1)).toString(32)+n),0<(n=Lo++)&&(e+="H"+n.toString(32)),e+=":"}else e=":"+e+"r"+(n=Xh++).toString(32)+":";return t.memoizedState=e},unstable_isNewReconciler:!1},nE={readContext:Dt,useCallback:jp,useContext:Dt,useEffect:$l,useImperativeHandle:Zp,useInsertionEffect:$p,useLayoutEffect:zp,useMemo:Wp,useReducer:Hl,useRef:Hp,useState:function(){return Hl(Oo)},useDebugValue:zl,useDeferredValue:function(t){return Kp(Ct(),Re.memoizedState,t)},useTransition:function(){return[Hl(Oo)[0],Ct().memoizedState]},useMutableSource:kp,useSyncExternalStore:Ip,useId:Yp,unstable_isNewReconciler:!1},rE={readContext:Dt,useCallback:jp,useContext:Dt,useEffect:$l,useImperativeHandle:Zp,useInsertionEffect:$p,useLayoutEffect:zp,useMemo:Wp,useReducer:Vl,useRef:Hp,useState:function(){return Vl(Oo)},useDebugValue:zl,useDeferredValue:function(t){var e=Ct();return null===Re?e.memoizedState=t:Kp(e,Re.memoizedState,t)},useTransition:function(){return[Vl(Oo)[0],Ct().memoizedState]},useMutableSource:kp,useSyncExternalStore:Ip,useId:Yp,unstable_isNewReconciler:!1};function xr(t,e){try{var n="",r=e;do{n+=L1(r),r=r.return}while(r);var o=n}catch(a){o="\nError generating stack: "+a.message+"\n"+a.stack}return{value:t,source:e,stack:o,digest:null}}function Gl(t,e,n){return{value:t,source:null,stack:n??null,digest:e??null}}function Zl(t,e){try{console.error(e.value)}catch(n){setTimeout((function(){throw n}))}}var oE="function"==typeof WeakMap?WeakMap:Map;function e0(t,e,n){(n=sn(-1,n)).tag=3,n.payload={element:null};var r=e.value;return n.callback=function(){ls||(ls=!0,iu=r),Zl(0,e)},n}function t0(t,e,n){(n=sn(-1,n)).tag=3;var r=t.type.getDerivedStateFromError;if("function"==typeof r){var o=e.value;n.payload=function(){return r(o)},n.callback=function(){Zl(0,e)}}var a=t.stateNode;return null!==a&&"function"==typeof a.componentDidCatch&&(n.callback=function(){Zl(0,e),"function"!=typeof r&&(null===Tn?Tn=new Set([this]):Tn.add(this));var s=e.stack;this.componentDidCatch(e.value,{componentStack:null!==s?s:""})}),n}function n0(t,e,n){var r=t.pingCache;if(null===r){r=t.pingCache=new oE;var o=new Set;r.set(e,o)}else void 0===(o=r.get(e))&&(o=new Set,r.set(e,o));o.has(n)||(o.add(n),t=bE.bind(null,t,e,n),e.then(t,t))}function r0(t){do{var e;if((e=13===t.tag)&&(e=null===(e=t.memoizedState)||null!==e.dehydrated),e)return t;t=t.return}while(null!==t);return null}function o0(t,e,n,r,o){return 1&t.mode?(t.flags|=65536,t.lanes=o,t):(t===e?t.flags|=65536:(t.flags|=128,n.flags|=131072,n.flags&=-52805,1===n.tag&&(null===n.alternate?n.tag=17:((e=sn(-1,1)).tag=2,Sn(n,e,1))),n.lanes|=1),t)}var aE=en.ReactCurrentOwner,ot=!1;function Je(t,e,n,r){e.child=null===t?Lp(e,null,n,r):Tr(e,t.child,n,r)}function a0(t,e,n,r,o){n=n.render;var a=e.ref;return Nr(e,o),r=Ul(t,e,n,r,a,o),n=ql(),null===t||ot?(_e&&n&&_l(e),e.flags|=1,Je(t,e,r,o),e.child):(e.updateQueue=t.updateQueue,e.flags&=-2053,t.lanes&=~o,ln(t,e,o))}function s0(t,e,n,r,o){if(null===t){var a=n.type;return"function"!=typeof a||mu(a)||void 0!==a.defaultProps||null!==n.compare||void 0!==n.defaultProps?((t=ms(n.type,null,r,e,e.mode,o)).ref=e.ref,t.return=e,e.child=t):(e.tag=15,e.type=a,i0(t,e,a,r,o))}if(a=t.child,!(t.lanes&o)){var s=a.memoizedProps;if((n=null!==(n=n.compare)?n:Ao)(s,r)&&t.ref===e.ref)return ln(t,e,o)}return e.flags|=1,(t=Ln(a,r)).ref=e.ref,t.return=e,e.child=t}function i0(t,e,n,r,o){if(null!==t){var a=t.memoizedProps;if(Ao(a,r)&&t.ref===e.ref){if(ot=!1,e.pendingProps=r=a,0==(t.lanes&o))return e.lanes=t.lanes,ln(t,e,o);131072&t.flags&&(ot=!0)}}return jl(t,e,n,r,o)}function l0(t,e,n){var r=e.pendingProps,o=r.children,a=null!==t?t.memoizedState:null;if("hidden"===r.mode)if(1&e.mode){if(!(1073741824&n))return t=null!==a?a.baseLanes|n:n,e.lanes=e.childLanes=1073741824,e.memoizedState={baseLanes:t,cachePool:null,transitions:null},e.updateQueue=null,me(Lr,gt),gt|=t,null;e.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=null!==a?a.baseLanes:n,me(Lr,gt),gt|=r}else e.memoizedState={baseLanes:0,cachePool:null,transitions:null},me(Lr,gt),gt|=n;else null!==a?(r=a.baseLanes|n,e.memoizedState=null):r=n,me(Lr,gt),gt|=r;return Je(t,e,o,n),e.child}function u0(t,e){var n=e.ref;(null===t&&null!==n||null!==t&&t.ref!==n)&&(e.flags|=512,e.flags|=2097152)}function jl(t,e,n,r,o){var a=rt(n)?Pn:$e.current;return a=vr(e,a),Nr(e,o),n=Ul(t,e,n,r,a,o),r=ql(),null===t||ot?(_e&&r&&_l(e),e.flags|=1,Je(t,e,n,o),e.child):(e.updateQueue=t.updateQueue,e.flags&=-2053,t.lanes&=~o,ln(t,e,o))}function c0(t,e,n,r,o){if(rt(n)){var a=!0;Ua(e)}else a=!1;if(Nr(e,o),null===e.stateNode)rs(t,e),Tp(e,n,r),Ol(e,n,r,o),r=!0;else if(null===t){var s=e.stateNode,i=e.memoizedProps;s.props=i;var l=s.context,u=n.contextType;"object"==typeof u&&null!==u?u=Dt(u):u=vr(e,u=rt(n)?Pn:$e.current);var c=n.getDerivedStateFromProps,p="function"==typeof c||"function"==typeof s.getSnapshotBeforeUpdate;p||"function"!=typeof s.UNSAFE_componentWillReceiveProps&&"function"!=typeof s.componentWillReceiveProps||(i!==r||l!==u)&&wp(e,s,r,u),Cn=!1;var d=e.memoizedState;s.state=d,ja(e,r,s,o),l=e.memoizedState,i!==r||d!==l||nt.current||Cn?("function"==typeof c&&(Ll(e,n,c,r),l=e.memoizedState),(i=Cn||Np(e,n,i,r,d,l,u))?(p||"function"!=typeof s.UNSAFE_componentWillMount&&"function"!=typeof s.componentWillMount||("function"==typeof s.componentWillMount&&s.componentWillMount(),"function"==typeof s.UNSAFE_componentWillMount&&s.UNSAFE_componentWillMount()),"function"==typeof s.componentDidMount&&(e.flags|=4194308)):("function"==typeof s.componentDidMount&&(e.flags|=4194308),e.memoizedProps=r,e.memoizedState=l),s.props=r,s.state=l,s.context=u,r=i):("function"==typeof s.componentDidMount&&(e.flags|=4194308),r=!1)}else{s=e.stateNode,yp(t,e),i=e.memoizedProps,u=e.type===e.elementType?i:kt(e.type,i),s.props=u,p=e.pendingProps,d=s.context,"object"==typeof(l=n.contextType)&&null!==l?l=Dt(l):l=vr(e,l=rt(n)?Pn:$e.current);var f=n.getDerivedStateFromProps;(c="function"==typeof f||"function"==typeof s.getSnapshotBeforeUpdate)||"function"!=typeof s.UNSAFE_componentWillReceiveProps&&"function"!=typeof s.componentWillReceiveProps||(i!==p||d!==l)&&wp(e,s,r,l),Cn=!1,d=e.memoizedState,s.state=d,ja(e,r,s,o);var b=e.memoizedState;i!==p||d!==b||nt.current||Cn?("function"==typeof f&&(Ll(e,n,f,r),b=e.memoizedState),(u=Cn||Np(e,n,u,r,d,b,l)||!1)?(c||"function"!=typeof s.UNSAFE_componentWillUpdate&&"function"!=typeof s.componentWillUpdate||("function"==typeof s.componentWillUpdate&&s.componentWillUpdate(r,b,l),"function"==typeof s.UNSAFE_componentWillUpdate&&s.UNSAFE_componentWillUpdate(r,b,l)),"function"==typeof s.componentDidUpdate&&(e.flags|=4),"function"==typeof s.getSnapshotBeforeUpdate&&(e.flags|=1024)):("function"!=typeof s.componentDidUpdate||i===t.memoizedProps&&d===t.memoizedState||(e.flags|=4),"function"!=typeof s.getSnapshotBeforeUpdate||i===t.memoizedProps&&d===t.memoizedState||(e.flags|=1024),e.memoizedProps=r,e.memoizedState=b),s.props=r,s.state=b,s.context=l,r=u):("function"!=typeof s.componentDidUpdate||i===t.memoizedProps&&d===t.memoizedState||(e.flags|=4),"function"!=typeof s.getSnapshotBeforeUpdate||i===t.memoizedProps&&d===t.memoizedState||(e.flags|=1024),r=!1)}return Wl(t,e,n,r,a,o)}function Wl(t,e,n,r,o,a){u0(t,e);var s=0!=(128&e.flags);if(!r&&!s)return o&&mp(e,n,!1),ln(t,e,a);r=e.stateNode,aE.current=e;var i=s&&"function"!=typeof n.getDerivedStateFromError?null:r.render();return e.flags|=1,null!==t&&s?(e.child=Tr(e,t.child,null,a),e.child=Tr(e,null,i,a)):Je(t,e,i,a),e.memoizedState=r.state,o&&mp(e,n,!0),e.child}function d0(t){var e=t.stateNode;e.pendingContext?pp(0,e.pendingContext,e.pendingContext!==e.context):e.context&&pp(0,e.context,!1),kl(t,e.containerInfo)}function p0(t,e,n,r,o){return Cr(),Cl(o),e.flags|=256,Je(t,e,n,r),e.child}var h0,Jl,E0,b0,Kl={dehydrated:null,treeContext:null,retryLane:0};function Yl(t){return{baseLanes:t,cachePool:null,transitions:null}}function f0(t,e,n){var i,r=e.pendingProps,o=ye.current,a=!1,s=0!=(128&e.flags);if((i=s)||(i=(null===t||null!==t.memoizedState)&&0!=(2&o)),i?(a=!0,e.flags&=-129):(null===t||null!==t.memoizedState)&&(o|=1),me(ye,1&o),null===t)return Dl(e),null!==(t=e.memoizedState)&&null!==(t=t.dehydrated)?(1&e.mode?"$!"===t.data?e.lanes=8:e.lanes=1073741824:e.lanes=1,null):(s=r.children,t=r.fallback,a?(r=e.mode,a=e.child,s={mode:"hidden",children:s},1&r||null===a?a=gs(s,r,0,null):(a.childLanes=0,a.pendingProps=s),t=Wn(t,r,n,null),a.return=e,t.return=e,a.sibling=t,e.child=a,e.child.memoizedState=Yl(n),e.memoizedState=Kl,t):Xl(e,s));if(null!==(o=t.memoizedState)&&null!==(i=o.dehydrated))return function(t,e,n,r,o,a,s){if(n)return 256&e.flags?(e.flags&=-257,r=Gl(Error(M(422))),ns(t,e,s,r)):null!==e.memoizedState?(e.child=t.child,e.flags|=128,null):(a=r.fallback,o=e.mode,r=gs({mode:"visible",children:r.children},o,0,null),a=Wn(a,o,s,null),a.flags|=2,r.return=e,a.return=e,r.sibling=a,e.child=r,1&e.mode&&Tr(e,t.child,null,s),e.child.memoizedState=Yl(s),e.memoizedState=Kl,a);if(!(1&e.mode))return ns(t,e,s,null);if("$!"===o.data){if(r=o.nextSibling&&o.nextSibling.dataset)var i=r.dgst;return r=i,ns(t,e,s,r=Gl(a=Error(M(419)),r,void 0))}if(i=0!=(s&t.childLanes),ot||i){if(null!==(r=Ie)){switch(s&-s){case 4:o=2;break;case 16:o=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:o=32;break;case 536870912:o=268435456;break;default:o=0}0!==(o=o&(r.suspendedLanes|s)?0:o)&&o!==a.retryLane&&(a.retryLane=o,an(t,o),Ft(r,t,o,-1))}return fu(),ns(t,e,s,r=Gl(Error(M(421))))}return"$?"===o.data?(e.flags|=128,e.child=t.child,e=AE.bind(null,t),o._reactRetry=e,null):(t=a.treeContext,mt=_n(o.nextSibling),ft=e,_e=!0,Ot=null,null!==t&&(vt[yt++]=rn,vt[yt++]=on,vt[yt++]=Un,rn=t.id,on=t.overflow,Un=e),e=Xl(e,r.children),e.flags|=4096,e)}(t,e,s,r,i,o,n);if(a){a=r.fallback,s=e.mode,i=(o=t.child).sibling;var l={mode:"hidden",children:r.children};return 1&s||e.child===o?(r=Ln(o,l)).subtreeFlags=14680064&o.subtreeFlags:((r=e.child).childLanes=0,r.pendingProps=l,e.deletions=null),null!==i?a=Ln(i,a):(a=Wn(a,s,n,null)).flags|=2,a.return=e,r.return=e,r.sibling=a,e.child=r,r=a,a=e.child,s=null===(s=t.child.memoizedState)?Yl(n):{baseLanes:s.baseLanes|n,cachePool:null,transitions:s.transitions},a.memoizedState=s,a.childLanes=t.childLanes&~n,e.memoizedState=Kl,r}return t=(a=t.child).sibling,r=Ln(a,{mode:"visible",children:r.children}),!(1&e.mode)&&(r.lanes=n),r.return=e,r.sibling=null,null!==t&&(null===(n=e.deletions)?(e.deletions=[t],e.flags|=16):n.push(t)),e.child=r,e.memoizedState=null,r}function Xl(t,e){return(e=gs({mode:"visible",children:e},t.mode,0,null)).return=t,t.child=e}function ns(t,e,n,r){return null!==r&&Cl(r),Tr(e,t.child,null,n),(t=Xl(e,e.pendingProps.children)).flags|=2,e.memoizedState=null,t}function m0(t,e,n){t.lanes|=e;var r=t.alternate;null!==r&&(r.lanes|=e),wl(t.return,e,n)}function Ql(t,e,n,r,o){var a=t.memoizedState;null===a?t.memoizedState={isBackwards:e,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:o}:(a.isBackwards=e,a.rendering=null,a.renderingStartTime=0,a.last=r,a.tail=n,a.tailMode=o)}function g0(t,e,n){var r=e.pendingProps,o=r.revealOrder,a=r.tail;if(Je(t,e,r.children,n),2&(r=ye.current))r=1&r|2,e.flags|=128;else{if(null!==t&&128&t.flags)e:for(t=e.child;null!==t;){if(13===t.tag)null!==t.memoizedState&&m0(t,n,e);else if(19===t.tag)m0(t,n,e);else if(null!==t.child){t.child.return=t,t=t.child;continue}if(t===e)break e;for(;null===t.sibling;){if(null===t.return||t.return===e)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}r&=1}if(me(ye,r),1&e.mode)switch(o){case"forwards":for(n=e.child,o=null;null!==n;)null!==(t=n.alternate)&&null===Ya(t)&&(o=n),n=n.sibling;null===(n=o)?(o=e.child,e.child=null):(o=n.sibling,n.sibling=null),Ql(e,!1,o,n,a);break;case"backwards":for(n=null,o=e.child,e.child=null;null!==o;){if(null!==(t=o.alternate)&&null===Ya(t)){e.child=o;break}t=o.sibling,o.sibling=n,n=o,o=t}Ql(e,!0,n,null,a);break;case"together":Ql(e,!1,null,null,void 0);break;default:e.memoizedState=null}else e.memoizedState=null;return e.child}function rs(t,e){!(1&e.mode)&&null!==t&&(t.alternate=null,e.alternate=null,e.flags|=2)}function ln(t,e,n){if(null!==t&&(e.dependencies=t.dependencies),zn|=e.lanes,!(n&e.childLanes))return null;if(null!==t&&e.child!==t.child)throw Error(M(153));if(null!==e.child){for(n=Ln(t=e.child,t.pendingProps),e.child=n,n.return=e;null!==t.sibling;)t=t.sibling,(n=n.sibling=Ln(t,t.pendingProps)).return=e;n.sibling=null}return e.child}function Io(t,e){if(!_e)switch(t.tailMode){case"hidden":e=t.tail;for(var n=null;null!==e;)null!==e.alternate&&(n=e),e=e.sibling;null===n?t.tail=null:n.sibling=null;break;case"collapsed":n=t.tail;for(var r=null;null!==n;)null!==n.alternate&&(r=n),n=n.sibling;null===r?e||null===t.tail?t.tail=null:t.tail.sibling=null:r.sibling=null}}function Ge(t){var e=null!==t.alternate&&t.alternate.child===t.child,n=0,r=0;if(e)for(var o=t.child;null!==o;)n|=o.lanes|o.childLanes,r|=14680064&o.subtreeFlags,r|=14680064&o.flags,o.return=t,o=o.sibling;else for(o=t.child;null!==o;)n|=o.lanes|o.childLanes,r|=o.subtreeFlags,r|=o.flags,o.return=t,o=o.sibling;return t.subtreeFlags|=r,t.childLanes=n,e}function lE(t,e,n){var r=e.pendingProps;switch(vl(e),e.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ge(e),null;case 1:case 17:return rt(e.type)&&Pa(),Ge(e),null;case 3:return r=e.stateNode,wr(),Ae(nt),Ae($e),Fl(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(null===t||null===t.child)&&($a(e)?e.flags|=4:null===t||t.memoizedState.isDehydrated&&!(256&e.flags)||(e.flags|=1024,null!==Ot&&(cu(Ot),Ot=null))),Jl(t,e),Ge(e),null;case 5:Il(e);var o=Vn(xo.current);if(n=e.type,null!==t&&null!=e.stateNode)E0(t,e,n,r,o),t.ref!==e.ref&&(e.flags|=512,e.flags|=2097152);else{if(!r){if(null===e.stateNode)throw Error(M(166));return Ge(e),null}if(t=Vn(zt.current),$a(e)){r=e.stateNode,n=e.type;var a=e.memoizedProps;switch(r[$t]=e,r[Co]=a,t=0!=(1&e.mode),n){case"dialog":be("cancel",r),be("close",r);break;case"iframe":case"object":case"embed":be("load",r);break;case"video":case"audio":for(o=0;o<\/script>",t=t.removeChild(t.firstChild)):"string"==typeof r.is?t=s.createElement(n,{is:r.is}):(t=s.createElement(n),"select"===n&&(s=t,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):t=s.createElementNS(t,n),t[$t]=e,t[Co]=r,h0(t,e,!1,!1),e.stateNode=t;e:{switch(s=Ii(n,r),n){case"dialog":be("cancel",t),be("close",t),o=r;break;case"iframe":case"object":case"embed":be("load",t),o=r;break;case"video":case"audio":for(o=0;oOr&&(e.flags|=128,r=!0,Io(a,!1),e.lanes=4194304)}else{if(!r)if(null!==(t=Ya(s))){if(e.flags|=128,r=!0,null!==(n=t.updateQueue)&&(e.updateQueue=n,e.flags|=4),Io(a,!0),null===a.tail&&"hidden"===a.tailMode&&!s.alternate&&!_e)return Ge(e),null}else 2*Te()-a.renderingStartTime>Or&&1073741824!==n&&(e.flags|=128,r=!0,Io(a,!1),e.lanes=4194304);a.isBackwards?(s.sibling=e.child,e.child=s):(null!==(n=a.last)?n.sibling=s:e.child=s,a.last=s)}return null!==a.tail?(e=a.tail,a.rendering=e,a.tail=e.sibling,a.renderingStartTime=Te(),e.sibling=null,n=ye.current,me(ye,r?1&n|2:1&n),e):(Ge(e),null);case 22:case 23:return pu(),r=null!==e.memoizedState,null!==t&&null!==t.memoizedState!==r&&(e.flags|=8192),r&&1&e.mode?1073741824>&&(Ge(e),6&e.subtreeFlags&&(e.flags|=8192)):Ge(e),null;case 24:case 25:return null}throw Error(M(156,e.tag))}function uE(t,e){switch(vl(e),e.tag){case 1:return rt(e.type)&&Pa(),65536&(t=e.flags)?(e.flags=-65537&t|128,e):null;case 3:return wr(),Ae(nt),Ae($e),Fl(),65536&(t=e.flags)&&!(128&t)?(e.flags=-65537&t|128,e):null;case 5:return Il(e),null;case 13:if(Ae(ye),null!==(t=e.memoizedState)&&null!==t.dehydrated){if(null===e.alternate)throw Error(M(340));Cr()}return 65536&(t=e.flags)?(e.flags=-65537&t|128,e):null;case 19:return Ae(ye),null;case 4:return wr(),null;case 10:return Tl(e.type._context),null;case 22:case 23:return pu(),null;default:return null}}h0=function(t,e){for(var n=e.child;null!==n;){if(5===n.tag||6===n.tag)t.appendChild(n.stateNode);else if(4!==n.tag&&null!==n.child){n.child.return=n,n=n.child;continue}if(n===e)break;for(;null===n.sibling;){if(null===n.return||n.return===e)return;n=n.return}n.sibling.return=n.return,n=n.sibling}},Jl=function(){},E0=function(t,e,n,r){var o=t.memoizedProps;if(o!==r){t=e.stateNode,Vn(zt.current);var s,a=null;switch(n){case"input":o=wi(t,o),r=wi(t,r),a=[];break;case"select":o=ve({},o,{value:void 0}),r=ve({},r,{value:void 0}),a=[];break;case"textarea":o=Li(t,o),r=Li(t,r),a=[];break;default:"function"!=typeof o.onClick&&"function"==typeof r.onClick&&(t.onclick=Fa)}for(u in ki(n,r),n=null,o)if(!r.hasOwnProperty(u)&&o.hasOwnProperty(u)&&null!=o[u])if("style"===u){var i=o[u];for(s in i)i.hasOwnProperty(s)&&(n||(n={}),n[s]="")}else"dangerouslySetInnerHTML"!==u&&"children"!==u&&"suppressContentEditableWarning"!==u&&"suppressHydrationWarning"!==u&&"autoFocus"!==u&&(Xr.hasOwnProperty(u)?a||(a=[]):(a=a||[]).push(u,null));for(u in r){var l=r[u];if(i=null!=o?o[u]:void 0,r.hasOwnProperty(u)&&l!==i&&(null!=l||null!=i))if("style"===u)if(i){for(s in i)!i.hasOwnProperty(s)||l&&l.hasOwnProperty(s)||(n||(n={}),n[s]="");for(s in l)l.hasOwnProperty(s)&&i[s]!==l[s]&&(n||(n={}),n[s]=l[s])}else n||(a||(a=[]),a.push(u,n)),n=l;else"dangerouslySetInnerHTML"===u?(l=l?l.__html:void 0,i=i?i.__html:void 0,null!=l&&i!==l&&(a=a||[]).push(u,l)):"children"===u?"string"!=typeof l&&"number"!=typeof l||(a=a||[]).push(u,""+l):"suppressContentEditableWarning"!==u&&"suppressHydrationWarning"!==u&&(Xr.hasOwnProperty(u)?(null!=l&&"onScroll"===u&&be("scroll",t),a||i===l||(a=[])):(a=a||[]).push(u,l))}n&&(a=a||[]).push("style",n);var u=a;(e.updateQueue=u)&&(e.flags|=4)}},b0=function(t,e,n,r){n!==r&&(e.flags|=4)};var os=!1,Ze=!1,cE="function"==typeof WeakSet?WeakSet:Set,q=null;function Rr(t,e){var n=t.ref;if(null!==n)if("function"==typeof n)try{n(null)}catch(r){Se(t,e,r)}else n.current=null}function eu(t,e,n){try{n()}catch(r){Se(t,e,r)}}var A0=!1;function Mo(t,e,n){var r=e.updateQueue;if(null!==(r=null!==r?r.lastEffect:null)){var o=r=r.next;do{if((o.tag&t)===t){var a=o.destroy;o.destroy=void 0,void 0!==a&&eu(e,n,a)}o=o.next}while(o!==r)}}function as(t,e){if(null!==(e=null!==(e=e.updateQueue)?e.lastEffect:null)){var n=e=e.next;do{if((n.tag&t)===t){var r=n.create;n.destroy=r()}n=n.next}while(n!==e)}}function tu(t){var e=t.ref;if(null!==e){var n=t.stateNode;t.tag,t=n,"function"==typeof e?e(t):e.current=t}}function _0(t){var e=t.alternate;null!==e&&(t.alternate=null,_0(e)),t.child=null,t.deletions=null,t.sibling=null,5===t.tag&&(null!==(e=t.stateNode)&&(delete e[$t],delete e[Co],delete e[El],delete e[jh],delete e[Wh])),t.stateNode=null,t.return=null,t.dependencies=null,t.memoizedProps=null,t.memoizedState=null,t.pendingProps=null,t.stateNode=null,t.updateQueue=null}function v0(t){return 5===t.tag||3===t.tag||4===t.tag}function y0(t){e:for(;;){for(;null===t.sibling;){if(null===t.return||v0(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;5!==t.tag&&6!==t.tag&&18!==t.tag;){if(2&t.flags||null===t.child||4===t.tag)continue e;t.child.return=t,t=t.child}if(!(2&t.flags))return t.stateNode}}function nu(t,e,n){var r=t.tag;if(5===r||6===r)t=t.stateNode,e?8===n.nodeType?n.parentNode.insertBefore(t,e):n.insertBefore(t,e):(8===n.nodeType?(e=n.parentNode).insertBefore(t,n):(e=n).appendChild(t),null!=(n=n._reactRootContainer)||null!==e.onclick||(e.onclick=Fa));else if(4!==r&&null!==(t=t.child))for(nu(t,e,n),t=t.sibling;null!==t;)nu(t,e,n),t=t.sibling}function ru(t,e,n){var r=t.tag;if(5===r||6===r)t=t.stateNode,e?n.insertBefore(t,e):n.appendChild(t);else if(4!==r&&null!==(t=t.child))for(ru(t,e,n),t=t.sibling;null!==t;)ru(t,e,n),t=t.sibling}var qe=null,It=!1;function Nn(t,e,n){for(n=n.child;null!==n;)D0(t,e,n),n=n.sibling}function D0(t,e,n){if(Vt&&"function"==typeof Vt.onCommitFiberUnmount)try{Vt.onCommitFiberUnmount(ba,n)}catch{}switch(n.tag){case 5:Ze||Rr(n,e);case 6:var r=qe,o=It;qe=null,Nn(t,e,n),It=o,null!==(qe=r)&&(It?(t=qe,n=n.stateNode,8===t.nodeType?t.parentNode.removeChild(n):t.removeChild(n)):qe.removeChild(n.stateNode));break;case 18:null!==qe&&(It?(t=qe,n=n.stateNode,8===t.nodeType?hl(t.parentNode,n):1===t.nodeType&&hl(t,n),fo(t)):hl(qe,n.stateNode));break;case 4:r=qe,o=It,qe=n.stateNode.containerInfo,It=!0,Nn(t,e,n),qe=r,It=o;break;case 0:case 11:case 14:case 15:if(!Ze&&(null!==(r=n.updateQueue)&&null!==(r=r.lastEffect))){o=r=r.next;do{var a=o,s=a.destroy;a=a.tag,void 0!==s&&(2&a||4&a)&&eu(n,e,s),o=o.next}while(o!==r)}Nn(t,e,n);break;case 1:if(!Ze&&(Rr(n,e),"function"==typeof(r=n.stateNode).componentWillUnmount))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){Se(n,e,i)}Nn(t,e,n);break;case 21:Nn(t,e,n);break;case 22:1&n.mode?(Ze=(r=Ze)||null!==n.memoizedState,Nn(t,e,n),Ze=r):Nn(t,e,n);break;default:Nn(t,e,n)}}function C0(t){var e=t.updateQueue;if(null!==e){t.updateQueue=null;var n=t.stateNode;null===n&&(n=t.stateNode=new cE),e.forEach((function(r){var o=_E.bind(null,t,r);n.has(r)||(n.add(r),r.then(o,o))}))}}function Mt(t,e){var n=e.deletions;if(null!==n)for(var r=0;ro&&(o=s),r&=~a}if(r=o,10<(r=(120>(r=Te()-r)?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*fE(r/1960))-r)){t.timeoutHandle=gl(jn.bind(null,t,at,un),r);break}jn(t,at,un);break;default:throw Error(M(329))}}}return st(t,Te()),t.callbackNode===n?R0.bind(null,t):null}function uu(t,e){var n=Bo;return t.current.memoizedState.isDehydrated&&(Zn(t,e).flags|=256),2!==(t=fs(t,e))&&(e=at,at=n,null!==e&&cu(e)),t}function cu(t){null===at?at=t:at.push.apply(at,t)}function Rn(t,e){for(e&=~au,e&=~is,t.suspendedLanes|=e,t.pingedLanes&=~e,t=t.expirationTimes;0t?16:t,null===wn)var r=!1;else{if(t=wn,wn=null,cs=0,6&oe)throw Error(M(331));var o=oe;for(oe|=4,q=t.current;null!==q;){var a=q,s=a.child;if(16&q.flags){var i=a.deletions;if(null!==i){for(var l=0;lTe()-su?Zn(t,0):au|=n),st(t,e)}function B0(t,e){0===e&&(1&t.mode?(e=_a,!(130023424&(_a<<=1))&&(_a=4194304)):e=1);var n=et();null!==(t=an(t,e))&&(io(t,e,n),st(t,n))}function AE(t){var e=t.memoizedState,n=0;null!==e&&(n=e.retryLane),B0(t,n)}function _E(t,e){var n=0;switch(t.tag){case 13:var r=t.stateNode,o=t.memoizedState;null!==o&&(n=o.retryLane);break;case 19:r=t.stateNode;break;default:throw Error(M(314))}null!==r&&r.delete(e),B0(t,n)}function U0(t,e){return Ed(t,e)}function vE(t,e,n,r){this.tag=t,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=e,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Nt(t,e,n,r){return new vE(t,e,n,r)}function mu(t){return!(!(t=t.prototype)||!t.isReactComponent)}function Ln(t,e){var n=t.alternate;return null===n?((n=Nt(t.tag,e,t.key,t.mode)).elementType=t.elementType,n.type=t.type,n.stateNode=t.stateNode,n.alternate=t,t.alternate=n):(n.pendingProps=e,n.type=t.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=14680064&t.flags,n.childLanes=t.childLanes,n.lanes=t.lanes,n.child=t.child,n.memoizedProps=t.memoizedProps,n.memoizedState=t.memoizedState,n.updateQueue=t.updateQueue,e=t.dependencies,n.dependencies=null===e?null:{lanes:e.lanes,firstContext:e.firstContext},n.sibling=t.sibling,n.index=t.index,n.ref=t.ref,n}function ms(t,e,n,r,o,a){var s=2;if(r=t,"function"==typeof t)mu(t)&&(s=1);else if("string"==typeof t)s=5;else e:switch(t){case lr:return Wn(n.children,o,a,e);case bi:s=8,o|=8;break;case Ai:return(t=Nt(12,n,e,2|o)).elementType=Ai,t.lanes=a,t;case vi:return(t=Nt(13,n,e,o)).elementType=vi,t.lanes=a,t;case yi:return(t=Nt(19,n,e,o)).elementType=yi,t.lanes=a,t;case jc:return gs(n,o,a,e);default:if("object"==typeof t&&null!==t)switch(t.$$typeof){case Gc:s=10;break e;case Zc:s=9;break e;case _i:s=11;break e;case Di:s=14;break e;case pn:s=16,r=null;break e}throw Error(M(130,null==t?t:typeof t,""))}return(e=Nt(s,n,e,o)).elementType=t,e.type=r,e.lanes=a,e}function Wn(t,e,n,r){return(t=Nt(7,t,r,e)).lanes=n,t}function gs(t,e,n,r){return(t=Nt(22,t,r,e)).elementType=jc,t.lanes=n,t.stateNode={isHidden:!1},t}function gu(t,e,n){return(t=Nt(6,t,null,e)).lanes=n,t}function hu(t,e,n){return(e=Nt(4,null!==t.children?t.children:[],t.key,e)).lanes=n,e.stateNode={containerInfo:t.containerInfo,pendingChildren:null,implementation:t.implementation},e}function DE(t,e,n,r,o){this.tag=e,this.containerInfo=t,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=$i(0),this.expirationTimes=$i(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=$i(0),this.identifierPrefix=r,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function Eu(t,e,n,r,o,a,s,i,l){return t=new DE(t,e,n,i,l),1===e?(e=1,!0===a&&(e|=8)):e=0,a=Nt(3,null,null,e),t.current=a,a.stateNode=t,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Rl(a),t}function q0(t){if(!t)return yn;e:{if(Fn(t=t._reactInternals)!==t||1!==t.tag)throw Error(M(170));var e=t;do{switch(e.tag){case 3:e=e.stateNode.context;break e;case 1:if(rt(e.type)){e=e.stateNode.__reactInternalMemoizedMergedChildContext;break e}}e=e.return}while(null!==e);throw Error(M(171))}if(1===t.tag){var n=t.type;if(rt(n))return fp(t,n,e)}return e}function H0(t,e,n,r,o,a,s,i,l){return(t=Eu(n,r,!0,t,0,a,0,i,l)).context=q0(null),n=t.current,(a=sn(r=et(),o=xn(n))).callback=e??null,Sn(n,a,o),t.current.lanes=o,io(t,o,r),st(t,r),t}function hs(t,e,n,r){var o=e.current,a=et(),s=xn(o);return n=q0(n),null===e.context?e.context=n:e.pendingContext=n,(e=sn(a,s)).payload={element:t},null!==(r=void 0===r?null:r)&&(e.callback=r),null!==(t=Sn(o,e,s))&&(Ft(t,o,s,a),Za(t,o,s)),s}function Es(t){return(t=t.current).child?(t.child.tag,t.child.stateNode):null}function V0(t,e){if(null!==(t=t.memoizedState)&&null!==t.dehydrated){var n=t.retryLane;t.retryLane=0!==n&&n"u"||"function"!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(G0)}catch(t){console.error(t)}}(),Pc.exports=ct;var Z0=Pc.exports;fi.createRoot=Z0.createRoot,fi.hydrateRoot=Z0.hydrateRoot;const j0={embedId:null,baseApiUrl:null,prompt:null,model:null,temperature:null,showThoughts:!1,chatIcon:"plus",brandImageUrl:null,greeting:null,buttonColor:"#262626",userBgColor:"#2C2F35",assistantBgColor:"#2563eb",noSponsor:null,sponsorText:"Powered by AnythingLLM",sponsorLink:"https://anythingllm.com",position:"bottom-right",assistantName:"AnythingLLM Chat Assistant",assistantIcon:null,windowHeight:null,windowWidth:null,textSize:null,noHeader:null,language:"en",sendMessageText:null,resetChatText:null,openOnLoad:"off",supportEmail:null,username:null,defaultMessages:[]};const W0={_fallbacks:{defaultMessages:[]},defaultMessages:function(t=null){if("string"!=typeof t)return this._fallbacks.defaultMessages;try{const e=t.split(",");if(!Array.isArray(e)||0===e.length||!e.every((n=>"string"==typeof n&&n.length>0)))throw new Error("Invalid default-messages attribute value. Must be array of strings");return e.map((n=>n.trim()))}catch(e){return console.error("AnythingLLMEmbed",e),this._fallbacks.defaultMessages}}};function LE(t={}){const e={};for(let[n,r]of Object.entries(t)){if(!W0.hasOwnProperty(n)){e[n]=r;continue}const o=W0[n](r);e[n]=o}return e}let ys;const OE=new Uint8Array(16);function kE(){if(!ys&&(ys=typeof crypto<"u"&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto),!ys))throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return ys(OE)}const Ve=[];for(let t=0;t<256;++t)Ve.push((t+256).toString(16).slice(1));const K0={randomUUID:typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto)};function Kn(t,e,n){if(K0.randomUUID&&!e&&!t)return K0.randomUUID();const r=(t=t||{}).random||(t.rng||kE)();if(r[6]=15&r[6]|64,r[8]=63&r[8]|128,e){n=n||0;for(let o=0;o<16;++o)e[n+o]=r[o];return e}return function(t,e=0){return Ve[t[e+0]]+Ve[t[e+1]]+Ve[t[e+2]]+Ve[t[e+3]]+"-"+Ve[t[e+4]]+Ve[t[e+5]]+"-"+Ve[t[e+6]]+Ve[t[e+7]]+"-"+Ve[t[e+8]]+Ve[t[e+9]]+"-"+Ve[t[e+10]]+Ve[t[e+11]]+Ve[t[e+12]]+Ve[t[e+13]]+Ve[t[e+14]]+Ve[t[e+15]]}(r)}function Y0(){const[t,e]=U.useState("");return U.useEffect((()=>{!function(){var s,i;if(!window||null==(s=null==ae?void 0:ae.settings)||!s.embedId)return;const r=`allm_${null==(i=null==ae?void 0:ae.settings)?void 0:i.embedId}_session_id`,o=window.localStorage.getItem(r);if(o)return console.log("Resuming session id",o),void e(o);const a=Kn();console.log("Registering new session id",a),window.localStorage.setItem(r,a),e(a)}()}),[window]),t}const vu="___anythingllm-chat-widget-open___";const BE="\npre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!\n Theme: GitHub Dark Dimmed\n Description: Dark dimmed theme as seen on github.com\n Author: github.com\n Maintainer: @Hirse\n Updated: 2021-05-15\n\n Colors taken from GitHub's CSS\n*/.hljs{color:#adbac7;background:#22272e}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#f47067}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#dcbdfb}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#6cb6ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#96d0ff}.hljs-built_in,.hljs-symbol{color:#f69d50}.hljs-code,.hljs-comment,.hljs-formula{color:#768390}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#8ddb8c}.hljs-subst{color:#adbac7}.hljs-section{color:#316dca;font-weight:700}.hljs-bullet{color:#eac55f}.hljs-emphasis{color:#adbac7;font-style:italic}.hljs-strong{color:#adbac7;font-weight:700}.hljs-addition{color:#b4f1b4;background-color:#1b4721}.hljs-deletion{color:#ffd8d3;background-color:#78191b}\n",PE='\n /**\n * ==============================================\n * Dot Falling\n * ==============================================\n */\n .allm-dot-falling {\n position: relative;\n left: -9999px;\n width: 10px;\n height: 10px;\n border-radius: 5px;\n background-color: #000000;\n color: #5fa4fa;\n box-shadow: 9999px 0 0 0 #000000;\n animation: dot-falling 1.5s infinite linear;\n animation-delay: 0.1s;\n }\n\n .allm-dot-falling::before,\n .allm-dot-falling::after {\n content: "";\n display: inline-block;\n position: absolute;\n top: 0;\n }\n\n .allm-dot-falling::before {\n width: 10px;\n height: 10px;\n border-radius: 5px;\n background-color: #000000;\n color: #000000;\n animation: dot-falling-before 1.5s infinite linear;\n animation-delay: 0s;\n }\n\n .allm-dot-falling::after {\n width: 10px;\n height: 10px;\n border-radius: 5px;\n background-color: #000000;\n color: #000000;\n animation: dot-falling-after 1.5s infinite linear;\n animation-delay: 0.2s;\n }\n\n @keyframes dot-falling {\n 0% {\n box-shadow: 9999px -15px 0 0 rgba(152, 128, 255, 0);\n }\n 25%,\n 50%,\n 75% {\n box-shadow: 9999px 0 0 0 #000000;\n }\n 100% {\n box-shadow: 9999px 15px 0 0 rgba(152, 128, 255, 0);\n }\n }\n\n @keyframes dot-falling-before {\n 0% {\n box-shadow: 9984px -15px 0 0 rgba(152, 128, 255, 0);\n }\n 25%,\n 50%,\n 75% {\n box-shadow: 9984px 0 0 0 #000000;\n }\n 100% {\n box-shadow: 9984px 15px 0 0 rgba(152, 128, 255, 0);\n }\n }\n\n @keyframes dot-falling-after {\n 0% {\n box-shadow: 10014px -15px 0 0 rgba(152, 128, 255, 0);\n }\n 25%,\n 50%,\n 75% {\n box-shadow: 10014px 0 0 0 #000000;\n }\n 100% {\n box-shadow: 10014px 15px 0 0 rgba(152, 128, 255, 0);\n }\n }\n\n #chat-history::-webkit-scrollbar,\n #chat-container::-webkit-scrollbar,\n .allm-no-scroll::-webkit-scrollbar {\n display: none !important;\n }\n\n /* Hide scrollbar for IE, Edge and Firefox */\n #chat-history,\n #chat-container,\n .allm-no-scroll {\n -ms-overflow-style: none !important; /* IE and Edge */\n scrollbar-width: none !important; /* Firefox */\n }\n\n span.allm-whitespace-pre-line>p {\n margin: 0px;\n }\n';function UE(){return w.jsxs("head",{children:[w.jsx("style",{children:BE}),w.jsx("style",{children:PE}),w.jsx("link",{rel:"stylesheet",href:ae.stylesSrc})]})}const qE=U.createContext({color:"currentColor",size:"1em",weight:"regular",mirrored:!1});var HE=Object.defineProperty,Ds=Object.getOwnPropertySymbols,X0=Object.prototype.hasOwnProperty,Q0=Object.prototype.propertyIsEnumerable,J0=(t,e,n)=>e in t?HE(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n,ef=(t,e)=>{for(var n in e||(e={}))X0.call(e,n)&&J0(t,n,e[n]);if(Ds)for(var n of Ds(e))Q0.call(e,n)&&J0(t,n,e[n]);return t},tf=(t,e)=>{var n={};for(var r in t)X0.call(t,r)&&e.indexOf(r)<0&&(n[r]=t[r]);if(null!=t&&Ds)for(var r of Ds(t))e.indexOf(r)<0&&Q0.call(t,r)&&(n[r]=t[r]);return n};const Me=U.forwardRef(((t,e)=>{const n=t,{alt:r,color:o,size:a,weight:s,mirrored:i,children:l,weights:u}=n,c=tf(n,["alt","color","size","weight","mirrored","children","weights"]),p=U.useContext(qE),{color:d="currentColor",size:f,weight:b="regular",mirrored:A=!1}=p,y=tf(p,["color","size","weight","mirrored"]);return m.createElement("svg",ef(ef({ref:e,xmlns:"http://www.w3.org/2000/svg",width:a??f,height:a??f,fill:o??d,viewBox:"0 0 256 256",transform:i||A?"scale(-1, 1)":void 0},y),c),!!r&&m.createElement("title",null,r),l,u.get(s??b))}));Me.displayName="IconBase";const VE=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M228,128a100,100,0,0,1-98.66,100H128a99.39,99.39,0,0,1-68.62-27.29,12,12,0,0,1,16.48-17.45,76,76,0,1,0-1.57-109c-.13.13-.25.25-.39.37L54.89,92H72a12,12,0,0,1,0,24H24a12,12,0,0,1-12-12V56a12,12,0,0,1,24,0V76.72L57.48,57.06A100,100,0,0,1,228,128Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M72,104H24V56Z",opacity:"0.2"}),m.createElement("path",{d:"M195.88,60.08A96.08,96.08,0,0,0,60.25,60L49.31,70,29.66,50.3A8,8,0,0,0,16,56v48a8,8,0,0,0,8,8H72a8,8,0,0,0,5.66-13.66l-17-17,10.54-9.65a3.07,3.07,0,0,0,.26-.25,80,80,0,1,1,1.65,114.78,8,8,0,0,0-11,11.63A95.38,95.38,0,0,0,128,224h1.32A96,96,0,0,0,195.88,60.08ZM32,96V75.28L52.69,96Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L60.63,81.29l17,17A8,8,0,0,1,72,112H24a8,8,0,0,1-8-8V56A8,8,0,0,1,29.66,50.3L49.31,70,60.25,60A96,96,0,0,1,224,128Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M222,128a94,94,0,0,1-92.74,94H128a93.43,93.43,0,0,1-64.5-25.65,6,6,0,1,1,8.24-8.72A82,82,0,1,0,70,70l-.19.19L39.44,98H72a6,6,0,0,1,0,12H24a6,6,0,0,1-6-6V56a6,6,0,0,1,12,0V90.34L61.63,61.4A94,94,0,0,1,222,128Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M220,128a92,92,0,0,1-90.77,92H128a91.47,91.47,0,0,1-63.13-25.1,4,4,0,1,1,5.5-5.82A84,84,0,1,0,68.6,68.57l-.13.12L34.3,100H72a4,4,0,0,1,0,8H24a4,4,0,0,1-4-4V56a4,4,0,0,1,8,0V94.89l35-32A92,92,0,0,1,220,128Z"}))]]);var $E=Object.defineProperty,zE=Object.defineProperties,GE=Object.getOwnPropertyDescriptors,nf=Object.getOwnPropertySymbols,ZE=Object.prototype.hasOwnProperty,jE=Object.prototype.propertyIsEnumerable,rf=(t,e,n)=>e in t?$E(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const of=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>zE(t,GE(e)))(((t,e)=>{for(var n in e||(e={}))ZE.call(e,n)&&rf(t,n,e[n]);if(nf)for(var n of nf(e))jE.call(e,n)&&rf(t,n,e[n]);return t})({ref:e},t),{weights:VE}))));of.displayName="ArrowCounterClockwise";const YE=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M208.49,152.49l-72,72a12,12,0,0,1-17,0l-72-72a12,12,0,0,1,17-17L116,187V40a12,12,0,0,1,24,0V187l51.51-51.52a12,12,0,0,1,17,17Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M200,144l-72,72L56,144Z",opacity:"0.2"}),m.createElement("path",{d:"M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M205.66,149.66l-72,72a8,8,0,0,1-11.32,0l-72-72A8,8,0,0,1,56,136h64V40a8,8,0,0,1,16,0v96h64a8,8,0,0,1,5.66,13.66Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M204.24,148.24l-72,72a6,6,0,0,1-8.48,0l-72-72a6,6,0,0,1,8.48-8.48L122,201.51V40a6,6,0,0,1,12,0V201.51l61.76-61.75a6,6,0,0,1,8.48,8.48Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M205.66,149.66l-72,72a8,8,0,0,1-11.32,0l-72-72a8,8,0,0,1,11.32-11.32L120,196.69V40a8,8,0,0,1,16,0V196.69l58.34-58.35a8,8,0,0,1,11.32,11.32Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M202.83,146.83l-72,72a4,4,0,0,1-5.66,0l-72-72a4,4,0,0,1,5.66-5.66L124,206.34V40a4,4,0,0,1,8,0V206.34l65.17-65.17a4,4,0,0,1,5.66,5.66Z"}))]]);var XE=Object.defineProperty,QE=Object.defineProperties,JE=Object.getOwnPropertyDescriptors,af=Object.getOwnPropertySymbols,eb=Object.prototype.hasOwnProperty,tb=Object.prototype.propertyIsEnumerable,sf=(t,e,n)=>e in t?XE(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const lf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>QE(t,JE(e)))(((t,e)=>{for(var n in e||(e={}))eb.call(e,n)&&sf(t,n,e[n]);if(af)for(var n of af(e))tb.call(e,n)&&sf(t,n,e[n]);return t})({ref:e},t),{weights:YE}))));lf.displayName="ArrowDown";const ob=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M241,150.65s0,0,0-.05a51.33,51.33,0,0,0-2.53-5.9L196.93,50.18a12,12,0,0,0-2.5-3.65,36,36,0,0,0-50.92,0A12,12,0,0,0,140,55V76H116V55a12,12,0,0,0-3.51-8.48,36,36,0,0,0-50.92,0,12,12,0,0,0-2.5,3.65L17.53,144.7A51.33,51.33,0,0,0,15,150.6s0,0,0,.05A52,52,0,1,0,116,168V100h24v68a52,52,0,1,0,101-17.35ZM80,62.28a12,12,0,0,1,12-1.22v63.15a51.9,51.9,0,0,0-35.9-7.62ZM64,196a28,28,0,1,1,28-28A28,28,0,0,1,64,196ZM164,61.06a12.06,12.06,0,0,1,12,1.22l23.87,54.31a51.9,51.9,0,0,0-35.9,7.62ZM192,196a28,28,0,1,1,28-28A28,28,0,0,1,192,196Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M104,168a40,40,0,1,1-40-40A40,40,0,0,1,104,168Zm88-40a40,40,0,1,0,40,40A40,40,0,0,0,192,128Z",opacity:"0.2"}),m.createElement("path",{d:"M237.2,151.87v0a47.1,47.1,0,0,0-2.35-5.45L193.26,51.8a7.82,7.82,0,0,0-1.66-2.44,32,32,0,0,0-45.26,0A8,8,0,0,0,144,55V80H112V55a8,8,0,0,0-2.34-5.66,32,32,0,0,0-45.26,0,7.82,7.82,0,0,0-1.66,2.44L21.15,146.4a47.1,47.1,0,0,0-2.35,5.45v0A48,48,0,1,0,112,168V96h32v72a48,48,0,1,0,93.2-16.13ZM76.71,59.75a16,16,0,0,1,19.29-1v73.51a47.9,47.9,0,0,0-46.79-9.92ZM64,200a32,32,0,1,1,32-32A32,32,0,0,1,64,200ZM160,58.74a16,16,0,0,1,19.29,1l27.5,62.58A47.9,47.9,0,0,0,160,132.25ZM192,200a32,32,0,1,1,32-32A32,32,0,0,1,192,200Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M237.22,151.9l0-.1a1.42,1.42,0,0,0-.07-.22,48.46,48.46,0,0,0-2.31-5.3L193.27,51.8a8,8,0,0,0-1.67-2.44,32,32,0,0,0-45.26,0A8,8,0,0,0,144,55V80H112V55a8,8,0,0,0-2.34-5.66,32,32,0,0,0-45.26,0,8,8,0,0,0-1.67,2.44L21.2,146.28a48.46,48.46,0,0,0-2.31,5.3,1.72,1.72,0,0,0-.07.21s0,.08,0,.11a48,48,0,0,0,90.32,32.51,47.49,47.49,0,0,0,2.9-16.59V96h32v71.83a47.49,47.49,0,0,0,2.9,16.59,48,48,0,0,0,90.32-32.51Zm-143.15,27a32,32,0,0,1-60.2-21.71l1.81-4.13A32,32,0,0,1,96,167.88V168h0A32,32,0,0,1,94.07,178.94ZM203,198.07A32,32,0,0,1,160,168h0v-.11a32,32,0,0,1,60.32-14.78l1.81,4.13A32,32,0,0,1,203,198.07Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M233,147.24,191.43,52.6a6,6,0,0,0-1.25-1.83,30,30,0,0,0-42.42,0A6,6,0,0,0,146,55V82H110V55a6,6,0,0,0-1.76-4.25,30,30,0,0,0-42.42,0,6,6,0,0,0-1.25,1.83L23,147.24A46,46,0,1,0,110,168V94h36v74a46,46,0,1,0,87-20.76ZM64,202a34,34,0,1,1,34-34A34,34,0,0,1,64,202Zm0-80a45.77,45.77,0,0,0-18.55,3.92L75.06,58.54A18,18,0,0,1,98,57.71V137A45.89,45.89,0,0,0,64,122Zm94-64.28a18,18,0,0,1,22.94.83l29.61,67.37A45.9,45.9,0,0,0,158,137ZM192,202a34,34,0,1,1,34-34A34,34,0,0,1,192,202Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M237.2,151.87v0a47.1,47.1,0,0,0-2.35-5.45L193.26,51.8a7.82,7.82,0,0,0-1.66-2.44,32,32,0,0,0-45.26,0A8,8,0,0,0,144,55V80H112V55a8,8,0,0,0-2.34-5.66,32,32,0,0,0-45.26,0,7.82,7.82,0,0,0-1.66,2.44L21.15,146.4a47.1,47.1,0,0,0-2.35,5.45v0A48,48,0,1,0,112,168V96h32v72a48,48,0,1,0,93.2-16.13ZM76.71,59.75a16,16,0,0,1,19.29-1v73.51a47.9,47.9,0,0,0-46.79-9.92ZM64,200a32,32,0,1,1,32-32A32,32,0,0,1,64,200ZM160,58.74a16,16,0,0,1,19.29,1l27.5,62.58A47.9,47.9,0,0,0,160,132.25ZM192,200a32,32,0,1,1,32-32A32,32,0,0,1,192,200Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M231.22,148.09,189.6,53.41a3.94,3.94,0,0,0-.83-1.22,28,28,0,0,0-39.6,0A4,4,0,0,0,148,55V84H108V55a4,4,0,0,0-1.17-2.83,28,28,0,0,0-39.6,0,3.94,3.94,0,0,0-.83,1.22L24.78,148.09A44,44,0,1,0,108,168V92h40v76a44,44,0,1,0,83.22-19.91ZM64,204a36,36,0,1,1,36-36A36,36,0,0,1,64,204Zm0-80a43.78,43.78,0,0,0-22.66,6.3L73.4,57.35a20,20,0,0,1,26.6-.59v86A44,44,0,0,0,64,124Zm92-67.23a20,20,0,0,1,26.6.59l32.06,72.94A43.92,43.92,0,0,0,156,142.74ZM192,204a36,36,0,1,1,36-36A36,36,0,0,1,192,204Z"}))]]);var ab=Object.defineProperty,sb=Object.defineProperties,ib=Object.getOwnPropertyDescriptors,uf=Object.getOwnPropertySymbols,lb=Object.prototype.hasOwnProperty,ub=Object.prototype.propertyIsEnumerable,cf=(t,e,n)=>e in t?ab(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const df=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>sb(t,ib(e)))(((t,e)=>{for(var n in e||(e={}))lb.call(e,n)&&cf(t,n,e[n]);if(uf)for(var n of uf(e))ub.call(e,n)&&cf(t,n,e[n]);return t})({ref:e},t),{weights:ob}))));df.displayName="Binoculars";const pb=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M208,96l-80,80L48,96Z",opacity:"0.2"}),m.createElement("path",{d:"M215.39,92.94A8,8,0,0,0,208,88H48a8,8,0,0,0-5.66,13.66l80,80a8,8,0,0,0,11.32,0l80-80A8,8,0,0,0,215.39,92.94ZM128,164.69,67.31,104H188.69Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M212.24,100.24l-80,80a6,6,0,0,1-8.48,0l-80-80a6,6,0,0,1,8.48-8.48L128,167.51l75.76-75.75a6,6,0,0,1,8.48,8.48Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M210.83,98.83l-80,80a4,4,0,0,1-5.66,0l-80-80a4,4,0,0,1,5.66-5.66L128,170.34l77.17-77.17a4,4,0,1,1,5.66,5.66Z"}))]]);var fb=Object.defineProperty,mb=Object.defineProperties,gb=Object.getOwnPropertyDescriptors,pf=Object.getOwnPropertySymbols,hb=Object.prototype.hasOwnProperty,Eb=Object.prototype.propertyIsEnumerable,ff=(t,e,n)=>e in t?fb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const yu=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>mb(t,gb(e)))(((t,e)=>{for(var n in e||(e={}))hb.call(e,n)&&ff(t,n,e[n]);if(pf)for(var n of pf(e))Eb.call(e,n)&&ff(t,n,e[n]);return t})({ref:e},t),{weights:pb}))));yu.displayName="CaretDown";const _b=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M120,128a16,16,0,1,1-16-16A16,16,0,0,1,120,128Zm32-16a16,16,0,1,0,16,16A16,16,0,0,0,152,112Zm84,16A108,108,0,0,1,78.77,224.15L46.34,235A20,20,0,0,1,21,209.66l10.81-32.43A108,108,0,1,1,236,128Zm-24,0A84,84,0,1,0,55.27,170.06a12,12,0,0,1,1,9.81l-9.93,29.79,29.79-9.93a12.1,12.1,0,0,1,3.8-.62,12,12,0,0,1,6,1.62A84,84,0,0,0,212,128Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z",opacity:"0.2"}),m.createElement("path",{d:"M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24ZM84,140a12,12,0,1,1,12-12A12,12,0,0,1,84,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,128,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,172,140Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M138,128a10,10,0,1,1-10-10A10,10,0,0,1,138,128ZM84,118a10,10,0,1,0,10,10A10,10,0,0,0,84,118Zm88,0a10,10,0,1,0,10,10A10,10,0,0,0,172,118Zm58,10A102,102,0,0,1,79.31,217.65L44.44,229.27a14,14,0,0,1-17.71-17.71l11.62-34.87A102,102,0,1,1,230,128Zm-12,0A90,90,0,1,0,50.08,173.06a6,6,0,0,1,.5,4.91L38.12,215.35a2,2,0,0,0,2.53,2.53L78,205.42a6.2,6.2,0,0,1,1.9-.31,6.09,6.09,0,0,1,3,.81A90,90,0,0,0,218,128Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128ZM84,116a12,12,0,1,0,12,12A12,12,0,0,0,84,116Zm88,0a12,12,0,1,0,12,12A12,12,0,0,0,172,116Zm60,12A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Zm-16,0A88,88,0,1,0,51.81,172.06a8,8,0,0,1,.66,6.54L40,216,77.4,203.53a7.85,7.85,0,0,1,2.53-.42,8,8,0,0,1,4,1.08A88,88,0,0,0,216,128Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M136,128a8,8,0,1,1-8-8A8,8,0,0,1,136,128Zm-52-8a8,8,0,1,0,8,8A8,8,0,0,0,84,120Zm88,0a8,8,0,1,0,8,8A8,8,0,0,0,172,120Zm56,8A100,100,0,0,1,79.5,215.47l-35.69,11.9a12,12,0,0,1-15.18-15.18l11.9-35.69A100,100,0,1,1,228,128Zm-8,0A92,92,0,1,0,48.35,174.07a4,4,0,0,1,.33,3.27L36.22,214.72a4,4,0,0,0,5.06,5.06l37.38-12.46a3.93,3.93,0,0,1,1.27-.21,4.05,4.05,0,0,1,2,.54A92,92,0,0,0,220,128Z"}))]]);var vb=Object.defineProperty,yb=Object.defineProperties,Db=Object.getOwnPropertyDescriptors,mf=Object.getOwnPropertySymbols,Cb=Object.prototype.hasOwnProperty,Sb=Object.prototype.propertyIsEnumerable,gf=(t,e,n)=>e in t?vb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const hf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>yb(t,Db(e)))(((t,e)=>{for(var n in e||(e={}))Cb.call(e,n)&&gf(t,n,e[n]);if(mf)for(var n of mf(e))Sb.call(e,n)&&gf(t,n,e[n]);return t})({ref:e},t),{weights:_b}))));hf.displayName="ChatCircleDots";const wb=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56Z",opacity:"0.2"}),m.createElement("path",{d:"M205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM205.66,85.66l-96,96a8,8,0,0,1-11.32,0l-40-40a8,8,0,0,1,11.32-11.32L104,164.69l90.34-90.35a8,8,0,0,1,11.32,11.32Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M228.24,76.24l-128,128a6,6,0,0,1-8.48,0l-56-56a6,6,0,0,1,8.48-8.48L96,191.51,219.76,67.76a6,6,0,0,1,8.48,8.48Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M226.83,74.83l-128,128a4,4,0,0,1-5.66,0l-56-56a4,4,0,0,1,5.66-5.66L96,194.34,221.17,69.17a4,4,0,1,1,5.66,5.66Z"}))]]);var xb=Object.defineProperty,Rb=Object.defineProperties,Lb=Object.getOwnPropertyDescriptors,Ef=Object.getOwnPropertySymbols,Ob=Object.prototype.hasOwnProperty,kb=Object.prototype.propertyIsEnumerable,bf=(t,e,n)=>e in t?xb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Af=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>Rb(t,Lb(e)))(((t,e)=>{for(var n in e||(e={}))Ob.call(e,n)&&bf(t,n,e[n]);if(Ef)for(var n of Ef(e))kb.call(e,n)&&bf(t,n,e[n]);return t})({ref:e},t),{weights:wb}))));Af.displayName="Check";const Fb=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M236,128a108,108,0,0,1-216,0c0-42.52,24.73-81.34,63-98.9A12,12,0,1,1,93,50.91C63.24,64.57,44,94.83,44,128a84,84,0,0,0,168,0c0-33.17-19.24-63.43-49-77.09A12,12,0,1,1,173,29.1C211.27,46.66,236,85.48,236,128Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z",opacity:"0.2"}),m.createElement("path",{d:"M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,176A72,72,0,0,1,92,65.64a8,8,0,0,1,8,13.85,56,56,0,1,0,56,0,8,8,0,0,1,8-13.85A72,72,0,0,1,128,200Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M230,128a102,102,0,0,1-204,0c0-40.18,23.35-76.86,59.5-93.45a6,6,0,0,1,5,10.9C58.61,60.09,38,92.49,38,128a90,90,0,0,0,180,0c0-35.51-20.61-67.91-52.5-82.55a6,6,0,0,1,5-10.9C206.65,51.14,230,87.82,230,128Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M228,128a100,100,0,0,1-200,0c0-39.4,22.9-75.37,58.33-91.63a4,4,0,1,1,3.34,7.27C57.07,58.6,36,91.71,36,128a92,92,0,0,0,184,0c0-36.29-21.07-69.4-53.67-84.36a4,4,0,1,1,3.34-7.27C205.1,52.63,228,88.6,228,128Z"}))]]);var Bb=Object.defineProperty,Pb=Object.defineProperties,Ub=Object.getOwnPropertyDescriptors,_f=Object.getOwnPropertySymbols,qb=Object.prototype.hasOwnProperty,Hb=Object.prototype.propertyIsEnumerable,vf=(t,e,n)=>e in t?Bb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Cs=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>Pb(t,Ub(e)))(((t,e)=>{for(var n in e||(e={}))qb.call(e,n)&&vf(t,n,e[n]);if(_f)for(var n of _f(e))Hb.call(e,n)&&vf(t,n,e[n]);return t})({ref:e},t),{weights:Fb}))));Cs.displayName="CircleNotch";const zb=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,28H88A12,12,0,0,0,76,40V76H40A12,12,0,0,0,28,88V216a12,12,0,0,0,12,12H168a12,12,0,0,0,12-12V180h36a12,12,0,0,0,12-12V40A12,12,0,0,0,216,28ZM156,204H52V100H156Zm48-48H180V88a12,12,0,0,0-12-12H100V52H204Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,40V168H168V88H88V40Z",opacity:"0.2"}),m.createElement("path",{d:"M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32Zm-8,128H176V88a8,8,0,0,0-8-8H96V48H208Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,34H88a6,6,0,0,0-6,6V82H40a6,6,0,0,0-6,6V216a6,6,0,0,0,6,6H168a6,6,0,0,0,6-6V174h42a6,6,0,0,0,6-6V40A6,6,0,0,0,216,34ZM162,210H46V94H162Zm48-48H174V88a6,6,0,0,0-6-6H94V46H210Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,36H88a4,4,0,0,0-4,4V84H40a4,4,0,0,0-4,4V216a4,4,0,0,0,4,4H168a4,4,0,0,0,4-4V172h44a4,4,0,0,0,4-4V40A4,4,0,0,0,216,36ZM164,212H44V92H164Zm48-48H172V88a4,4,0,0,0-4-4H92V44H212Z"}))]]);var Gb=Object.defineProperty,Zb=Object.defineProperties,jb=Object.getOwnPropertyDescriptors,yf=Object.getOwnPropertySymbols,Wb=Object.prototype.hasOwnProperty,Kb=Object.prototype.propertyIsEnumerable,Df=(t,e,n)=>e in t?Gb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Cf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>Zb(t,jb(e)))(((t,e)=>{for(var n in e||(e={}))Wb.call(e,n)&&Df(t,n,e[n]);if(yf)for(var n of yf(e))Kb.call(e,n)&&Df(t,n,e[n]);return t})({ref:e},t),{weights:zb}))));Cf.displayName="Copy";const Qb=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,40a8,8,0,1,1,8-8A8,8,0,0,1,128,136Zm0-56A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-40a8,8,0,1,1-8,8A8,8,0,0,1,128,40Zm0,136a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,40a8,8,0,1,1,8-8A8,8,0,0,1,128,216Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M152,128a24,24,0,1,1-24-24A24,24,0,0,1,152,128ZM128,72a24,24,0,1,0-24-24A24,24,0,0,0,128,72Zm0,112a24,24,0,1,0,24,24A24,24,0,0,0,128,184Z",opacity:"0.2"}),m.createElement("path",{d:"M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,144Zm0-64A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,128,32Zm0,144a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,224Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M156,128a28,28,0,1,1-28-28A28,28,0,0,1,156,128ZM128,76a28,28,0,1,0-28-28A28,28,0,0,0,128,76Zm0,104a28,28,0,1,0,28,28A28,28,0,0,0,128,180Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,98a30,30,0,1,0,30,30A30,30,0,0,0,128,98Zm0,48a18,18,0,1,1,18-18A18,18,0,0,1,128,146Zm0-68A30,30,0,1,0,98,48,30,30,0,0,0,128,78Zm0-48a18,18,0,1,1-18,18A18,18,0,0,1,128,30Zm0,148a30,30,0,1,0,30,30A30,30,0,0,0,128,178Zm0,48a18,18,0,1,1,18-18A18,18,0,0,1,128,226Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,144Zm0-64A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,128,32Zm0,144a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,224Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M128,100a28,28,0,1,0,28,28A28,28,0,0,0,128,100Zm0,48a20,20,0,1,1,20-20A20,20,0,0,1,128,148Zm0-72a28,28,0,1,0-28-28A28,28,0,0,0,128,76Zm0-48a20,20,0,1,1-20,20A20,20,0,0,1,128,28Zm0,152a28,28,0,1,0,28,28A28,28,0,0,0,128,180Zm0,48a20,20,0,1,1,20-20A20,20,0,0,1,128,228Z"}))]]);var Jb=Object.defineProperty,eA=Object.defineProperties,tA=Object.getOwnPropertyDescriptors,Sf=Object.getOwnPropertySymbols,nA=Object.prototype.hasOwnProperty,rA=Object.prototype.propertyIsEnumerable,Nf=(t,e,n)=>e in t?Jb(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Tf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>eA(t,tA(e)))(((t,e)=>{for(var n in e||(e={}))nA.call(e,n)&&Nf(t,n,e[n]);if(Sf)for(var n of Sf(e))rA.call(e,n)&&Nf(t,n,e[n]);return t})({ref:e},t),{weights:Qb}))));Tf.displayName="DotsThreeOutlineVertical";const sA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,44H32A12,12,0,0,0,20,56V192a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A12,12,0,0,0,224,44Zm-96,83.72L62.85,68h130.3ZM92.79,128,44,172.72V83.28Zm17.76,16.28,9.34,8.57a12,12,0,0,0,16.22,0l9.34-8.57L193.15,188H62.85ZM163.21,128,212,83.28v89.44Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,56l-96,88L32,56Z",opacity:"0.2"}),m.createElement("path",{d:"M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-96,85.15L52.57,64H203.43ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.18V181.82Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.18V181.82Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,50H32a6,6,0,0,0-6,6V192a14,14,0,0,0,14,14H216a14,14,0,0,0,14-14V56A6,6,0,0,0,224,50Zm-96,85.86L47.42,62H208.58ZM101.67,128,38,186.36V69.64Zm8.88,8.14L124,148.42a6,6,0,0,0,8.1,0l13.4-12.28L208.58,194H47.43ZM154.33,128,218,69.64V186.36Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-96,85.15L52.57,64H203.43ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.18V181.82Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,52H32a4,4,0,0,0-4,4V192a12,12,0,0,0,12,12H216a12,12,0,0,0,12-12V56A4,4,0,0,0,224,52Zm-96,86.57L42.28,60H213.72ZM104.63,128,36,190.91V65.09Zm5.92,5.43L125.3,147a4,4,0,0,0,5.4,0l14.75-13.52L213.72,196H42.28ZM151.37,128,220,65.09V190.91Z"}))]]);var iA=Object.defineProperty,lA=Object.defineProperties,uA=Object.getOwnPropertyDescriptors,wf=Object.getOwnPropertySymbols,cA=Object.prototype.hasOwnProperty,dA=Object.prototype.propertyIsEnumerable,xf=(t,e,n)=>e in t?iA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Rf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>lA(t,uA(e)))(((t,e)=>{for(var n in e||(e={}))cA.call(e,n)&&xf(t,n,e[n]);if(wf)for(var n of wf(e))dA.call(e,n)&&xf(t,n,e[n]);return t})({ref:e},t),{weights:sA}))));Rf.displayName="Envelope";const mA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M204.73,51.85A108.07,108.07,0,0,0,20,128v56a28,28,0,0,0,28,28H64a28,28,0,0,0,28-28V144a28,28,0,0,0-28-28H44.84A84.05,84.05,0,0,1,128,44h.64a83.7,83.7,0,0,1,82.52,72H192a28,28,0,0,0-28,28v40a28,28,0,0,0,28,28h19.6A20,20,0,0,1,192,228H136a12,12,0,0,0,0,24h56a44.05,44.05,0,0,0,44-44V128A107.34,107.34,0,0,0,204.73,51.85ZM64,140a4,4,0,0,1,4,4v40a4,4,0,0,1-4,4H48a4,4,0,0,1-4-4V140Zm124,44V144a4,4,0,0,1,4-4h20v48H192A4,4,0,0,1,188,184Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M80,144v40a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V128H64A16,16,0,0,1,80,144Zm112-16a16,16,0,0,0-16,16v40a16,16,0,0,0,16,16h32V128Z",opacity:"0.2"}),m.createElement("path",{d:"M201.89,54.66A104.08,104.08,0,0,0,24,128v56a24,24,0,0,0,24,24H64a24,24,0,0,0,24-24V144a24,24,0,0,0-24-24H40.36A88.12,88.12,0,0,1,190.54,65.93,87.39,87.39,0,0,1,215.65,120H192a24,24,0,0,0-24,24v40a24,24,0,0,0,24,24h24a24,24,0,0,1-24,24H136a8,8,0,0,0,0,16h56a40,40,0,0,0,40-40V128A103.41,103.41,0,0,0,201.89,54.66ZM64,136a8,8,0,0,1,8,8v40a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V136Zm128,56a8,8,0,0,1-8-8V144a8,8,0,0,1,8-8h24v56Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232,128v80a40,40,0,0,1-40,40H136a8,8,0,0,1,0-16h56a24,24,0,0,0,24-24H192a24,24,0,0,1-24-24V144a24,24,0,0,1,24-24h23.65A88,88,0,0,0,66,65.54,87.29,87.29,0,0,0,40.36,120H64a24,24,0,0,1,24,24v40a24,24,0,0,1-24,24H48a24,24,0,0,1-24-24V128A104.11,104.11,0,0,1,201.89,54.66,103.41,103.41,0,0,1,232,128Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M200.47,56.07A101.37,101.37,0,0,0,128.77,26H128A102,102,0,0,0,26,128v56a22,22,0,0,0,22,22H64a22,22,0,0,0,22-22V144a22,22,0,0,0-22-22H38.2A90,90,0,0,1,128,38h.68a89.71,89.71,0,0,1,89.13,84H192a22,22,0,0,0-22,22v40a22,22,0,0,0,22,22h26v2a26,26,0,0,1-26,26H136a6,6,0,0,0,0,12h56a38,38,0,0,0,38-38V128A101.44,101.44,0,0,0,200.47,56.07ZM64,134a10,10,0,0,1,10,10v40a10,10,0,0,1-10,10H48a10,10,0,0,1-10-10V134Zm118,50V144a10,10,0,0,1,10-10h26v60H192A10,10,0,0,1,182,184Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M201.89,54.66A103.43,103.43,0,0,0,128.79,24H128A104,104,0,0,0,24,128v56a24,24,0,0,0,24,24H64a24,24,0,0,0,24-24V144a24,24,0,0,0-24-24H40.36A88.12,88.12,0,0,1,190.54,65.93,87.39,87.39,0,0,1,215.65,120H192a24,24,0,0,0-24,24v40a24,24,0,0,0,24,24h24a24,24,0,0,1-24,24H136a8,8,0,0,0,0,16h56a40,40,0,0,0,40-40V128A103.41,103.41,0,0,0,201.89,54.66ZM64,136a8,8,0,0,1,8,8v40a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V136Zm128,56a8,8,0,0,1-8-8V144a8,8,0,0,1,8-8h24v56Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M199.05,57.48A100.07,100.07,0,0,0,28,128v56a20,20,0,0,0,20,20H64a20,20,0,0,0,20-20V144a20,20,0,0,0-20-20H36.08A92,92,0,0,1,128,36h.7a91.75,91.75,0,0,1,91.22,88H192a20,20,0,0,0-20,20v40a20,20,0,0,0,20,20h28v4a28,28,0,0,1-28,28H136a4,4,0,0,0,0,8h56a36,36,0,0,0,36-36V128A99.44,99.44,0,0,0,199.05,57.48ZM64,132a12,12,0,0,1,12,12v40a12,12,0,0,1-12,12H48a12,12,0,0,1-12-12V132Zm116,52V144a12,12,0,0,1,12-12h28v64H192A12,12,0,0,1,180,184Z"}))]]);var gA=Object.defineProperty,hA=Object.defineProperties,EA=Object.getOwnPropertyDescriptors,Lf=Object.getOwnPropertySymbols,bA=Object.prototype.hasOwnProperty,AA=Object.prototype.propertyIsEnumerable,Of=(t,e,n)=>e in t?gA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const kf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>hA(t,EA(e)))(((t,e)=>{for(var n in e||(e={}))bA.call(e,n)&&Of(t,n,e[n]);if(Lf)for(var n of Lf(e))AA.call(e,n)&&Of(t,n,e[n]);return t})({ref:e},t),{weights:mA}))));kf.displayName="Headset";const yA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M252,152a12,12,0,0,1-12,12H228v12a12,12,0,0,1-24,0V164H192a12,12,0,0,1,0-24h12V128a12,12,0,0,1,24,0v12h12A12,12,0,0,1,252,152ZM56,76H68V88a12,12,0,0,0,24,0V76h12a12,12,0,1,0,0-24H92V40a12,12,0,0,0-24,0V52H56a12,12,0,0,0,0,24ZM184,188h-4v-4a12,12,0,0,0-24,0v4h-4a12,12,0,0,0,0,24h4v4a12,12,0,0,0,24,0v-4h4a12,12,0,0,0,0-24ZM222.14,82.83,82.82,222.14a20,20,0,0,1-28.28,0L33.85,201.46a20,20,0,0,1,0-28.29L173.17,33.86a20,20,0,0,1,28.28,0l20.69,20.68A20,20,0,0,1,222.14,82.83ZM159,112,144,97,53.65,187.31l15,15Zm43.31-43.31-15-15L161,80l15,15Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M176,112,74.34,213.66a8,8,0,0,1-11.31,0L42.34,193a8,8,0,0,1,0-11.31L144,80Z",opacity:"0.2"}),m.createElement("path",{d:"M248,152a8,8,0,0,1-8,8H224v16a8,8,0,0,1-16,0V160H192a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,248,152ZM56,72H72V88a8,8,0,0,0,16,0V72h16a8,8,0,0,0,0-16H88V40a8,8,0,0,0-16,0V56H56a8,8,0,0,0,0,16ZM184,192h-8v-8a8,8,0,0,0-16,0v8h-8a8,8,0,0,0,0,16h8v8a8,8,0,0,0,16,0v-8h8a8,8,0,0,0,0-16ZM219.31,80,80,219.31a16,16,0,0,1-22.62,0L36.68,198.63a16,16,0,0,1,0-22.63L176,36.69a16,16,0,0,1,22.63,0l20.68,20.68A16,16,0,0,1,219.31,80Zm-54.63,32L144,91.31l-96,96L68.68,208ZM208,68.69,187.31,48l-32,32L176,100.69Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M248,152a8,8,0,0,1-8,8H224v16a8,8,0,0,1-16,0V160H192a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16A8,8,0,0,1,248,152ZM56,72H72V88a8,8,0,0,0,16,0V72h16a8,8,0,0,0,0-16H88V40a8,8,0,0,0-16,0V56H56a8,8,0,0,0,0,16ZM184,192h-8v-8a8,8,0,0,0-16,0v8h-8a8,8,0,0,0,0,16h8v8a8,8,0,0,0,16,0v-8h8a8,8,0,0,0,0-16ZM219.31,80,80,219.31a16,16,0,0,1-22.62,0L36.68,198.63a16,16,0,0,1,0-22.63L176,36.69a16,16,0,0,1,22.63,0l20.68,20.68A16,16,0,0,1,219.31,80ZM208,68.69,187.31,48l-32,32L176,100.69Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M246,152a6,6,0,0,1-6,6H222v18a6,6,0,0,1-12,0V158H192a6,6,0,0,1,0-12h18V128a6,6,0,0,1,12,0v18h18A6,6,0,0,1,246,152ZM56,70H74V88a6,6,0,0,0,12,0V70h18a6,6,0,0,0,0-12H86V40a6,6,0,0,0-12,0V58H56a6,6,0,0,0,0,12ZM184,194H174V184a6,6,0,0,0-12,0v10H152a6,6,0,0,0,0,12h10v10a6,6,0,0,0,12,0V206h10a6,6,0,0,0,0-12ZM217.9,78.59,78.58,217.9a14,14,0,0,1-19.8,0L38.09,197.21a14,14,0,0,1,0-19.8L177.41,38.1a14,14,0,0,1,19.8,0L217.9,58.79A14,14,0,0,1,217.9,78.59ZM167.51,112,144,88.49,46.58,185.9a2,2,0,0,0,0,2.83l20.69,20.68a2,2,0,0,0,2.82,0h0Zm41.9-44.73L188.73,46.59a2,2,0,0,0-2.83,0L152.48,80,176,103.52,209.41,70.1A2,2,0,0,0,209.41,67.27Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M48,64a8,8,0,0,1,8-8H72V40a8,8,0,0,1,16,0V56h16a8,8,0,0,1,0,16H88V88a8,8,0,0,1-16,0V72H56A8,8,0,0,1,48,64ZM184,192h-8v-8a8,8,0,0,0-16,0v8h-8a8,8,0,0,0,0,16h8v8a8,8,0,0,0,16,0v-8h8a8,8,0,0,0,0-16Zm56-48H224V128a8,8,0,0,0-16,0v16H192a8,8,0,0,0,0,16h16v16a8,8,0,0,0,16,0V160h16a8,8,0,0,0,0-16ZM219.31,80,80,219.31a16,16,0,0,1-22.62,0L36.68,198.63a16,16,0,0,1,0-22.63L176,36.69a16,16,0,0,1,22.63,0l20.68,20.68A16,16,0,0,1,219.31,80Zm-54.63,32L144,91.31l-96,96L68.68,208ZM208,68.69,187.31,48l-32,32L176,100.69Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M244,152a4,4,0,0,1-4,4H220v20a4,4,0,0,1-8,0V156H192a4,4,0,0,1,0-8h20V128a4,4,0,0,1,8,0v20h20A4,4,0,0,1,244,152ZM56,68H76V88a4,4,0,0,0,8,0V68h20a4,4,0,0,0,0-8H84V40a4,4,0,0,0-8,0V60H56a4,4,0,0,0,0,8ZM184,196H172V184a4,4,0,0,0-8,0v12H152a4,4,0,0,0,0,8h12v12a4,4,0,0,0,8,0V204h12a4,4,0,0,0,0-8ZM216.48,77.17,77.17,216.49a12,12,0,0,1-17,0L39.51,195.8a12,12,0,0,1,0-17L178.83,39.51a12,12,0,0,1,17,0L216.48,60.2A12,12,0,0,1,216.48,77.17ZM170.34,112,144,85.66,45.17,184.49a4,4,0,0,0,0,5.65l20.68,20.69a4,4,0,0,0,5.66,0Zm40.49-46.14L190.14,45.17a4,4,0,0,0-5.66,0L149.65,80,176,106.34l34.83-34.83A4,4,0,0,0,210.83,65.86Z"}))]]);var DA=Object.defineProperty,CA=Object.defineProperties,SA=Object.getOwnPropertyDescriptors,If=Object.getOwnPropertySymbols,NA=Object.prototype.hasOwnProperty,TA=Object.prototype.propertyIsEnumerable,Mf=(t,e,n)=>e in t?DA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Ff=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>CA(t,SA(e)))(((t,e)=>{for(var n in e||(e={}))NA.call(e,n)&&Mf(t,n,e[n]);if(If)for(var n of If(e))TA.call(e,n)&&Mf(t,n,e[n]);return t})({ref:e},t),{weights:yA}))));Ff.displayName="MagicWand";const RA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232.49,215.51,185,168a92.12,92.12,0,1,0-17,17l47.53,47.54a12,12,0,0,0,17-17ZM44,112a68,68,0,1,1,68,68A68.07,68.07,0,0,1,44,112Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z",opacity:"0.2"}),m.createElement("path",{d:"M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M228.24,219.76l-51.38-51.38a86.15,86.15,0,1,0-8.48,8.48l51.38,51.38a6,6,0,0,0,8.48-8.48ZM38,112a74,74,0,1,1,74,74A74.09,74.09,0,0,1,38,112Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M226.83,221.17l-52.7-52.7a84.1,84.1,0,1,0-5.66,5.66l52.7,52.7a4,4,0,0,0,5.66-5.66ZM36,112a76,76,0,1,1,76,76A76.08,76.08,0,0,1,36,112Z"}))]]);var LA=Object.defineProperty,OA=Object.defineProperties,kA=Object.getOwnPropertyDescriptors,Bf=Object.getOwnPropertySymbols,IA=Object.prototype.hasOwnProperty,MA=Object.prototype.propertyIsEnumerable,Pf=(t,e,n)=>e in t?LA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Uf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>OA(t,kA(e)))(((t,e)=>{for(var n in e||(e={}))IA.call(e,n)&&Pf(t,n,e[n]);if(Bf)for(var n of Bf(e))MA.call(e,n)&&Pf(t,n,e[n]);return t})({ref:e},t),{weights:RA}))));Uf.displayName="MagnifyingGlass";const PA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M225.86,110.48,57.8,14.58A20,20,0,0,0,29.16,38.67l30.61,89.21L29.16,217.33A20,20,0,0,0,48,244a20.1,20.1,0,0,0,9.81-2.58l.09-.06,168-96.07a20,20,0,0,0,0-34.81ZM55.24,215.23,81,140h55a12,12,0,0,0,0-24H81.07L55.25,40.76l152.69,87.13Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M219.91,134.86,51.93,231a8,8,0,0,1-11.44-9.67l31-90.71a7.89,7.89,0,0,0,0-5.38l-31-90.47a8,8,0,0,1,11.44-9.67l168,95.85A8,8,0,0,1,219.91,134.86Z",opacity:"0.2"}),m.createElement("path",{d:"M223.87,114l-168-95.89A16,16,0,0,0,32.93,37.32l31,90.47a.42.42,0,0,0,0,.1.3.3,0,0,0,0,.1l-31,90.67A16,16,0,0,0,48,240a16.14,16.14,0,0,0,7.92-2.1l167.91-96.05a16,16,0,0,0,.05-27.89ZM48,224l0-.09L78.14,136H136a8,8,0,0,0,0-16H78.22L48.06,32.12,48,32l168,95.83Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M232,127.89a16,16,0,0,1-8.18,14L55.91,237.9A16.14,16.14,0,0,1,48,240a16,16,0,0,1-15.05-21.34L60.3,138.71A4,4,0,0,1,64.09,136H136a8,8,0,0,0,8-8.53,8.19,8.19,0,0,0-8.26-7.47H64.16a4,4,0,0,1-3.79-2.7l-27.44-80A16,16,0,0,1,55.85,18.07l168,95.89A16,16,0,0,1,232,127.89Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M222.88,115.69l-168-95.88a14,14,0,0,0-20,16.85l31,90.48,0,.07a2.11,2.11,0,0,1,0,1.42l-31,90.64A14,14,0,0,0,48,238a14.11,14.11,0,0,0,6.92-1.83L222.84,140.1a14,14,0,0,0,0-24.41Zm-5.95,14L49,225.73a1.87,1.87,0,0,1-2.27-.22,1.92,1.92,0,0,1-.56-2.28L76.7,134H136a6,6,0,0,0,0-12H76.78L46.14,32.7A2,2,0,0,1,49,30.25l168,95.89a1.93,1.93,0,0,1,1,1.74A2,2,0,0,1,216.93,129.66Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M223.87,114l-168-95.89A16,16,0,0,0,32.93,37.32l31,90.47a.42.42,0,0,0,0,.1.3.3,0,0,0,0,.1l-31,90.67A16,16,0,0,0,48,240a16.14,16.14,0,0,0,7.92-2.1l167.91-96.05a16,16,0,0,0,.05-27.89ZM48,224l0-.09L78.14,136H136a8,8,0,0,0,0-16H78.22L48.06,32.12,48,32l168,95.83Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M221.89,117.43l-168-95.88A12,12,0,0,0,36.7,36l31.05,90.48v.05a4.09,4.09,0,0,1,0,2.74L36.72,220A12,12,0,0,0,48,236a12.13,12.13,0,0,0,5.93-1.57l167.94-96.08a12,12,0,0,0,0-20.92Zm-4,14L50,227.47a4,4,0,0,1-5.7-4.88l31-90.59H136a4,4,0,0,0,0-8H75.35a.65.65,0,0,1,0-.13L44.25,33.37A4,4,0,0,1,50,28.52l168,95.87a4,4,0,0,1,0,7Z"}))]]);var UA=Object.defineProperty,qA=Object.defineProperties,HA=Object.getOwnPropertyDescriptors,qf=Object.getOwnPropertySymbols,VA=Object.prototype.hasOwnProperty,$A=Object.prototype.propertyIsEnumerable,Hf=(t,e,n)=>e in t?UA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Vf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>qA(t,HA(e)))(((t,e)=>{for(var n in e||(e={}))VA.call(e,n)&&Hf(t,n,e[n]);if(qf)for(var n of qf(e))$A.call(e,n)&&Hf(t,n,e[n]);return t})({ref:e},t),{weights:PA}))));Vf.displayName="PaperPlaneRight";const ZA=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z",opacity:"0.2"}),m.createElement("path",{d:"M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM184,136H136v48a8,8,0,0,1-16,0V136H72a8,8,0,0,1,0-16h48V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M222,128a6,6,0,0,1-6,6H134v82a6,6,0,0,1-12,0V134H40a6,6,0,0,1,0-12h82V40a6,6,0,0,1,12,0v82h82A6,6,0,0,1,222,128Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M220,128a4,4,0,0,1-4,4H132v84a4,4,0,0,1-8,0V132H40a4,4,0,0,1,0-8h84V40a4,4,0,0,1,8,0v84h84A4,4,0,0,1,220,128Z"}))]]);var jA=Object.defineProperty,WA=Object.defineProperties,KA=Object.getOwnPropertyDescriptors,$f=Object.getOwnPropertySymbols,YA=Object.prototype.hasOwnProperty,XA=Object.prototype.propertyIsEnumerable,zf=(t,e,n)=>e in t?jA(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Gf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>WA(t,KA(e)))(((t,e)=>{for(var n in e||(e={}))YA.call(e,n)&&zf(t,n,e[n]);if($f)for(var n of $f(e))XA.call(e,n)&&zf(t,n,e[n]);return t})({ref:e},t),{weights:ZA}))));Gf.displayName="Plus";const e_=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M240.26,186.1,152.81,34.23h0a28.74,28.74,0,0,0-49.62,0L15.74,186.1a27.45,27.45,0,0,0,0,27.71A28.31,28.31,0,0,0,40.55,228h174.9a28.31,28.31,0,0,0,24.79-14.19A27.45,27.45,0,0,0,240.26,186.1Zm-20.8,15.7a4.46,4.46,0,0,1-4,2.2H40.55a4.46,4.46,0,0,1-4-2.2,3.56,3.56,0,0,1,0-3.73L124,46.2a4.77,4.77,0,0,1,8,0l87.44,151.87A3.56,3.56,0,0,1,219.46,201.8ZM116,136V104a12,12,0,0,1,24,0v32a12,12,0,0,1-24,0Zm28,40a16,16,0,1,1-16-16A16,16,0,0,1,144,176Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M215.46,216H40.54C27.92,216,20,202.79,26.13,192.09L113.59,40.22c6.3-11,22.52-11,28.82,0l87.46,151.87C236,202.79,228.08,216,215.46,216Z",opacity:"0.2"}),m.createElement("path",{d:"M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M235.07,189.09,147.61,37.22h0a22.75,22.75,0,0,0-39.22,0L20.93,189.09a21.53,21.53,0,0,0,0,21.72A22.35,22.35,0,0,0,40.55,222h174.9a22.35,22.35,0,0,0,19.6-11.19A21.53,21.53,0,0,0,235.07,189.09ZM224.66,204.8a10.46,10.46,0,0,1-9.21,5.2H40.55a10.46,10.46,0,0,1-9.21-5.2,9.51,9.51,0,0,1,0-9.72L118.79,43.21a10.75,10.75,0,0,1,18.42,0l87.46,151.87A9.51,9.51,0,0,1,224.66,204.8ZM122,144V104a6,6,0,0,1,12,0v40a6,6,0,0,1-12,0Zm16,36a10,10,0,1,1-10-10A10,10,0,0,1,138,180Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M233.34,190.09,145.88,38.22h0a20.75,20.75,0,0,0-35.76,0L22.66,190.09a19.52,19.52,0,0,0,0,19.71A20.36,20.36,0,0,0,40.54,220H215.46a20.36,20.36,0,0,0,17.86-10.2A19.52,19.52,0,0,0,233.34,190.09ZM226.4,205.8a12.47,12.47,0,0,1-10.94,6.2H40.54a12.47,12.47,0,0,1-10.94-6.2,11.45,11.45,0,0,1,0-11.72L117.05,42.21a12.76,12.76,0,0,1,21.9,0L226.4,194.08A11.45,11.45,0,0,1,226.4,205.8ZM124,144V104a4,4,0,0,1,8,0v40a4,4,0,0,1-8,0Zm12,36a8,8,0,1,1-8-8A8,8,0,0,1,136,180Z"}))]]);var t_=Object.defineProperty,n_=Object.defineProperties,r_=Object.getOwnPropertyDescriptors,Zf=Object.getOwnPropertySymbols,o_=Object.prototype.hasOwnProperty,a_=Object.prototype.propertyIsEnumerable,jf=(t,e,n)=>e in t?t_(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Du=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>n_(t,r_(e)))(((t,e)=>{for(var n in e||(e={}))o_.call(e,n)&&jf(t,n,e[n]);if(Zf)for(var n of Zf(e))a_.call(e,n)&&jf(t,n,e[n]);return t})({ref:e},t),{weights:e_}))));Du.displayName="Warning";const l_=new Map([["bold",m.createElement(m.Fragment,null,m.createElement("path",{d:"M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"}))],["duotone",m.createElement(m.Fragment,null,m.createElement("path",{d:"M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z",opacity:"0.2"}),m.createElement("path",{d:"M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"}))],["fill",m.createElement(m.Fragment,null,m.createElement("path",{d:"M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM181.66,170.34a8,8,0,0,1-11.32,11.32L128,139.31,85.66,181.66a8,8,0,0,1-11.32-11.32L116.69,128,74.34,85.66A8,8,0,0,1,85.66,74.34L128,116.69l42.34-42.35a8,8,0,0,1,11.32,11.32L139.31,128Z"}))],["light",m.createElement(m.Fragment,null,m.createElement("path",{d:"M204.24,195.76a6,6,0,1,1-8.48,8.48L128,136.49,60.24,204.24a6,6,0,0,1-8.48-8.48L119.51,128,51.76,60.24a6,6,0,0,1,8.48-8.48L128,119.51l67.76-67.75a6,6,0,0,1,8.48,8.48L136.49,128Z"}))],["regular",m.createElement(m.Fragment,null,m.createElement("path",{d:"M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"}))],["thin",m.createElement(m.Fragment,null,m.createElement("path",{d:"M202.83,197.17a4,4,0,0,1-5.66,5.66L128,133.66,58.83,202.83a4,4,0,0,1-5.66-5.66L122.34,128,53.17,58.83a4,4,0,0,1,5.66-5.66L128,122.34l69.17-69.17a4,4,0,1,1,5.66,5.66L133.66,128Z"}))]]);var u_=Object.defineProperty,c_=Object.defineProperties,d_=Object.getOwnPropertyDescriptors,Wf=Object.getOwnPropertySymbols,p_=Object.prototype.hasOwnProperty,f_=Object.prototype.propertyIsEnumerable,Kf=(t,e,n)=>e in t?u_(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;const Yf=U.forwardRef(((t,e)=>m.createElement(Me,((t,e)=>c_(t,d_(e)))(((t,e)=>{for(var n in e||(e={}))p_.call(e,n)&&Kf(t,n,e[n]);if(Wf)for(var n of Wf(e))f_.call(e,n)&&Kf(t,n,e[n]);return t})({ref:e},t),{weights:l_}))));Yf.displayName="X";const Cu={plus:Gf,chatBubble:hf,support:kf,search2:df,search:Uf,magic:Ff};function h_({settings:t,isOpen:e,toggleOpen:n}){if(e)return null;const r=Cu.hasOwnProperty(null==t?void 0:t.chatIcon)?Cu[t.chatIcon]:Cu.plus;return w.jsx("button",{style:{backgroundColor:t.buttonColor},id:"anything-llm-embed-chat-button",onClick:n,className:"hover:allm-cursor-pointer allm-border-none allm-flex allm-items-center allm-justify-center allm-p-4 allm-rounded-full allm-text-white allm-text-2xl hover:allm-opacity-95","aria-label":"Toggle Menu",children:w.jsx(r,{className:"text-white"})})}const qo="data:image/svg+xml,%3csvg%20width='49'%20height='49'%20viewBox='0%200%2049%2049'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3crect%20x='0.898438'%20y='0.5'%20width='48'%20height='48'%20rx='24'%20fill='%23222628'%20fill-opacity='0.8'/%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M12.0173%2014.2937C10.4387%2014.2937%209.14844%2015.584%209.14844%2017.1626V31.8372C9.14844%2033.4156%2010.4365%2034.7061%2012.0173%2034.7061H17.5428C18.4316%2034.7061%2019.2557%2034.3035%2019.8034%2033.6063L19.8041%2033.6054L32.4126%2017.4869H37.4552V31.509H32.4204L29.8951%2027.9721L29.8867%2027.9615C29.4483%2027.4042%2028.602%2027.4042%2028.1635%2027.9615L27.52%2028.7815L27.5178%2028.7843C27.2188%2029.1751%2027.2113%2029.7217%2027.512%2030.1178L29.985%2033.5936L29.9935%2033.6044C30.5415%2034.302%2031.3696%2034.7042%2032.2541%2034.7042H37.7795C39.3643%2034.7042%2040.6484%2033.4175%2040.6484%2031.8353V17.1626C40.6484%2015.5827%2039.3646%2014.2937%2037.7795%2014.2937H32.2541C31.3673%2014.2937%2030.5407%2014.6964%2029.9928%2015.3964L17.3858%2031.511H12.3417V17.4889H17.3757L20.133%2021.2573L20.1386%2021.2645C20.5782%2021.8273%2021.4253%2021.8239%2021.8647%2021.2666L21.8661%2021.2648L22.505%2020.4477L22.5069%2020.4453C22.8064%2020.0538%2022.8139%2019.5046%2022.5076%2019.1075L19.8102%2015.4041L19.804%2015.3963C19.2562%2014.6965%2018.4318%2014.2937%2017.5428%2014.2937H12.0173Z'%20fill='white'/%3e%3cpath%20d='M19.8034%2033.6063L20.0392%2033.7915L20.0394%2033.7912L19.8034%2033.6063ZM19.8041%2033.6054L20.0401%2033.7903L20.0403%2033.7901L19.8041%2033.6054ZM32.4126%2017.4869V17.1871H32.2665L32.1764%2017.3022L32.4126%2017.4869ZM37.4552%2017.4869H37.755V17.1871H37.4552V17.4869ZM37.4552%2031.509V31.8089H37.755V31.509H37.4552ZM32.4204%2031.509L32.1763%2031.6833L32.266%2031.8089H32.4204V31.509ZM29.8951%2027.9721L30.1394%2027.7977L30.1307%2027.7867L29.8951%2027.9721ZM29.8867%2027.9615L29.6511%2028.1469L29.6511%2028.1469L29.8867%2027.9615ZM28.1635%2027.9615L27.9279%2027.7761L27.9277%2027.7764L28.1635%2027.9615ZM27.52%2028.7815L27.2841%2028.5964L27.2819%2028.5993L27.52%2028.7815ZM27.5178%2028.7843L27.7559%2028.9665L27.7559%2028.9665L27.5178%2028.7843ZM27.512%2030.1178L27.7564%2029.9439L27.7508%2029.9365L27.512%2030.1178ZM29.985%2033.5936L29.7407%2033.7674L29.7448%2033.7732L29.7492%2033.7788L29.985%2033.5936ZM29.9935%2033.6044L30.2293%2033.4191L30.2293%2033.4191L29.9935%2033.6044ZM29.9928%2015.3964L29.7567%2015.2116L29.7566%2015.2116L29.9928%2015.3964ZM17.3858%2031.511V31.8108H17.5319L17.6219%2031.6957L17.3858%2031.511ZM12.3417%2031.511H12.0418V31.8108H12.3417V31.511ZM12.3417%2017.4889V17.189H12.0418V17.4889H12.3417ZM17.3757%2017.4889L17.6177%2017.3118L17.5278%2017.189H17.3757V17.4889ZM20.133%2021.2573L19.8909%2021.4345L19.8967%2021.4419L20.133%2021.2573ZM20.1386%2021.2645L19.9023%2021.4491V21.4491L20.1386%2021.2645ZM21.8647%2021.2666L22.1001%2021.4522L22.1005%2021.4517L21.8647%2021.2666ZM21.8661%2021.2648L22.1019%2021.45L22.1023%2021.4495L21.8661%2021.2648ZM22.505%2020.4477L22.7412%2020.6324L22.7431%2020.63L22.505%2020.4477ZM22.5069%2020.4453L22.7449%2020.6276L22.745%2020.6275L22.5069%2020.4453ZM22.5076%2019.1075L22.2651%2019.2841L22.2702%2019.2907L22.5076%2019.1075ZM19.8102%2015.4041L20.0527%2015.2275L20.0463%2015.2193L19.8102%2015.4041ZM19.804%2015.3963L19.5679%2015.5811L19.5679%2015.5811L19.804%2015.3963ZM9.44828%2017.1626C9.44828%2015.7496%2010.6043%2014.5935%2012.0173%2014.5935V13.9939C10.2731%2013.9939%208.8486%2015.4184%208.8486%2017.1626H9.44828ZM9.44828%2031.8372V17.1626H8.8486V31.8372H9.44828ZM12.0173%2034.4063C10.6022%2034.4063%209.44828%2033.2501%209.44828%2031.8372H8.8486C8.8486%2033.581%2010.2707%2035.006%2012.0173%2035.006V34.4063ZM17.5428%2034.4063H12.0173V35.006H17.5428V34.4063ZM19.5676%2033.4211C19.0766%2034.0462%2018.3393%2034.4063%2017.5428%2034.4063V35.006C18.524%2035.006%2019.4349%2034.5608%2020.0392%2033.7915L19.5676%2033.4211ZM19.5681%2033.4205L19.5674%2033.4214L20.0394%2033.7912L20.0401%2033.7903L19.5681%2033.4205ZM32.1764%2017.3022L19.5679%2033.4206L20.0403%2033.7901L32.6488%2017.6717L32.1764%2017.3022ZM37.4552%2017.1871H32.4126V17.7868H37.4552V17.1871ZM37.755%2031.509V17.4869H37.1553V31.509H37.755ZM32.4204%2031.8089H37.4552V31.2092H32.4204V31.8089ZM29.651%2028.1464L32.1763%2031.6833L32.6644%2031.3348L30.1391%2027.7979L29.651%2028.1464ZM29.6511%2028.1469L29.6594%2028.1575L30.1307%2027.7867L30.1224%2027.7761L29.6511%2028.1469ZM28.3992%2028.1469C28.7176%2027.7422%2029.3327%2027.7422%2029.6511%2028.1469L30.1224%2027.7761C29.5639%2027.0662%2028.4864%2027.0662%2027.9279%2027.7761L28.3992%2028.1469ZM27.7558%2028.9666L28.3994%2028.1466L27.9277%2027.7764L27.2841%2028.5964L27.7558%2028.9666ZM27.7559%2028.9665L27.7581%2028.9637L27.2819%2028.5993L27.2797%2028.6021L27.7559%2028.9665ZM27.7508%2029.9365C27.5333%2029.65%2027.5374%2029.2521%2027.7559%2028.9665L27.2797%2028.6021C26.9002%2029.098%2026.8893%2029.7935%2027.2732%2030.2991L27.7508%2029.9365ZM30.2293%2033.4197L27.7563%2029.944L27.2677%2030.2916L29.7407%2033.7674L30.2293%2033.4197ZM30.2293%2033.4191L30.2208%2033.4083L29.7492%2033.7788L29.7577%2033.7896L30.2293%2033.4191ZM32.2541%2034.4044C31.4617%2034.4044%2030.7205%2034.0445%2030.2293%2033.4191L29.7577%2033.7896C30.3625%2034.5595%2031.2775%2035.0041%2032.2541%2035.0041V34.4044ZM37.7795%2034.4044H32.2541V35.0041H37.7795V34.4044ZM40.3486%2031.8353C40.3486%2033.2521%2039.1985%2034.4044%2037.7795%2034.4044V35.0041C39.5301%2035.0041%2040.9483%2033.5829%2040.9483%2031.8353H40.3486ZM40.3486%2017.1626V31.8353H40.9483V17.1626H40.3486ZM37.7795%2014.5935C39.1987%2014.5935%2040.3486%2015.7479%2040.3486%2017.1626H40.9483C40.9483%2015.4174%2039.5305%2013.9939%2037.7795%2013.9939V14.5935ZM32.2541%2014.5935H37.7795V13.9939H32.2541V14.5935ZM30.2289%2015.5812C30.72%2014.9537%2031.4596%2014.5935%2032.2541%2014.5935V13.9939C31.2749%2013.9939%2030.3613%2014.4391%2029.7567%2015.2116L30.2289%2015.5812ZM17.6219%2031.6957L30.2289%2015.5811L29.7566%2015.2116L17.1496%2031.3262L17.6219%2031.6957ZM12.3417%2031.8108H17.3858V31.2111H12.3417V31.8108ZM12.0418%2017.4889V31.511H12.6415V17.4889H12.0418ZM17.3757%2017.189H12.3417V17.7887H17.3757V17.189ZM20.375%2021.0803L17.6177%2017.3118L17.1337%2017.6659L19.891%2021.4344L20.375%2021.0803ZM20.3749%2021.08L20.3693%2021.0728L19.8967%2021.4419L19.9023%2021.4491L20.3749%2021.08ZM21.6292%2021.0809C21.3091%2021.4869%2020.6937%2021.488%2020.3749%2021.08L19.9023%2021.4491C20.4627%2022.1665%2021.5415%2022.1608%2022.1001%2021.4522L21.6292%2021.0809ZM21.6302%2021.0796L21.6288%2021.0814L22.1005%2021.4517L22.1019%2021.45L21.6302%2021.0796ZM22.2688%2020.263L21.6299%2021.0801L22.1023%2021.4495L22.7412%2020.6324L22.2688%2020.263ZM22.2688%2020.263L22.2669%2020.2654L22.7431%2020.63L22.7449%2020.6276L22.2688%2020.263ZM22.2702%2019.2907C22.4916%2019.5777%2022.4877%2019.977%2022.2687%2020.2631L22.745%2020.6275C23.1252%2020.1307%2023.1363%2019.4315%2022.7449%2018.9243L22.2702%2019.2907ZM19.5678%2015.5807L22.2652%2019.284L22.7499%2018.931L20.0525%2015.2276L19.5678%2015.5807ZM19.5679%2015.5811L19.5741%2015.589L20.0463%2015.2193L20.0401%2015.2114L19.5679%2015.5811ZM17.5428%2014.5935C18.3394%2014.5935%2019.0768%2014.9537%2019.5679%2015.5811L20.0401%2015.2114C19.4357%2014.4393%2018.5243%2013.9939%2017.5428%2013.9939V14.5935ZM12.0173%2014.5935H17.5428V13.9939H12.0173V14.5935Z'%20fill='white'/%3e%3c/svg%3e";function b_(t){let e,n,r,o=!1;return function(s){void 0===e?(e=s,n=0,r=-1):e=function(t,e){const n=new Uint8Array(t.length+e.length);return n.set(t),n.set(e,t.length),n}(e,s);const i=e.length;let l=0;for(;n{const f=Object.assign({},r);let b;function A(){b.abort(),document.hidden||N()}f.accept||(f.accept=Su),l||document.addEventListener("visibilitychange",A);let y=1e3,h=0;function g(){document.removeEventListener("visibilitychange",A),window.clearTimeout(h),b.abort()}null==n||n.addEventListener("abort",(()=>{g(),p()}));const E=u??window.fetch,_=o??C_;async function N(){var v;b=new AbortController;try{const T=await E(t,Object.assign(Object.assign({},c),{headers:f,signal:b.signal}));await _(T),await async function(t,e){const n=t.getReader();let r;for(;!(r=await n.read()).done;)e(r.value)}(T.body,b_(function(t,e,n){let r={data:"",event:"",id:"",retry:void 0};const o=new TextDecoder;return function(s,i){if(0===s.length)null==n||n(r),r={data:"",event:"",id:"",retry:void 0};else if(i>0){const l=o.decode(s.subarray(0,i)),u=i+(32===s[i+1]?2:1),c=o.decode(s.subarray(u));switch(l){case"data":r.data=r.data?r.data+"\n"+c:c;break;case"event":r.event=c;break;case"id":t(r.id=c);break;case"retry":const p=parseInt(c,10);isNaN(p)||e(r.retry=p)}}}}((R=>{R?f[Qf]=R:delete f[Qf]}),(R=>{y=R}),a))),null==s||s(),g(),p()}catch(T){if(!b.signal.aborted)try{const R=null!==(v=null==i?void 0:i(T))&&void 0!==v?v:y;window.clearTimeout(h),h=window.setTimeout(N,R)}catch(R){g(),d(R)}}}N()}))}function C_(t){const e=t.headers.get("content-type");if(null==e||!e.startsWith(Su))throw new Error(`Expected content-type to be ${Su}, Actual: ${e}`)}const Ss={embedSessionHistory:async function(t,e){const{embedId:n,baseApiUrl:r}=t;return await fetch(`${r}/${n}/${e}`).then((o=>{if(o.ok)return o.json();throw new Error("Invalid response from server")})).then((o=>o.history.map((a=>({...a,id:Kn(),sender:"user"===a.role?"user":"system",textResponse:a.content,close:!1}))))).catch((o=>(console.error(o),[])))},resetEmbedChatSession:async function(t,e){const{baseApiUrl:n,embedId:r}=t;return await fetch(`${n}/${r}/${e}`,{method:"DELETE"}).then((o=>o.ok)).catch((()=>!1))},streamChat:async function(t,e,n,r){const{baseApiUrl:o,embedId:a,username:s}=e,i={prompt:(null==e?void 0:e.prompt)??null,model:(null==e?void 0:e.model)??null,temperature:(null==e?void 0:e.temperature)??null},l=new AbortController;await D_(`${o}/${a}/stream-chat`,{method:"POST",body:JSON.stringify({message:n,sessionId:t,username:s,...i}),signal:l.signal,openWhenHidden:!0,async onopen(u){if(!u.ok)throw u.status>=400?(await u.json().then((c=>{r(c)})).catch((()=>{r({id:Kn(),type:"abort",textResponse:null,sources:[],close:!0,error:`An error occurred while streaming response. Code ${u.status}`})})),l.abort(),new Error):(r({id:Kn(),type:"abort",textResponse:null,sources:[],close:!0,error:"An error occurred while streaming response. Unknown Error."}),l.abort(),new Error("Unknown Error"))},async onmessage(u){try{const c=JSON.parse(u.data);r(c)}catch{}},onerror(u){throw r({id:Kn(),type:"abort",textResponse:null,sources:[],close:!0,error:`An error occurred while streaming response. ${u.message}`}),l.abort(),new Error}})}};const Jf={};function Nu(){for(var t=arguments.length,e=new Array(t),n=0;n()=>{if(t.isInitialized)e();else{const n=()=>{setTimeout((()=>{t.off("initialized",n)}),0),e()};t.on("initialized",n)}},t2=(t,e,n)=>{t.loadNamespaces(e,e2(t,n))},n2=(t,e,n,r)=>{Yn(n)&&(n=[n]),n.forEach((o=>{t.options.ns.indexOf(o)<0&&t.options.ns.push(o)})),t.loadLanguages(e,e2(t,r))},Yn=t=>"string"==typeof t,x_=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,R_={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},L_=t=>R_[t];let Tu={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:t=>t.replace(x_,L_)};let r2;const F_={type:"3rdParty",init(t){(function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};Tu={...Tu,...t}})(t.options.react),(t=>{r2=t})(t)}},o2=U.createContext();class B_{constructor(){r1(this,"getUsedNamespaces",(()=>Object.keys(this.usedNamespaces))),this.usedNamespaces={}}addUsedNamespaces(e){e.forEach((n=>{this.usedNamespaces[n]||(this.usedNamespaces[n]=!0)}))}}const a2=(t,e,n,r)=>t.getFixedT(e,n,r),Ho=function(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{i18n:n}=e,{i18n:r,defaultNS:o}=U.useContext(o2)||{},a=n||r||r2;if(a&&!a.reportNamespaces&&(a.reportNamespaces=new B_),!a){Nu("You will need to pass in an i18next instance by using initReactI18next");const _=(v,T)=>Yn(T)?T:(t=>"object"==typeof t&&null!==t)(T)&&Yn(T.defaultValue)?T.defaultValue:Array.isArray(v)?v[v.length-1]:v,N=[_,{},!1];return N.t=_,N.i18n={},N.ready=!1,N}a.options.react&&void 0!==a.options.react.wait&&Nu("It seems you are still using the old wait option, you may migrate to the new useSuspense behaviour.");const s={...Tu,...a.options.react,...e},{useSuspense:i,keyPrefix:l}=s;let u=t||o||a.options&&a.options.defaultNS;u=Yn(u)?[u]:u||["translation"],a.reportNamespaces.addUsedNamespaces&&a.reportNamespaces.addUsedNamespaces(u);const c=(a.isInitialized||a.initializedStoreOnce)&&u.every((_=>function(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return e.languages&&e.languages.length?void 0!==e.options.ignoreJSONStructure?e.hasLoadedNamespace(t,{lng:n.lng,precheck:(o,a)=>{if(n.bindI18n&&n.bindI18n.indexOf("languageChanging")>-1&&o.services.backendConnector.backend&&o.isLanguageChangingTo&&!a(o.isLanguageChangingTo,t))return!1}}):function(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const r=e.languages[0],o=!!e.options&&e.options.fallbackLng,a=e.languages[e.languages.length-1];if("cimode"===r.toLowerCase())return!0;const s=(i,l)=>{const u=e.services.backendConnector.state[`${i}|${l}`];return-1===u||2===u};return!(n.bindI18n&&n.bindI18n.indexOf("languageChanging")>-1&&e.services.backendConnector.backend&&e.isLanguageChangingTo&&!s(e.isLanguageChangingTo,t)||!(e.hasResourceBundle(r,t)||!e.services.backendConnector.backend||e.options.resources&&!e.options.partialBundledLanguages||s(r,t)&&(!o||s(a,t))))}(t,e,n):(Nu("i18n.languages were undefined or empty",e.languages),!0)}(_,a,s))),p=((t,e,n,r)=>U.useCallback(a2(t,e,n,r),[t,e,n,r]))(a,e.lng||null,"fallback"===s.nsMode?u:u[0],l),d=()=>p,f=()=>a2(a,e.lng||null,"fallback"===s.nsMode?u:u[0],l),[b,A]=U.useState(d);let y=u.join();e.lng&&(y=`${e.lng}${y}`);const h=((t,e)=>{const n=U.useRef();return U.useEffect((()=>{n.current=e?n.current:t}),[t,e]),n.current})(y),g=U.useRef(!0);U.useEffect((()=>{const{bindI18n:_,bindI18nStore:N}=s;g.current=!0,!c&&!i&&(e.lng?n2(a,e.lng,u,(()=>{g.current&&A(f)})):t2(a,u,(()=>{g.current&&A(f)}))),c&&h&&h!==y&&g.current&&A(f);const v=()=>{g.current&&A(f)};return _&&a&&a.on(_,v),N&&a&&a.store.on(N,v),()=>{g.current=!1,_&&a&&_.split(" ").forEach((T=>a.off(T,v))),N&&a&&N.split(" ").forEach((T=>a.store.off(T,v)))}}),[a,y]),U.useEffect((()=>{g.current&&c&&A(d)}),[a,l,c]);const E=[b,a,c];if(E.t=b,E.i18n=a,E.ready=c,c||!c&&!i)return E;throw new Promise((_=>{e.lng?n2(a,e.lng,u,(()=>_())):t2(a,u,(()=>_()))}))};function q_(t){let{i18n:e,defaultNS:n,children:r}=t;const o=U.useMemo((()=>({i18n:e,defaultNS:n})),[e,n]);return U.createElement(o2.Provider,{value:o},r)}function s2({sessionId:t,settings:e={},iconUrl:n=null,closeChat:r,setChatHistory:o}){const[a,s]=U.useState(!1),i=U.useRef(),l=U.useRef();return U.useEffect((()=>{function c(p){i.current&&!i.current.contains(p.target)&&l.current&&!l.current.contains(p.target)&&s(!1)}return document.addEventListener("mousedown",c),()=>{document.removeEventListener("mousedown",c)}}),[i]),w.jsxs("div",{style:{borderBottom:"1px solid #E9E9E9"},className:"allm-flex allm-items-center allm-relative allm-rounded-t-2xl",id:"anything-llm-header",children:[w.jsx("div",{className:"allm-flex allm-justify-center allm-items-center allm-w-full allm-h-[76px]",children:w.jsx("img",{style:{maxWidth:48,maxHeight:48},src:n??qo,alt:n?"Brand":"AnythingLLM Logo"})}),w.jsxs("div",{className:"allm-absolute allm-right-0 allm-flex allm-gap-x-1 allm-items-center allm-px-[22px]",children:[e.loaded&&w.jsx("button",{ref:l,type:"button",onClick:()=>s(!a),className:"allm-bg-transparent hover:allm-cursor-pointer allm-border-none hover:allm-bg-gray-100 allm-rounded-sm allm-text-slate-800/60","aria-label":"Options",children:w.jsx(Tf,{size:20,weight:"fill"})}),w.jsx("button",{type:"button",onClick:r,className:"allm-bg-transparent hover:allm-cursor-pointer allm-border-none hover:allm-bg-gray-100 allm-rounded-sm allm-text-slate-800/60","aria-label":"Close",children:w.jsx(Yf,{size:20,weight:"bold"})})]}),w.jsx(H_,{settings:e,showing:a,resetChat:async()=>{await Ss.resetEmbedChatSession(e,t),o([]),s(!1)},sessionId:t,menuRef:i})]})}function H_({settings:t,showing:e,resetChat:n,sessionId:r,menuRef:o}){if(!e)return null;const{t:a}=Ho();return w.jsxs("div",{ref:o,className:"allm-bg-white allm-absolute allm-z-10 allm-flex allm-flex-col allm-gap-y-1 allm-rounded-xl allm-shadow-lg allm-top-[64px] allm-right-[46px]",children:[w.jsxs("button",{onClick:n,className:"hover:allm-cursor-pointer allm-bg-white allm-gap-x-[12px] hover:allm-bg-gray-100 allm-rounded-lg allm-border-none allm-flex allm-items-center allm-text-base allm-text-[#7A7D7E] allm-font-bold allm-px-4",children:[w.jsx(of,{size:24}),w.jsx("p",{className:"allm-text-[14px]",children:a("chat.reset-chat")})]}),w.jsx($_,{email:t.supportEmail}),w.jsx(V_,{sessionId:r})]})}function V_({sessionId:t}){if(!t)return null;const{t:e}=Ho(),[n,r]=U.useState(!1);return n?w.jsxs("div",{className:"hover:allm-cursor-pointer allm-bg-white allm-gap-x-[12px] hover:allm-bg-gray-100 allm-rounded-lg allm-border-none allm-flex allm-items-center allm-text-base allm-text-[#7A7D7E] allm-font-bold allm-px-4",children:[w.jsx(Af,{size:24}),w.jsx("p",{className:"allm-text-[14px] allm-font-sans",children:e("chat.message-copied")})]}):w.jsxs("button",{onClick:()=>{navigator.clipboard.writeText(t),r(!0),setTimeout((()=>r(!1)),1e3)},className:"hover:allm-cursor-pointer allm-bg-white allm-gap-x-[12px] hover:allm-bg-gray-100 allm-rounded-lg allm-border-none allm-flex allm-items-center allm-text-base allm-text-[#7A7D7E] allm-font-bold allm-px-4",children:[w.jsx(Cf,{size:24}),w.jsx("p",{className:"allm-text-[14px]",children:e("chat.session-id")})]})}function $_({email:t=null}){if(!t)return null;const{t:e}=Ho(),n=`Inquiry from ${window.location.origin}`;return w.jsxs("a",{href:`mailto:${t}?Subject=${encodeURIComponent(n)}`,className:"allm-no-underline hover:allm-underline hover:allm-cursor-pointer allm-bg-white allm-gap-x-[12px] hover:allm-bg-gray-100 allm-rounded-lg allm-border-none allm-flex allm-items-center allm-text-base allm-text-[#7A7D7E] allm-font-bold allm-px-4",children:[w.jsx(Rf,{size:24}),w.jsx("p",{className:"allm-text-[14px] allm-font-sans",children:e("chat.email-support")})]})}function z_(){const t=Y0();return t?w.jsx("div",{className:"allm-text-xs allm-text-gray-300 allm-w-full allm-text-center",children:t}):null}var Ns={exports:{}};/*! https://mths.be/he v1.2.0 by @mathias | MIT license */!function(t,e){!function(n){var r=e,o=t&&t.exports==r&&t,a="object"==typeof xe&&xe;(a.global===a||a.window===a)&&(n=a);var s=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,i=/[\x01-\x7F]/g,l=/[\x01-\t\x0B\f\x0E-\x1F\x7F\x81\x8D\x8F\x90\x9D\xA0-\uFFFF]/g,u=/<\u20D2|=\u20E5|>\u20D2|\u205F\u200A|\u219D\u0338|\u2202\u0338|\u2220\u20D2|\u2229\uFE00|\u222A\uFE00|\u223C\u20D2|\u223D\u0331|\u223E\u0333|\u2242\u0338|\u224B\u0338|\u224D\u20D2|\u224E\u0338|\u224F\u0338|\u2250\u0338|\u2261\u20E5|\u2264\u20D2|\u2265\u20D2|\u2266\u0338|\u2267\u0338|\u2268\uFE00|\u2269\uFE00|\u226A\u0338|\u226A\u20D2|\u226B\u0338|\u226B\u20D2|\u227F\u0338|\u2282\u20D2|\u2283\u20D2|\u228A\uFE00|\u228B\uFE00|\u228F\u0338|\u2290\u0338|\u2293\uFE00|\u2294\uFE00|\u22B4\u20D2|\u22B5\u20D2|\u22D8\u0338|\u22D9\u0338|\u22DA\uFE00|\u22DB\uFE00|\u22F5\u0338|\u22F9\u0338|\u2933\u0338|\u29CF\u0338|\u29D0\u0338|\u2A6D\u0338|\u2A70\u0338|\u2A7D\u0338|\u2A7E\u0338|\u2AA1\u0338|\u2AA2\u0338|\u2AAC\uFE00|\u2AAD\uFE00|\u2AAF\u0338|\u2AB0\u0338|\u2AC5\u0338|\u2AC6\u0338|\u2ACB\uFE00|\u2ACC\uFE00|\u2AFD\u20E5|[\xA0-\u0113\u0116-\u0122\u0124-\u012B\u012E-\u014D\u0150-\u017E\u0192\u01B5\u01F5\u0237\u02C6\u02C7\u02D8-\u02DD\u0311\u0391-\u03A1\u03A3-\u03A9\u03B1-\u03C9\u03D1\u03D2\u03D5\u03D6\u03DC\u03DD\u03F0\u03F1\u03F5\u03F6\u0401-\u040C\u040E-\u044F\u0451-\u045C\u045E\u045F\u2002-\u2005\u2007-\u2010\u2013-\u2016\u2018-\u201A\u201C-\u201E\u2020-\u2022\u2025\u2026\u2030-\u2035\u2039\u203A\u203E\u2041\u2043\u2044\u204F\u2057\u205F-\u2063\u20AC\u20DB\u20DC\u2102\u2105\u210A-\u2113\u2115-\u211E\u2122\u2124\u2127-\u2129\u212C\u212D\u212F-\u2131\u2133-\u2138\u2145-\u2148\u2153-\u215E\u2190-\u219B\u219D-\u21A7\u21A9-\u21AE\u21B0-\u21B3\u21B5-\u21B7\u21BA-\u21DB\u21DD\u21E4\u21E5\u21F5\u21FD-\u2205\u2207-\u2209\u220B\u220C\u220F-\u2214\u2216-\u2218\u221A\u221D-\u2238\u223A-\u2257\u2259\u225A\u225C\u225F-\u2262\u2264-\u228B\u228D-\u229B\u229D-\u22A5\u22A7-\u22B0\u22B2-\u22BB\u22BD-\u22DB\u22DE-\u22E3\u22E6-\u22F7\u22F9-\u22FE\u2305\u2306\u2308-\u2310\u2312\u2313\u2315\u2316\u231C-\u231F\u2322\u2323\u232D\u232E\u2336\u233D\u233F\u237C\u23B0\u23B1\u23B4-\u23B6\u23DC-\u23DF\u23E2\u23E7\u2423\u24C8\u2500\u2502\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C\u2550-\u256C\u2580\u2584\u2588\u2591-\u2593\u25A1\u25AA\u25AB\u25AD\u25AE\u25B1\u25B3-\u25B5\u25B8\u25B9\u25BD-\u25BF\u25C2\u25C3\u25CA\u25CB\u25EC\u25EF\u25F8-\u25FC\u2605\u2606\u260E\u2640\u2642\u2660\u2663\u2665\u2666\u266A\u266D-\u266F\u2713\u2717\u2720\u2736\u2758\u2772\u2773\u27C8\u27C9\u27E6-\u27ED\u27F5-\u27FA\u27FC\u27FF\u2902-\u2905\u290C-\u2913\u2916\u2919-\u2920\u2923-\u292A\u2933\u2935-\u2939\u293C\u293D\u2945\u2948-\u294B\u294E-\u2976\u2978\u2979\u297B-\u297F\u2985\u2986\u298B-\u2996\u299A\u299C\u299D\u29A4-\u29B7\u29B9\u29BB\u29BC\u29BE-\u29C5\u29C9\u29CD-\u29D0\u29DC-\u29DE\u29E3-\u29E5\u29EB\u29F4\u29F6\u2A00-\u2A02\u2A04\u2A06\u2A0C\u2A0D\u2A10-\u2A17\u2A22-\u2A27\u2A29\u2A2A\u2A2D-\u2A31\u2A33-\u2A3C\u2A3F\u2A40\u2A42-\u2A4D\u2A50\u2A53-\u2A58\u2A5A-\u2A5D\u2A5F\u2A66\u2A6A\u2A6D-\u2A75\u2A77-\u2A9A\u2A9D-\u2AA2\u2AA4-\u2AB0\u2AB3-\u2AC8\u2ACB\u2ACC\u2ACF-\u2ADB\u2AE4\u2AE6-\u2AE9\u2AEB-\u2AF3\u2AFD\uFB00-\uFB04]|\uD835[\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDCCF\uDD04\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDD6B]/g,c={"­":"shy","‌":"zwnj","‍":"zwj","‎":"lrm","⁣":"ic","⁢":"it","⁡":"af","‏":"rlm","​":"ZeroWidthSpace","⁠":"NoBreak","̑":"DownBreve","⃛":"tdot","⃜":"DotDot","\t":"Tab","\n":"NewLine"," ":"puncsp"," ":"MediumSpace"," ":"thinsp"," ":"hairsp"," ":"emsp13"," ":"ensp"," ":"emsp14"," ":"emsp"," ":"numsp"," ":"nbsp","  ":"ThickSpace","‾":"oline",_:"lowbar","‐":"dash","–":"ndash","—":"mdash","―":"horbar",",":"comma",";":"semi","⁏":"bsemi",":":"colon","⩴":"Colone","!":"excl","¡":"iexcl","?":"quest","¿":"iquest",".":"period","‥":"nldr","…":"mldr","·":"middot","'":"apos","‘":"lsquo","’":"rsquo","‚":"sbquo","‹":"lsaquo","›":"rsaquo",'"':"quot","“":"ldquo","”":"rdquo","„":"bdquo","«":"laquo","»":"raquo","(":"lpar",")":"rpar","[":"lsqb","]":"rsqb","{":"lcub","}":"rcub","⌈":"lceil","⌉":"rceil","⌊":"lfloor","⌋":"rfloor","⦅":"lopar","⦆":"ropar","⦋":"lbrke","⦌":"rbrke","⦍":"lbrkslu","⦎":"rbrksld","⦏":"lbrksld","⦐":"rbrkslu","⦑":"langd","⦒":"rangd","⦓":"lparlt","⦔":"rpargt","⦕":"gtlPar","⦖":"ltrPar","⟦":"lobrk","⟧":"robrk","⟨":"lang","⟩":"rang","⟪":"Lang","⟫":"Rang","⟬":"loang","⟭":"roang","❲":"lbbrk","❳":"rbbrk","‖":"Vert","§":"sect","¶":"para","@":"commat","*":"ast","/":"sol",undefined:null,"&":"amp","#":"num","%":"percnt","‰":"permil","‱":"pertenk","†":"dagger","‡":"Dagger","•":"bull","⁃":"hybull","′":"prime","″":"Prime","‴":"tprime","⁗":"qprime","‵":"bprime","⁁":"caret","`":"grave","´":"acute","˜":"tilde","^":"Hat","¯":"macr","˘":"breve","˙":"dot","¨":"die","˚":"ring","˝":"dblac","¸":"cedil","˛":"ogon","ˆ":"circ","ˇ":"caron","°":"deg","©":"copy","®":"reg","℗":"copysr","℘":"wp","℞":"rx","℧":"mho","℩":"iiota","←":"larr","↚":"nlarr","→":"rarr","↛":"nrarr","↑":"uarr","↓":"darr","↔":"harr","↮":"nharr","↕":"varr","↖":"nwarr","↗":"nearr","↘":"searr","↙":"swarr","↝":"rarrw","↝̸":"nrarrw","↞":"Larr","↟":"Uarr","↠":"Rarr","↡":"Darr","↢":"larrtl","↣":"rarrtl","↤":"mapstoleft","↥":"mapstoup","↦":"map","↧":"mapstodown","↩":"larrhk","↪":"rarrhk","↫":"larrlp","↬":"rarrlp","↭":"harrw","↰":"lsh","↱":"rsh","↲":"ldsh","↳":"rdsh","↵":"crarr","↶":"cularr","↷":"curarr","↺":"olarr","↻":"orarr","↼":"lharu","↽":"lhard","↾":"uharr","↿":"uharl","⇀":"rharu","⇁":"rhard","⇂":"dharr","⇃":"dharl","⇄":"rlarr","⇅":"udarr","⇆":"lrarr","⇇":"llarr","⇈":"uuarr","⇉":"rrarr","⇊":"ddarr","⇋":"lrhar","⇌":"rlhar","⇐":"lArr","⇍":"nlArr","⇑":"uArr","⇒":"rArr","⇏":"nrArr","⇓":"dArr","⇔":"iff","⇎":"nhArr","⇕":"vArr","⇖":"nwArr","⇗":"neArr","⇘":"seArr","⇙":"swArr","⇚":"lAarr","⇛":"rAarr","⇝":"zigrarr","⇤":"larrb","⇥":"rarrb","⇵":"duarr","⇽":"loarr","⇾":"roarr","⇿":"hoarr","∀":"forall","∁":"comp","∂":"part","∂̸":"npart","∃":"exist","∄":"nexist","∅":"empty","∇":"Del","∈":"in","∉":"notin","∋":"ni","∌":"notni","϶":"bepsi","∏":"prod","∐":"coprod","∑":"sum","+":"plus","±":"pm","÷":"div","×":"times","<":"lt","≮":"nlt","<⃒":"nvlt","=":"equals","≠":"ne","=⃥":"bne","⩵":"Equal",">":"gt","≯":"ngt",">⃒":"nvgt","¬":"not","|":"vert","¦":"brvbar","−":"minus","∓":"mp","∔":"plusdo","⁄":"frasl","∖":"setmn","∗":"lowast","∘":"compfn","√":"Sqrt","∝":"prop","∞":"infin","∟":"angrt","∠":"ang","∠⃒":"nang","∡":"angmsd","∢":"angsph","∣":"mid","∤":"nmid","∥":"par","∦":"npar","∧":"and","∨":"or","∩":"cap","∩︀":"caps","∪":"cup","∪︀":"cups","∫":"int","∬":"Int","∭":"tint","⨌":"qint","∮":"oint","∯":"Conint","∰":"Cconint","∱":"cwint","∲":"cwconint","∳":"awconint","∴":"there4","∵":"becaus","∶":"ratio","∷":"Colon","∸":"minusd","∺":"mDDot","∻":"homtht","∼":"sim","≁":"nsim","∼⃒":"nvsim","∽":"bsim","∽̱":"race","∾":"ac","∾̳":"acE","∿":"acd","≀":"wr","≂":"esim","≂̸":"nesim","≃":"sime","≄":"nsime","≅":"cong","≇":"ncong","≆":"simne","≈":"ap","≉":"nap","≊":"ape","≋":"apid","≋̸":"napid","≌":"bcong","≍":"CupCap","≭":"NotCupCap","≍⃒":"nvap","≎":"bump","≎̸":"nbump","≏":"bumpe","≏̸":"nbumpe","≐":"doteq","≐̸":"nedot","≑":"eDot","≒":"efDot","≓":"erDot","≔":"colone","≕":"ecolon","≖":"ecir","≗":"cire","≙":"wedgeq","≚":"veeeq","≜":"trie","≟":"equest","≡":"equiv","≢":"nequiv","≡⃥":"bnequiv","≤":"le","≰":"nle","≤⃒":"nvle","≥":"ge","≱":"nge","≥⃒":"nvge","≦":"lE","≦̸":"nlE","≧":"gE","≧̸":"ngE","≨︀":"lvnE","≨":"lnE","≩":"gnE","≩︀":"gvnE","≪":"ll","≪̸":"nLtv","≪⃒":"nLt","≫":"gg","≫̸":"nGtv","≫⃒":"nGt","≬":"twixt","≲":"lsim","≴":"nlsim","≳":"gsim","≵":"ngsim","≶":"lg","≸":"ntlg","≷":"gl","≹":"ntgl","≺":"pr","⊀":"npr","≻":"sc","⊁":"nsc","≼":"prcue","⋠":"nprcue","≽":"sccue","⋡":"nsccue","≾":"prsim","≿":"scsim","≿̸":"NotSucceedsTilde","⊂":"sub","⊄":"nsub","⊂⃒":"vnsub","⊃":"sup","⊅":"nsup","⊃⃒":"vnsup","⊆":"sube","⊈":"nsube","⊇":"supe","⊉":"nsupe","⊊︀":"vsubne","⊊":"subne","⊋︀":"vsupne","⊋":"supne","⊍":"cupdot","⊎":"uplus","⊏":"sqsub","⊏̸":"NotSquareSubset","⊐":"sqsup","⊐̸":"NotSquareSuperset","⊑":"sqsube","⋢":"nsqsube","⊒":"sqsupe","⋣":"nsqsupe","⊓":"sqcap","⊓︀":"sqcaps","⊔":"sqcup","⊔︀":"sqcups","⊕":"oplus","⊖":"ominus","⊗":"otimes","⊘":"osol","⊙":"odot","⊚":"ocir","⊛":"oast","⊝":"odash","⊞":"plusb","⊟":"minusb","⊠":"timesb","⊡":"sdotb","⊢":"vdash","⊬":"nvdash","⊣":"dashv","⊤":"top","⊥":"bot","⊧":"models","⊨":"vDash","⊭":"nvDash","⊩":"Vdash","⊮":"nVdash","⊪":"Vvdash","⊫":"VDash","⊯":"nVDash","⊰":"prurel","⊲":"vltri","⋪":"nltri","⊳":"vrtri","⋫":"nrtri","⊴":"ltrie","⋬":"nltrie","⊴⃒":"nvltrie","⊵":"rtrie","⋭":"nrtrie","⊵⃒":"nvrtrie","⊶":"origof","⊷":"imof","⊸":"mumap","⊹":"hercon","⊺":"intcal","⊻":"veebar","⊽":"barvee","⊾":"angrtvb","⊿":"lrtri","⋀":"Wedge","⋁":"Vee","⋂":"xcap","⋃":"xcup","⋄":"diam","⋅":"sdot","⋆":"Star","⋇":"divonx","⋈":"bowtie","⋉":"ltimes","⋊":"rtimes","⋋":"lthree","⋌":"rthree","⋍":"bsime","⋎":"cuvee","⋏":"cuwed","⋐":"Sub","⋑":"Sup","⋒":"Cap","⋓":"Cup","⋔":"fork","⋕":"epar","⋖":"ltdot","⋗":"gtdot","⋘":"Ll","⋘̸":"nLl","⋙":"Gg","⋙̸":"nGg","⋚︀":"lesg","⋚":"leg","⋛":"gel","⋛︀":"gesl","⋞":"cuepr","⋟":"cuesc","⋦":"lnsim","⋧":"gnsim","⋨":"prnsim","⋩":"scnsim","⋮":"vellip","⋯":"ctdot","⋰":"utdot","⋱":"dtdot","⋲":"disin","⋳":"isinsv","⋴":"isins","⋵":"isindot","⋵̸":"notindot","⋶":"notinvc","⋷":"notinvb","⋹":"isinE","⋹̸":"notinE","⋺":"nisd","⋻":"xnis","⋼":"nis","⋽":"notnivc","⋾":"notnivb","⌅":"barwed","⌆":"Barwed","⌌":"drcrop","⌍":"dlcrop","⌎":"urcrop","⌏":"ulcrop","⌐":"bnot","⌒":"profline","⌓":"profsurf","⌕":"telrec","⌖":"target","⌜":"ulcorn","⌝":"urcorn","⌞":"dlcorn","⌟":"drcorn","⌢":"frown","⌣":"smile","⌭":"cylcty","⌮":"profalar","⌶":"topbot","⌽":"ovbar","⌿":"solbar","⍼":"angzarr","⎰":"lmoust","⎱":"rmoust","⎴":"tbrk","⎵":"bbrk","⎶":"bbrktbrk","⏜":"OverParenthesis","⏝":"UnderParenthesis","⏞":"OverBrace","⏟":"UnderBrace","⏢":"trpezium","⏧":"elinters","␣":"blank","─":"boxh","│":"boxv","┌":"boxdr","┐":"boxdl","└":"boxur","┘":"boxul","├":"boxvr","┤":"boxvl","┬":"boxhd","┴":"boxhu","┼":"boxvh","═":"boxH","║":"boxV","╒":"boxdR","╓":"boxDr","╔":"boxDR","╕":"boxdL","╖":"boxDl","╗":"boxDL","╘":"boxuR","╙":"boxUr","╚":"boxUR","╛":"boxuL","╜":"boxUl","╝":"boxUL","╞":"boxvR","╟":"boxVr","╠":"boxVR","╡":"boxvL","╢":"boxVl","╣":"boxVL","╤":"boxHd","╥":"boxhD","╦":"boxHD","╧":"boxHu","╨":"boxhU","╩":"boxHU","╪":"boxvH","╫":"boxVh","╬":"boxVH","▀":"uhblk","▄":"lhblk","█":"block","░":"blk14","▒":"blk12","▓":"blk34","□":"squ","▪":"squf","▫":"EmptyVerySmallSquare","▭":"rect","▮":"marker","▱":"fltns","△":"xutri","▴":"utrif","▵":"utri","▸":"rtrif","▹":"rtri","▽":"xdtri","▾":"dtrif","▿":"dtri","◂":"ltrif","◃":"ltri","◊":"loz","○":"cir","◬":"tridot","◯":"xcirc","◸":"ultri","◹":"urtri","◺":"lltri","◻":"EmptySmallSquare","◼":"FilledSmallSquare","★":"starf","☆":"star","☎":"phone","♀":"female","♂":"male","♠":"spades","♣":"clubs","♥":"hearts","♦":"diams","♪":"sung","✓":"check","✗":"cross","✠":"malt","✶":"sext","❘":"VerticalSeparator","⟈":"bsolhsub","⟉":"suphsol","⟵":"xlarr","⟶":"xrarr","⟷":"xharr","⟸":"xlArr","⟹":"xrArr","⟺":"xhArr","⟼":"xmap","⟿":"dzigrarr","⤂":"nvlArr","⤃":"nvrArr","⤄":"nvHarr","⤅":"Map","⤌":"lbarr","⤍":"rbarr","⤎":"lBarr","⤏":"rBarr","⤐":"RBarr","⤑":"DDotrahd","⤒":"UpArrowBar","⤓":"DownArrowBar","⤖":"Rarrtl","⤙":"latail","⤚":"ratail","⤛":"lAtail","⤜":"rAtail","⤝":"larrfs","⤞":"rarrfs","⤟":"larrbfs","⤠":"rarrbfs","⤣":"nwarhk","⤤":"nearhk","⤥":"searhk","⤦":"swarhk","⤧":"nwnear","⤨":"toea","⤩":"tosa","⤪":"swnwar","⤳":"rarrc","⤳̸":"nrarrc","⤵":"cudarrr","⤶":"ldca","⤷":"rdca","⤸":"cudarrl","⤹":"larrpl","⤼":"curarrm","⤽":"cularrp","⥅":"rarrpl","⥈":"harrcir","⥉":"Uarrocir","⥊":"lurdshar","⥋":"ldrushar","⥎":"LeftRightVector","⥏":"RightUpDownVector","⥐":"DownLeftRightVector","⥑":"LeftUpDownVector","⥒":"LeftVectorBar","⥓":"RightVectorBar","⥔":"RightUpVectorBar","⥕":"RightDownVectorBar","⥖":"DownLeftVectorBar","⥗":"DownRightVectorBar","⥘":"LeftUpVectorBar","⥙":"LeftDownVectorBar","⥚":"LeftTeeVector","⥛":"RightTeeVector","⥜":"RightUpTeeVector","⥝":"RightDownTeeVector","⥞":"DownLeftTeeVector","⥟":"DownRightTeeVector","⥠":"LeftUpTeeVector","⥡":"LeftDownTeeVector","⥢":"lHar","⥣":"uHar","⥤":"rHar","⥥":"dHar","⥦":"luruhar","⥧":"ldrdhar","⥨":"ruluhar","⥩":"rdldhar","⥪":"lharul","⥫":"llhard","⥬":"rharul","⥭":"lrhard","⥮":"udhar","⥯":"duhar","⥰":"RoundImplies","⥱":"erarr","⥲":"simrarr","⥳":"larrsim","⥴":"rarrsim","⥵":"rarrap","⥶":"ltlarr","⥸":"gtrarr","⥹":"subrarr","⥻":"suplarr","⥼":"lfisht","⥽":"rfisht","⥾":"ufisht","⥿":"dfisht","⦚":"vzigzag","⦜":"vangrt","⦝":"angrtvbd","⦤":"ange","⦥":"range","⦦":"dwangle","⦧":"uwangle","⦨":"angmsdaa","⦩":"angmsdab","⦪":"angmsdac","⦫":"angmsdad","⦬":"angmsdae","⦭":"angmsdaf","⦮":"angmsdag","⦯":"angmsdah","⦰":"bemptyv","⦱":"demptyv","⦲":"cemptyv","⦳":"raemptyv","⦴":"laemptyv","⦵":"ohbar","⦶":"omid","⦷":"opar","⦹":"operp","⦻":"olcross","⦼":"odsold","⦾":"olcir","⦿":"ofcir","⧀":"olt","⧁":"ogt","⧂":"cirscir","⧃":"cirE","⧄":"solb","⧅":"bsolb","⧉":"boxbox","⧍":"trisb","⧎":"rtriltri","⧏":"LeftTriangleBar","⧏̸":"NotLeftTriangleBar","⧐":"RightTriangleBar","⧐̸":"NotRightTriangleBar","⧜":"iinfin","⧝":"infintie","⧞":"nvinfin","⧣":"eparsl","⧤":"smeparsl","⧥":"eqvparsl","⧫":"lozf","⧴":"RuleDelayed","⧶":"dsol","⨀":"xodot","⨁":"xoplus","⨂":"xotime","⨄":"xuplus","⨆":"xsqcup","⨍":"fpartint","⨐":"cirfnint","⨑":"awint","⨒":"rppolint","⨓":"scpolint","⨔":"npolint","⨕":"pointint","⨖":"quatint","⨗":"intlarhk","⨢":"pluscir","⨣":"plusacir","⨤":"simplus","⨥":"plusdu","⨦":"plussim","⨧":"plustwo","⨩":"mcomma","⨪":"minusdu","⨭":"loplus","⨮":"roplus","⨯":"Cross","⨰":"timesd","⨱":"timesbar","⨳":"smashp","⨴":"lotimes","⨵":"rotimes","⨶":"otimesas","⨷":"Otimes","⨸":"odiv","⨹":"triplus","⨺":"triminus","⨻":"tritime","⨼":"iprod","⨿":"amalg","⩀":"capdot","⩂":"ncup","⩃":"ncap","⩄":"capand","⩅":"cupor","⩆":"cupcap","⩇":"capcup","⩈":"cupbrcap","⩉":"capbrcup","⩊":"cupcup","⩋":"capcap","⩌":"ccups","⩍":"ccaps","⩐":"ccupssm","⩓":"And","⩔":"Or","⩕":"andand","⩖":"oror","⩗":"orslope","⩘":"andslope","⩚":"andv","⩛":"orv","⩜":"andd","⩝":"ord","⩟":"wedbar","⩦":"sdote","⩪":"simdot","⩭":"congdot","⩭̸":"ncongdot","⩮":"easter","⩯":"apacir","⩰":"apE","⩰̸":"napE","⩱":"eplus","⩲":"pluse","⩳":"Esim","⩷":"eDDot","⩸":"equivDD","⩹":"ltcir","⩺":"gtcir","⩻":"ltquest","⩼":"gtquest","⩽":"les","⩽̸":"nles","⩾":"ges","⩾̸":"nges","⩿":"lesdot","⪀":"gesdot","⪁":"lesdoto","⪂":"gesdoto","⪃":"lesdotor","⪄":"gesdotol","⪅":"lap","⪆":"gap","⪇":"lne","⪈":"gne","⪉":"lnap","⪊":"gnap","⪋":"lEg","⪌":"gEl","⪍":"lsime","⪎":"gsime","⪏":"lsimg","⪐":"gsiml","⪑":"lgE","⪒":"glE","⪓":"lesges","⪔":"gesles","⪕":"els","⪖":"egs","⪗":"elsdot","⪘":"egsdot","⪙":"el","⪚":"eg","⪝":"siml","⪞":"simg","⪟":"simlE","⪠":"simgE","⪡":"LessLess","⪡̸":"NotNestedLessLess","⪢":"GreaterGreater","⪢̸":"NotNestedGreaterGreater","⪤":"glj","⪥":"gla","⪦":"ltcc","⪧":"gtcc","⪨":"lescc","⪩":"gescc","⪪":"smt","⪫":"lat","⪬":"smte","⪬︀":"smtes","⪭":"late","⪭︀":"lates","⪮":"bumpE","⪯":"pre","⪯̸":"npre","⪰":"sce","⪰̸":"nsce","⪳":"prE","⪴":"scE","⪵":"prnE","⪶":"scnE","⪷":"prap","⪸":"scap","⪹":"prnap","⪺":"scnap","⪻":"Pr","⪼":"Sc","⪽":"subdot","⪾":"supdot","⪿":"subplus","⫀":"supplus","⫁":"submult","⫂":"supmult","⫃":"subedot","⫄":"supedot","⫅":"subE","⫅̸":"nsubE","⫆":"supE","⫆̸":"nsupE","⫇":"subsim","⫈":"supsim","⫋︀":"vsubnE","⫋":"subnE","⫌︀":"vsupnE","⫌":"supnE","⫏":"csub","⫐":"csup","⫑":"csube","⫒":"csupe","⫓":"subsup","⫔":"supsub","⫕":"subsub","⫖":"supsup","⫗":"suphsub","⫘":"supdsub","⫙":"forkv","⫚":"topfork","⫛":"mlcp","⫤":"Dashv","⫦":"Vdashl","⫧":"Barv","⫨":"vBar","⫩":"vBarv","⫫":"Vbar","⫬":"Not","⫭":"bNot","⫮":"rnmid","⫯":"cirmid","⫰":"midcir","⫱":"topcir","⫲":"nhpar","⫳":"parsim","⫽":"parsl","⫽⃥":"nparsl","♭":"flat","♮":"natur","♯":"sharp","¤":"curren","¢":"cent",$:"dollar","£":"pound","¥":"yen","€":"euro","¹":"sup1","½":"half","⅓":"frac13","¼":"frac14","⅕":"frac15","⅙":"frac16","⅛":"frac18","²":"sup2","⅔":"frac23","⅖":"frac25","³":"sup3","¾":"frac34","⅗":"frac35","⅜":"frac38","⅘":"frac45","⅚":"frac56","⅝":"frac58","⅞":"frac78","𝒶":"ascr","𝕒":"aopf","𝔞":"afr","𝔸":"Aopf","𝔄":"Afr","𝒜":"Ascr","ª":"ordf","á":"aacute","Á":"Aacute","à":"agrave","À":"Agrave","ă":"abreve","Ă":"Abreve","â":"acirc","Â":"Acirc","å":"aring","Å":"angst","ä":"auml","Ä":"Auml","ã":"atilde","Ã":"Atilde","ą":"aogon","Ą":"Aogon","ā":"amacr","Ā":"Amacr","æ":"aelig","Æ":"AElig","𝒷":"bscr","𝕓":"bopf","𝔟":"bfr","𝔹":"Bopf","ℬ":"Bscr","𝔅":"Bfr","𝔠":"cfr","𝒸":"cscr","𝕔":"copf","ℭ":"Cfr","𝒞":"Cscr","ℂ":"Copf","ć":"cacute","Ć":"Cacute","ĉ":"ccirc","Ĉ":"Ccirc","č":"ccaron","Č":"Ccaron","ċ":"cdot","Ċ":"Cdot","ç":"ccedil","Ç":"Ccedil","℅":"incare","𝔡":"dfr","ⅆ":"dd","𝕕":"dopf","𝒹":"dscr","𝒟":"Dscr","𝔇":"Dfr","ⅅ":"DD","𝔻":"Dopf","ď":"dcaron","Ď":"Dcaron","đ":"dstrok","Đ":"Dstrok","ð":"eth","Ð":"ETH","ⅇ":"ee","ℯ":"escr","𝔢":"efr","𝕖":"eopf","ℰ":"Escr","𝔈":"Efr","𝔼":"Eopf","é":"eacute","É":"Eacute","è":"egrave","È":"Egrave","ê":"ecirc","Ê":"Ecirc","ě":"ecaron","Ě":"Ecaron","ë":"euml","Ë":"Euml","ė":"edot","Ė":"Edot","ę":"eogon","Ę":"Eogon","ē":"emacr","Ē":"Emacr","𝔣":"ffr","𝕗":"fopf","𝒻":"fscr","𝔉":"Ffr","𝔽":"Fopf","ℱ":"Fscr","ff":"fflig","ffi":"ffilig","ffl":"ffllig","fi":"filig",fj:"fjlig","fl":"fllig","ƒ":"fnof","ℊ":"gscr","𝕘":"gopf","𝔤":"gfr","𝒢":"Gscr","𝔾":"Gopf","𝔊":"Gfr","ǵ":"gacute","ğ":"gbreve","Ğ":"Gbreve","ĝ":"gcirc","Ĝ":"Gcirc","ġ":"gdot","Ġ":"Gdot","Ģ":"Gcedil","𝔥":"hfr","ℎ":"planckh","𝒽":"hscr","𝕙":"hopf","ℋ":"Hscr","ℌ":"Hfr","ℍ":"Hopf","ĥ":"hcirc","Ĥ":"Hcirc","ℏ":"hbar","ħ":"hstrok","Ħ":"Hstrok","𝕚":"iopf","𝔦":"ifr","𝒾":"iscr","ⅈ":"ii","𝕀":"Iopf","ℐ":"Iscr","ℑ":"Im","í":"iacute","Í":"Iacute","ì":"igrave","Ì":"Igrave","î":"icirc","Î":"Icirc","ï":"iuml","Ï":"Iuml","ĩ":"itilde","Ĩ":"Itilde","İ":"Idot","į":"iogon","Į":"Iogon","ī":"imacr","Ī":"Imacr","ij":"ijlig","IJ":"IJlig","ı":"imath","𝒿":"jscr","𝕛":"jopf","𝔧":"jfr","𝒥":"Jscr","𝔍":"Jfr","𝕁":"Jopf","ĵ":"jcirc","Ĵ":"Jcirc","ȷ":"jmath","𝕜":"kopf","𝓀":"kscr","𝔨":"kfr","𝒦":"Kscr","𝕂":"Kopf","𝔎":"Kfr","ķ":"kcedil","Ķ":"Kcedil","𝔩":"lfr","𝓁":"lscr","ℓ":"ell","𝕝":"lopf","ℒ":"Lscr","𝔏":"Lfr","𝕃":"Lopf","ĺ":"lacute","Ĺ":"Lacute","ľ":"lcaron","Ľ":"Lcaron","ļ":"lcedil","Ļ":"Lcedil","ł":"lstrok","Ł":"Lstrok","ŀ":"lmidot","Ŀ":"Lmidot","𝔪":"mfr","𝕞":"mopf","𝓂":"mscr","𝔐":"Mfr","𝕄":"Mopf","ℳ":"Mscr","𝔫":"nfr","𝕟":"nopf","𝓃":"nscr","ℕ":"Nopf","𝒩":"Nscr","𝔑":"Nfr","ń":"nacute","Ń":"Nacute","ň":"ncaron","Ň":"Ncaron","ñ":"ntilde","Ñ":"Ntilde","ņ":"ncedil","Ņ":"Ncedil","№":"numero","ŋ":"eng","Ŋ":"ENG","𝕠":"oopf","𝔬":"ofr","ℴ":"oscr","𝒪":"Oscr","𝔒":"Ofr","𝕆":"Oopf","º":"ordm","ó":"oacute","Ó":"Oacute","ò":"ograve","Ò":"Ograve","ô":"ocirc","Ô":"Ocirc","ö":"ouml","Ö":"Ouml","ő":"odblac","Ő":"Odblac","õ":"otilde","Õ":"Otilde","ø":"oslash","Ø":"Oslash","ō":"omacr","Ō":"Omacr","œ":"oelig","Œ":"OElig","𝔭":"pfr","𝓅":"pscr","𝕡":"popf","ℙ":"Popf","𝔓":"Pfr","𝒫":"Pscr","𝕢":"qopf","𝔮":"qfr","𝓆":"qscr","𝒬":"Qscr","𝔔":"Qfr","ℚ":"Qopf","ĸ":"kgreen","𝔯":"rfr","𝕣":"ropf","𝓇":"rscr","ℛ":"Rscr","ℜ":"Re","ℝ":"Ropf","ŕ":"racute","Ŕ":"Racute","ř":"rcaron","Ř":"Rcaron","ŗ":"rcedil","Ŗ":"Rcedil","𝕤":"sopf","𝓈":"sscr","𝔰":"sfr","𝕊":"Sopf","𝔖":"Sfr","𝒮":"Sscr","Ⓢ":"oS","ś":"sacute","Ś":"Sacute","ŝ":"scirc","Ŝ":"Scirc","š":"scaron","Š":"Scaron","ş":"scedil","Ş":"Scedil","ß":"szlig","𝔱":"tfr","𝓉":"tscr","𝕥":"topf","𝒯":"Tscr","𝔗":"Tfr","𝕋":"Topf","ť":"tcaron","Ť":"Tcaron","ţ":"tcedil","Ţ":"Tcedil","™":"trade","ŧ":"tstrok","Ŧ":"Tstrok","𝓊":"uscr","𝕦":"uopf","𝔲":"ufr","𝕌":"Uopf","𝔘":"Ufr","𝒰":"Uscr","ú":"uacute","Ú":"Uacute","ù":"ugrave","Ù":"Ugrave","ŭ":"ubreve","Ŭ":"Ubreve","û":"ucirc","Û":"Ucirc","ů":"uring","Ů":"Uring","ü":"uuml","Ü":"Uuml","ű":"udblac","Ű":"Udblac","ũ":"utilde","Ũ":"Utilde","ų":"uogon","Ų":"Uogon","ū":"umacr","Ū":"Umacr","𝔳":"vfr","𝕧":"vopf","𝓋":"vscr","𝔙":"Vfr","𝕍":"Vopf","𝒱":"Vscr","𝕨":"wopf","𝓌":"wscr","𝔴":"wfr","𝒲":"Wscr","𝕎":"Wopf","𝔚":"Wfr","ŵ":"wcirc","Ŵ":"Wcirc","𝔵":"xfr","𝓍":"xscr","𝕩":"xopf","𝕏":"Xopf","𝔛":"Xfr","𝒳":"Xscr","𝔶":"yfr","𝓎":"yscr","𝕪":"yopf","𝒴":"Yscr","𝔜":"Yfr","𝕐":"Yopf","ý":"yacute","Ý":"Yacute","ŷ":"ycirc","Ŷ":"Ycirc","ÿ":"yuml","Ÿ":"Yuml","𝓏":"zscr","𝔷":"zfr","𝕫":"zopf","ℨ":"Zfr","ℤ":"Zopf","𝒵":"Zscr","ź":"zacute","Ź":"Zacute","ž":"zcaron","Ž":"Zcaron","ż":"zdot","Ż":"Zdot","Ƶ":"imped","þ":"thorn","Þ":"THORN","ʼn":"napos","α":"alpha","Α":"Alpha","β":"beta","Β":"Beta","γ":"gamma","Γ":"Gamma","δ":"delta","Δ":"Delta","ε":"epsi","ϵ":"epsiv","Ε":"Epsilon","ϝ":"gammad","Ϝ":"Gammad","ζ":"zeta","Ζ":"Zeta","η":"eta","Η":"Eta","θ":"theta","ϑ":"thetav","Θ":"Theta","ι":"iota","Ι":"Iota","κ":"kappa","ϰ":"kappav","Κ":"Kappa","λ":"lambda","Λ":"Lambda","μ":"mu","µ":"micro","Μ":"Mu","ν":"nu","Ν":"Nu","ξ":"xi","Ξ":"Xi","ο":"omicron","Ο":"Omicron","π":"pi","ϖ":"piv","Π":"Pi","ρ":"rho","ϱ":"rhov","Ρ":"Rho","σ":"sigma","Σ":"Sigma","ς":"sigmaf","τ":"tau","Τ":"Tau","υ":"upsi","Υ":"Upsilon","ϒ":"Upsi","φ":"phi","ϕ":"phiv","Φ":"Phi","χ":"chi","Χ":"Chi","ψ":"psi","Ψ":"Psi","ω":"omega","Ω":"ohm","а":"acy","А":"Acy","б":"bcy","Б":"Bcy","в":"vcy","В":"Vcy","г":"gcy","Г":"Gcy","ѓ":"gjcy","Ѓ":"GJcy","д":"dcy","Д":"Dcy","ђ":"djcy","Ђ":"DJcy","е":"iecy","Е":"IEcy","ё":"iocy","Ё":"IOcy","є":"jukcy","Є":"Jukcy","ж":"zhcy","Ж":"ZHcy","з":"zcy","З":"Zcy","ѕ":"dscy","Ѕ":"DScy","и":"icy","И":"Icy","і":"iukcy","І":"Iukcy","ї":"yicy","Ї":"YIcy","й":"jcy","Й":"Jcy","ј":"jsercy","Ј":"Jsercy","к":"kcy","К":"Kcy","ќ":"kjcy","Ќ":"KJcy","л":"lcy","Л":"Lcy","љ":"ljcy","Љ":"LJcy","м":"mcy","М":"Mcy","н":"ncy","Н":"Ncy","њ":"njcy","Њ":"NJcy","о":"ocy","О":"Ocy","п":"pcy","П":"Pcy","р":"rcy","Р":"Rcy","с":"scy","С":"Scy","т":"tcy","Т":"Tcy","ћ":"tshcy","Ћ":"TSHcy","у":"ucy","У":"Ucy","ў":"ubrcy","Ў":"Ubrcy","ф":"fcy","Ф":"Fcy","х":"khcy","Х":"KHcy","ц":"tscy","Ц":"TScy","ч":"chcy","Ч":"CHcy","џ":"dzcy","Џ":"DZcy","ш":"shcy","Ш":"SHcy","щ":"shchcy","Щ":"SHCHcy","ъ":"hardcy","Ъ":"HARDcy","ы":"ycy","Ы":"Ycy","ь":"softcy","Ь":"SOFTcy","э":"ecy","Э":"Ecy","ю":"yucy","Ю":"YUcy","я":"yacy","Я":"YAcy","ℵ":"aleph","ℶ":"beth","ℷ":"gimel","ℸ":"daleth"},p=/["&'<>`]/g,d={'"':""","&":"&","'":"'","<":"<",">":">","`":"`"},f=/&#(?:[xX][^a-fA-F0-9]|[^0-9xX])/,b=/[\0-\x08\x0B\x0E-\x1F\x7F-\x9F\uFDD0-\uFDEF\uFFFE\uFFFF]|[\uD83F\uD87F\uD8BF\uD8FF\uD93F\uD97F\uD9BF\uD9FF\uDA3F\uDA7F\uDABF\uDAFF\uDB3F\uDB7F\uDBBF\uDBFF][\uDFFE\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,A=/&(CounterClockwiseContourIntegral|DoubleLongLeftRightArrow|ClockwiseContourIntegral|NotNestedGreaterGreater|NotSquareSupersetEqual|DiacriticalDoubleAcute|NotRightTriangleEqual|NotSucceedsSlantEqual|NotPrecedesSlantEqual|CloseCurlyDoubleQuote|NegativeVeryThinSpace|DoubleContourIntegral|FilledVerySmallSquare|CapitalDifferentialD|OpenCurlyDoubleQuote|EmptyVerySmallSquare|NestedGreaterGreater|DoubleLongRightArrow|NotLeftTriangleEqual|NotGreaterSlantEqual|ReverseUpEquilibrium|DoubleLeftRightArrow|NotSquareSubsetEqual|NotDoubleVerticalBar|RightArrowLeftArrow|NotGreaterFullEqual|NotRightTriangleBar|SquareSupersetEqual|DownLeftRightVector|DoubleLongLeftArrow|leftrightsquigarrow|LeftArrowRightArrow|NegativeMediumSpace|blacktriangleright|RightDownVectorBar|PrecedesSlantEqual|RightDoubleBracket|SucceedsSlantEqual|NotLeftTriangleBar|RightTriangleEqual|SquareIntersection|RightDownTeeVector|ReverseEquilibrium|NegativeThickSpace|longleftrightarrow|Longleftrightarrow|LongLeftRightArrow|DownRightTeeVector|DownRightVectorBar|GreaterSlantEqual|SquareSubsetEqual|LeftDownVectorBar|LeftDoubleBracket|VerticalSeparator|rightleftharpoons|NotGreaterGreater|NotSquareSuperset|blacktriangleleft|blacktriangledown|NegativeThinSpace|LeftDownTeeVector|NotLessSlantEqual|leftrightharpoons|DoubleUpDownArrow|DoubleVerticalBar|LeftTriangleEqual|FilledSmallSquare|twoheadrightarrow|NotNestedLessLess|DownLeftTeeVector|DownLeftVectorBar|RightAngleBracket|NotTildeFullEqual|NotReverseElement|RightUpDownVector|DiacriticalTilde|NotSucceedsTilde|circlearrowright|NotPrecedesEqual|rightharpoondown|DoubleRightArrow|NotSucceedsEqual|NonBreakingSpace|NotRightTriangle|LessEqualGreater|RightUpTeeVector|LeftAngleBracket|GreaterFullEqual|DownArrowUpArrow|RightUpVectorBar|twoheadleftarrow|GreaterEqualLess|downharpoonright|RightTriangleBar|ntrianglerighteq|NotSupersetEqual|LeftUpDownVector|DiacriticalAcute|rightrightarrows|vartriangleright|UpArrowDownArrow|DiacriticalGrave|UnderParenthesis|EmptySmallSquare|LeftUpVectorBar|leftrightarrows|DownRightVector|downharpoonleft|trianglerighteq|ShortRightArrow|OverParenthesis|DoubleLeftArrow|DoubleDownArrow|NotSquareSubset|bigtriangledown|ntrianglelefteq|UpperRightArrow|curvearrowright|vartriangleleft|NotLeftTriangle|nleftrightarrow|LowerRightArrow|NotHumpDownHump|NotGreaterTilde|rightthreetimes|LeftUpTeeVector|NotGreaterEqual|straightepsilon|LeftTriangleBar|rightsquigarrow|ContourIntegral|rightleftarrows|CloseCurlyQuote|RightDownVector|LeftRightVector|nLeftrightarrow|leftharpoondown|circlearrowleft|SquareSuperset|OpenCurlyQuote|hookrightarrow|HorizontalLine|DiacriticalDot|NotLessGreater|ntriangleright|DoubleRightTee|InvisibleComma|InvisibleTimes|LowerLeftArrow|DownLeftVector|NotSubsetEqual|curvearrowleft|trianglelefteq|NotVerticalBar|TildeFullEqual|downdownarrows|NotGreaterLess|RightTeeVector|ZeroWidthSpace|looparrowright|LongRightArrow|doublebarwedge|ShortLeftArrow|ShortDownArrow|RightVectorBar|GreaterGreater|ReverseElement|rightharpoonup|LessSlantEqual|leftthreetimes|upharpoonright|rightarrowtail|LeftDownVector|Longrightarrow|NestedLessLess|UpperLeftArrow|nshortparallel|leftleftarrows|leftrightarrow|Leftrightarrow|LeftRightArrow|longrightarrow|upharpoonleft|RightArrowBar|ApplyFunction|LeftTeeVector|leftarrowtail|NotEqualTilde|varsubsetneqq|varsupsetneqq|RightTeeArrow|SucceedsEqual|SucceedsTilde|LeftVectorBar|SupersetEqual|hookleftarrow|DifferentialD|VerticalTilde|VeryThinSpace|blacktriangle|bigtriangleup|LessFullEqual|divideontimes|leftharpoonup|UpEquilibrium|ntriangleleft|RightTriangle|measuredangle|shortparallel|longleftarrow|Longleftarrow|LongLeftArrow|DoubleLeftTee|Poincareplane|PrecedesEqual|triangleright|DoubleUpArrow|RightUpVector|fallingdotseq|looparrowleft|PrecedesTilde|NotTildeEqual|NotTildeTilde|smallsetminus|Proportional|triangleleft|triangledown|UnderBracket|NotHumpEqual|exponentiale|ExponentialE|NotLessTilde|HilbertSpace|RightCeiling|blacklozenge|varsupsetneq|HumpDownHump|GreaterEqual|VerticalLine|LeftTeeArrow|NotLessEqual|DownTeeArrow|LeftTriangle|varsubsetneq|Intersection|NotCongruent|DownArrowBar|LeftUpVector|LeftArrowBar|risingdotseq|GreaterTilde|RoundImplies|SquareSubset|ShortUpArrow|NotSuperset|quaternions|precnapprox|backepsilon|preccurlyeq|OverBracket|blacksquare|MediumSpace|VerticalBar|circledcirc|circleddash|CircleMinus|CircleTimes|LessGreater|curlyeqprec|curlyeqsucc|diamondsuit|UpDownArrow|Updownarrow|RuleDelayed|Rrightarrow|updownarrow|RightVector|nRightarrow|nrightarrow|eqslantless|LeftCeiling|Equilibrium|SmallCircle|expectation|NotSucceeds|thickapprox|GreaterLess|SquareUnion|NotPrecedes|NotLessLess|straightphi|succnapprox|succcurlyeq|SubsetEqual|sqsupseteq|Proportion|Laplacetrf|ImaginaryI|supsetneqq|NotGreater|gtreqqless|NotElement|ThickSpace|TildeEqual|TildeTilde|Fouriertrf|rmoustache|EqualTilde|eqslantgtr|UnderBrace|LeftVector|UpArrowBar|nLeftarrow|nsubseteqq|subsetneqq|nsupseteqq|nleftarrow|succapprox|lessapprox|UpTeeArrow|upuparrows|curlywedge|lesseqqgtr|varepsilon|varnothing|RightFloor|complement|CirclePlus|sqsubseteq|Lleftarrow|circledast|RightArrow|Rightarrow|rightarrow|lmoustache|Bernoullis|precapprox|mapstoleft|mapstodown|longmapsto|dotsquare|downarrow|DoubleDot|nsubseteq|supsetneq|leftarrow|nsupseteq|subsetneq|ThinSpace|ngeqslant|subseteqq|HumpEqual|NotSubset|triangleq|NotCupCap|lesseqgtr|heartsuit|TripleDot|Leftarrow|Coproduct|Congruent|varpropto|complexes|gvertneqq|LeftArrow|LessTilde|supseteqq|MinusPlus|CircleDot|nleqslant|NotExists|gtreqless|nparallel|UnionPlus|LeftFloor|checkmark|CenterDot|centerdot|Mellintrf|gtrapprox|bigotimes|OverBrace|spadesuit|therefore|pitchfork|rationals|PlusMinus|Backslash|Therefore|DownBreve|backsimeq|backprime|DownArrow|nshortmid|Downarrow|lvertneqq|eqvparsl|imagline|imagpart|infintie|integers|Integral|intercal|LessLess|Uarrocir|intlarhk|sqsupset|angmsdaf|sqsubset|llcorner|vartheta|cupbrcap|lnapprox|Superset|SuchThat|succnsim|succneqq|angmsdag|biguplus|curlyvee|trpezium|Succeeds|NotTilde|bigwedge|angmsdah|angrtvbd|triminus|cwconint|fpartint|lrcorner|smeparsl|subseteq|urcorner|lurdshar|laemptyv|DDotrahd|approxeq|ldrushar|awconint|mapstoup|backcong|shortmid|triangle|geqslant|gesdotol|timesbar|circledR|circledS|setminus|multimap|naturals|scpolint|ncongdot|RightTee|boxminus|gnapprox|boxtimes|andslope|thicksim|angmsdaa|varsigma|cirfnint|rtriltri|angmsdab|rppolint|angmsdac|barwedge|drbkarow|clubsuit|thetasym|bsolhsub|capbrcup|dzigrarr|doteqdot|DotEqual|dotminus|UnderBar|NotEqual|realpart|otimesas|ulcorner|hksearow|hkswarow|parallel|PartialD|elinters|emptyset|plusacir|bbrktbrk|angmsdad|pointint|bigoplus|angmsdae|Precedes|bigsqcup|varkappa|notindot|supseteq|precneqq|precnsim|profalar|profline|profsurf|leqslant|lesdotor|raemptyv|subplus|notnivb|notnivc|subrarr|zigrarr|vzigzag|submult|subedot|Element|between|cirscir|larrbfs|larrsim|lotimes|lbrksld|lbrkslu|lozenge|ldrdhar|dbkarow|bigcirc|epsilon|simrarr|simplus|ltquest|Epsilon|luruhar|gtquest|maltese|npolint|eqcolon|npreceq|bigodot|ddagger|gtrless|bnequiv|harrcir|ddotseq|equivDD|backsim|demptyv|nsqsube|nsqsupe|Upsilon|nsubset|upsilon|minusdu|nsucceq|swarrow|nsupset|coloneq|searrow|boxplus|napprox|natural|asympeq|alefsym|congdot|nearrow|bigstar|diamond|supplus|tritime|LeftTee|nvinfin|triplus|NewLine|nvltrie|nvrtrie|nwarrow|nexists|Diamond|ruluhar|Implies|supmult|angzarr|suplarr|suphsub|questeq|because|digamma|Because|olcross|bemptyv|omicron|Omicron|rotimes|NoBreak|intprod|angrtvb|orderof|uwangle|suphsol|lesdoto|orslope|DownTee|realine|cudarrl|rdldhar|OverBar|supedot|lessdot|supdsub|topfork|succsim|rbrkslu|rbrksld|pertenk|cudarrr|isindot|planckh|lessgtr|pluscir|gesdoto|plussim|plustwo|lesssim|cularrp|rarrsim|Cayleys|notinva|notinvb|notinvc|UpArrow|Uparrow|uparrow|NotLess|dwangle|precsim|Product|curarrm|Cconint|dotplus|rarrbfs|ccupssm|Cedilla|cemptyv|notniva|quatint|frac35|frac38|frac45|frac56|frac58|frac78|tridot|xoplus|gacute|gammad|Gammad|lfisht|lfloor|bigcup|sqsupe|gbreve|Gbreve|lharul|sqsube|sqcups|Gcedil|apacir|llhard|lmidot|Lmidot|lmoust|andand|sqcaps|approx|Abreve|spades|circeq|tprime|divide|topcir|Assign|topbot|gesdot|divonx|xuplus|timesd|gesles|atilde|solbar|SOFTcy|loplus|timesb|lowast|lowbar|dlcorn|dlcrop|softcy|dollar|lparlt|thksim|lrhard|Atilde|lsaquo|smashp|bigvee|thinsp|wreath|bkarow|lsquor|lstrok|Lstrok|lthree|ltimes|ltlarr|DotDot|simdot|ltrPar|weierp|xsqcup|angmsd|sigmav|sigmaf|zeetrf|Zcaron|zcaron|mapsto|vsupne|thetav|cirmid|marker|mcomma|Zacute|vsubnE|there4|gtlPar|vsubne|bottom|gtrarr|SHCHcy|shchcy|midast|midcir|middot|minusb|minusd|gtrdot|bowtie|sfrown|mnplus|models|colone|seswar|Colone|mstpos|searhk|gtrsim|nacute|Nacute|boxbox|telrec|hairsp|Tcedil|nbumpe|scnsim|ncaron|Ncaron|ncedil|Ncedil|hamilt|Scedil|nearhk|hardcy|HARDcy|tcedil|Tcaron|commat|nequiv|nesear|tcaron|target|hearts|nexist|varrho|scedil|Scaron|scaron|hellip|Sacute|sacute|hercon|swnwar|compfn|rtimes|rthree|rsquor|rsaquo|zacute|wedgeq|homtht|barvee|barwed|Barwed|rpargt|horbar|conint|swarhk|roplus|nltrie|hslash|hstrok|Hstrok|rmoust|Conint|bprime|hybull|hyphen|iacute|Iacute|supsup|supsub|supsim|varphi|coprod|brvbar|agrave|Supset|supset|igrave|Igrave|notinE|Agrave|iiiint|iinfin|copysr|wedbar|Verbar|vangrt|becaus|incare|verbar|inodot|bullet|drcorn|intcal|drcrop|cularr|vellip|Utilde|bumpeq|cupcap|dstrok|Dstrok|CupCap|cupcup|cupdot|eacute|Eacute|supdot|iquest|easter|ecaron|Ecaron|ecolon|isinsv|utilde|itilde|Itilde|curarr|succeq|Bumpeq|cacute|ulcrop|nparsl|Cacute|nprcue|egrave|Egrave|nrarrc|nrarrw|subsup|subsub|nrtrie|jsercy|nsccue|Jsercy|kappav|kcedil|Kcedil|subsim|ulcorn|nsimeq|egsdot|veebar|kgreen|capand|elsdot|Subset|subset|curren|aacute|lacute|Lacute|emptyv|ntilde|Ntilde|lagran|lambda|Lambda|capcap|Ugrave|langle|subdot|emsp13|numero|emsp14|nvdash|nvDash|nVdash|nVDash|ugrave|ufisht|nvHarr|larrfs|nvlArr|larrhk|larrlp|larrpl|nvrArr|Udblac|nwarhk|larrtl|nwnear|oacute|Oacute|latail|lAtail|sstarf|lbrace|odblac|Odblac|lbrack|udblac|odsold|eparsl|lcaron|Lcaron|ograve|Ograve|lcedil|Lcedil|Aacute|ssmile|ssetmn|squarf|ldquor|capcup|ominus|cylcty|rharul|eqcirc|dagger|rfloor|rfisht|Dagger|daleth|equals|origof|capdot|equest|dcaron|Dcaron|rdquor|oslash|Oslash|otilde|Otilde|otimes|Otimes|urcrop|Ubreve|ubreve|Yacute|Uacute|uacute|Rcedil|rcedil|urcorn|parsim|Rcaron|Vdashl|rcaron|Tstrok|percnt|period|permil|Exists|yacute|rbrack|rbrace|phmmat|ccaron|Ccaron|planck|ccedil|plankv|tstrok|female|plusdo|plusdu|ffilig|plusmn|ffllig|Ccedil|rAtail|dfisht|bernou|ratail|Rarrtl|rarrtl|angsph|rarrpl|rarrlp|rarrhk|xwedge|xotime|forall|ForAll|Vvdash|vsupnE|preceq|bigcap|frac12|frac13|frac14|primes|rarrfs|prnsim|frac15|Square|frac16|square|lesdot|frac18|frac23|propto|prurel|rarrap|rangle|puncsp|frac25|Racute|qprime|racute|lesges|frac34|abreve|AElig|eqsim|utdot|setmn|urtri|Equal|Uring|seArr|uring|searr|dashv|Dashv|mumap|nabla|iogon|Iogon|sdote|sdotb|scsim|napid|napos|equiv|natur|Acirc|dblac|erarr|nbump|iprod|erDot|ucirc|awint|esdot|angrt|ncong|isinE|scnap|Scirc|scirc|ndash|isins|Ubrcy|nearr|neArr|isinv|nedot|ubrcy|acute|Ycirc|iukcy|Iukcy|xutri|nesim|caret|jcirc|Jcirc|caron|twixt|ddarr|sccue|exist|jmath|sbquo|ngeqq|angst|ccaps|lceil|ngsim|UpTee|delta|Delta|rtrif|nharr|nhArr|nhpar|rtrie|jukcy|Jukcy|kappa|rsquo|Kappa|nlarr|nlArr|TSHcy|rrarr|aogon|Aogon|fflig|xrarr|tshcy|ccirc|nleqq|filig|upsih|nless|dharl|nlsim|fjlig|ropar|nltri|dharr|robrk|roarr|fllig|fltns|roang|rnmid|subnE|subne|lAarr|trisb|Ccirc|acirc|ccups|blank|VDash|forkv|Vdash|langd|cedil|blk12|blk14|laquo|strns|diams|notin|vDash|larrb|blk34|block|disin|uplus|vdash|vBarv|aelig|starf|Wedge|check|xrArr|lates|lbarr|lBarr|notni|lbbrk|bcong|frasl|lbrke|frown|vrtri|vprop|vnsup|gamma|Gamma|wedge|xodot|bdquo|srarr|doteq|ldquo|boxdl|boxdL|gcirc|Gcirc|boxDl|boxDL|boxdr|boxdR|boxDr|TRADE|trade|rlhar|boxDR|vnsub|npart|vltri|rlarr|boxhd|boxhD|nprec|gescc|nrarr|nrArr|boxHd|boxHD|boxhu|boxhU|nrtri|boxHu|clubs|boxHU|times|colon|Colon|gimel|xlArr|Tilde|nsime|tilde|nsmid|nspar|THORN|thorn|xlarr|nsube|nsubE|thkap|xhArr|comma|nsucc|boxul|boxuL|nsupe|nsupE|gneqq|gnsim|boxUl|boxUL|grave|boxur|boxuR|boxUr|boxUR|lescc|angle|bepsi|boxvh|varpi|boxvH|numsp|Theta|gsime|gsiml|theta|boxVh|boxVH|boxvl|gtcir|gtdot|boxvL|boxVl|boxVL|crarr|cross|Cross|nvsim|boxvr|nwarr|nwArr|sqsup|dtdot|Uogon|lhard|lharu|dtrif|ocirc|Ocirc|lhblk|duarr|odash|sqsub|Hacek|sqcup|llarr|duhar|oelig|OElig|ofcir|boxvR|uogon|lltri|boxVr|csube|uuarr|ohbar|csupe|ctdot|olarr|olcir|harrw|oline|sqcap|omacr|Omacr|omega|Omega|boxVR|aleph|lneqq|lnsim|loang|loarr|rharu|lobrk|hcirc|operp|oplus|rhard|Hcirc|orarr|Union|order|ecirc|Ecirc|cuepr|szlig|cuesc|breve|reals|eDDot|Breve|hoarr|lopar|utrif|rdquo|Umacr|umacr|efDot|swArr|ultri|alpha|rceil|ovbar|swarr|Wcirc|wcirc|smtes|smile|bsemi|lrarr|aring|parsl|lrhar|bsime|uhblk|lrtri|cupor|Aring|uharr|uharl|slarr|rbrke|bsolb|lsime|rbbrk|RBarr|lsimg|phone|rBarr|rbarr|icirc|lsquo|Icirc|emacr|Emacr|ratio|simne|plusb|simlE|simgE|simeq|pluse|ltcir|ltdot|empty|xharr|xdtri|iexcl|Alpha|ltrie|rarrw|pound|ltrif|xcirc|bumpe|prcue|bumpE|asymp|amacr|cuvee|Sigma|sigma|iiint|udhar|iiota|ijlig|IJlig|supnE|imacr|Imacr|prime|Prime|image|prnap|eogon|Eogon|rarrc|mdash|mDDot|cuwed|imath|supne|imped|Amacr|udarr|prsim|micro|rarrb|cwint|raquo|infin|eplus|range|rangd|Ucirc|radic|minus|amalg|veeeq|rAarr|epsiv|ycirc|quest|sharp|quot|zwnj|Qscr|race|qscr|Qopf|qopf|qint|rang|Rang|Zscr|zscr|Zopf|zopf|rarr|rArr|Rarr|Pscr|pscr|prop|prod|prnE|prec|ZHcy|zhcy|prap|Zeta|zeta|Popf|popf|Zdot|plus|zdot|Yuml|yuml|phiv|YUcy|yucy|Yscr|yscr|perp|Yopf|yopf|part|para|YIcy|Ouml|rcub|yicy|YAcy|rdca|ouml|osol|Oscr|rdsh|yacy|real|oscr|xvee|andd|rect|andv|Xscr|oror|ordm|ordf|xscr|ange|aopf|Aopf|rHar|Xopf|opar|Oopf|xopf|xnis|rhov|oopf|omid|xmap|oint|apid|apos|ogon|ascr|Ascr|odot|odiv|xcup|xcap|ocir|oast|nvlt|nvle|nvgt|nvge|nvap|Wscr|wscr|auml|ntlg|ntgl|nsup|nsub|nsim|Nscr|nscr|nsce|Wopf|ring|npre|wopf|npar|Auml|Barv|bbrk|Nopf|nopf|nmid|nLtv|beta|ropf|Ropf|Beta|beth|nles|rpar|nleq|bnot|bNot|nldr|NJcy|rscr|Rscr|Vscr|vscr|rsqb|njcy|bopf|nisd|Bopf|rtri|Vopf|nGtv|ngtr|vopf|boxh|boxH|boxv|nges|ngeq|boxV|bscr|scap|Bscr|bsim|Vert|vert|bsol|bull|bump|caps|cdot|ncup|scnE|ncap|nbsp|napE|Cdot|cent|sdot|Vbar|nang|vBar|chcy|Mscr|mscr|sect|semi|CHcy|Mopf|mopf|sext|circ|cire|mldr|mlcp|cirE|comp|shcy|SHcy|vArr|varr|cong|copf|Copf|copy|COPY|malt|male|macr|lvnE|cscr|ltri|sime|ltcc|simg|Cscr|siml|csub|Uuml|lsqb|lsim|uuml|csup|Lscr|lscr|utri|smid|lpar|cups|smte|lozf|darr|Lopf|Uscr|solb|lopf|sopf|Sopf|lneq|uscr|spar|dArr|lnap|Darr|dash|Sqrt|LJcy|ljcy|lHar|dHar|Upsi|upsi|diam|lesg|djcy|DJcy|leqq|dopf|Dopf|dscr|Dscr|dscy|ldsh|ldca|squf|DScy|sscr|Sscr|dsol|lcub|late|star|Star|Uopf|Larr|lArr|larr|uopf|dtri|dzcy|sube|subE|Lang|lang|Kscr|kscr|Kopf|kopf|KJcy|kjcy|KHcy|khcy|DZcy|ecir|edot|eDot|Jscr|jscr|succ|Jopf|jopf|Edot|uHar|emsp|ensp|Iuml|iuml|eopf|isin|Iscr|iscr|Eopf|epar|sung|epsi|escr|sup1|sup2|sup3|Iota|iota|supe|supE|Iopf|iopf|IOcy|iocy|Escr|esim|Esim|imof|Uarr|QUOT|uArr|uarr|euml|IEcy|iecy|Idot|Euml|euro|excl|Hscr|hscr|Hopf|hopf|TScy|tscy|Tscr|hbar|tscr|flat|tbrk|fnof|hArr|harr|half|fopf|Fopf|tdot|gvnE|fork|trie|gtcc|fscr|Fscr|gdot|gsim|Gscr|gscr|Gopf|gopf|gneq|Gdot|tosa|gnap|Topf|topf|geqq|toea|GJcy|gjcy|tint|gesl|mid|Sfr|ggg|top|ges|gla|glE|glj|geq|gne|gEl|gel|gnE|Gcy|gcy|gap|Tfr|tfr|Tcy|tcy|Hat|Tau|Ffr|tau|Tab|hfr|Hfr|ffr|Fcy|fcy|icy|Icy|iff|ETH|eth|ifr|Ifr|Eta|eta|int|Int|Sup|sup|ucy|Ucy|Sum|sum|jcy|ENG|ufr|Ufr|eng|Jcy|jfr|els|ell|egs|Efr|efr|Jfr|uml|kcy|Kcy|Ecy|ecy|kfr|Kfr|lap|Sub|sub|lat|lcy|Lcy|leg|Dot|dot|lEg|leq|les|squ|div|die|lfr|Lfr|lgE|Dfr|dfr|Del|deg|Dcy|dcy|lne|lnE|sol|loz|smt|Cup|lrm|cup|lsh|Lsh|sim|shy|map|Map|mcy|Mcy|mfr|Mfr|mho|gfr|Gfr|sfr|cir|Chi|chi|nap|Cfr|vcy|Vcy|cfr|Scy|scy|ncy|Ncy|vee|Vee|Cap|cap|nfr|scE|sce|Nfr|nge|ngE|nGg|vfr|Vfr|ngt|bot|nGt|nis|niv|Rsh|rsh|nle|nlE|bne|Bfr|bfr|nLl|nlt|nLt|Bcy|bcy|not|Not|rlm|wfr|Wfr|npr|nsc|num|ocy|ast|Ocy|ofr|xfr|Xfr|Ofr|ogt|ohm|apE|olt|Rho|ape|rho|Rfr|rfr|ord|REG|ang|reg|orv|And|and|AMP|Rcy|amp|Afr|ycy|Ycy|yen|yfr|Yfr|rcy|par|pcy|Pcy|pfr|Pfr|phi|Phi|afr|Acy|acy|zcy|Zcy|piv|acE|acd|zfr|Zfr|pre|prE|psi|Psi|qfr|Qfr|zwj|Or|ge|Gg|gt|gg|el|oS|lt|Lt|LT|Re|lg|gl|eg|ne|Im|it|le|DD|wp|wr|nu|Nu|dd|lE|Sc|sc|pi|Pi|ee|af|ll|Ll|rx|gE|xi|pm|Xi|ic|pr|Pr|in|ni|mp|mu|ac|Mu|or|ap|Gt|GT|ii);|&(Aacute|Agrave|Atilde|Ccedil|Eacute|Egrave|Iacute|Igrave|Ntilde|Oacute|Ograve|Oslash|Otilde|Uacute|Ugrave|Yacute|aacute|agrave|atilde|brvbar|ccedil|curren|divide|eacute|egrave|frac12|frac14|frac34|iacute|igrave|iquest|middot|ntilde|oacute|ograve|oslash|otilde|plusmn|uacute|ugrave|yacute|AElig|Acirc|Aring|Ecirc|Icirc|Ocirc|THORN|Ucirc|acirc|acute|aelig|aring|cedil|ecirc|icirc|iexcl|laquo|micro|ocirc|pound|raquo|szlig|thorn|times|ucirc|Auml|COPY|Euml|Iuml|Ouml|QUOT|Uuml|auml|cent|copy|euml|iuml|macr|nbsp|ordf|ordm|ouml|para|quot|sect|sup1|sup2|sup3|uuml|yuml|AMP|ETH|REG|amp|deg|eth|not|reg|shy|uml|yen|GT|LT|gt|lt)(?!;)([=a-zA-Z0-9]?)|&#([0-9]+)(;?)|&#[xX]([a-fA-F0-9]+)(;?)|&([0-9a-zA-Z]+)/g,y={aacute:"á",Aacute:"Á",abreve:"ă",Abreve:"Ă",ac:"∾",acd:"∿",acE:"∾̳",acirc:"â",Acirc:"Â",acute:"´",acy:"а",Acy:"А",aelig:"æ",AElig:"Æ",af:"⁡",afr:"𝔞",Afr:"𝔄",agrave:"à",Agrave:"À",alefsym:"ℵ",aleph:"ℵ",alpha:"α",Alpha:"Α",amacr:"ā",Amacr:"Ā",amalg:"⨿",amp:"&",AMP:"&",and:"∧",And:"⩓",andand:"⩕",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsd:"∡",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",aogon:"ą",Aogon:"Ą",aopf:"𝕒",Aopf:"𝔸",ap:"≈",apacir:"⩯",ape:"≊",apE:"⩰",apid:"≋",apos:"'",ApplyFunction:"⁡",approx:"≈",approxeq:"≊",aring:"å",Aring:"Å",ascr:"𝒶",Ascr:"𝒜",Assign:"≔",ast:"*",asymp:"≈",asympeq:"≍",atilde:"ã",Atilde:"Ã",auml:"ä",Auml:"Ä",awconint:"∳",awint:"⨑",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",Backslash:"∖",Barv:"⫧",barvee:"⊽",barwed:"⌅",Barwed:"⌆",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",bcy:"б",Bcy:"Б",bdquo:"„",becaus:"∵",because:"∵",Because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",Bernoullis:"ℬ",beta:"β",Beta:"Β",beth:"ℶ",between:"≬",bfr:"𝔟",Bfr:"𝔅",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bnot:"⌐",bNot:"⫭",bopf:"𝕓",Bopf:"𝔹",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxbox:"⧉",boxdl:"┐",boxdL:"╕",boxDl:"╖",boxDL:"╗",boxdr:"┌",boxdR:"╒",boxDr:"╓",boxDR:"╔",boxh:"─",boxH:"═",boxhd:"┬",boxhD:"╥",boxHd:"╤",boxHD:"╦",boxhu:"┴",boxhU:"╨",boxHu:"╧",boxHU:"╩",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxul:"┘",boxuL:"╛",boxUl:"╜",boxUL:"╝",boxur:"└",boxuR:"╘",boxUr:"╙",boxUR:"╚",boxv:"│",boxV:"║",boxvh:"┼",boxvH:"╪",boxVh:"╫",boxVH:"╬",boxvl:"┤",boxvL:"╡",boxVl:"╢",boxVL:"╣",boxvr:"├",boxvR:"╞",boxVr:"╟",boxVR:"╠",bprime:"‵",breve:"˘",Breve:"˘",brvbar:"¦",bscr:"𝒷",Bscr:"ℬ",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsol:"\\",bsolb:"⧅",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpe:"≏",bumpE:"⪮",bumpeq:"≏",Bumpeq:"≎",cacute:"ć",Cacute:"Ć",cap:"∩",Cap:"⋒",capand:"⩄",capbrcup:"⩉",capcap:"⩋",capcup:"⩇",capdot:"⩀",CapitalDifferentialD:"ⅅ",caps:"∩︀",caret:"⁁",caron:"ˇ",Cayleys:"ℭ",ccaps:"⩍",ccaron:"č",Ccaron:"Č",ccedil:"ç",Ccedil:"Ç",ccirc:"ĉ",Ccirc:"Ĉ",Cconint:"∰",ccups:"⩌",ccupssm:"⩐",cdot:"ċ",Cdot:"Ċ",cedil:"¸",Cedilla:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",CenterDot:"·",cfr:"𝔠",Cfr:"ℭ",chcy:"ч",CHcy:"Ч",check:"✓",checkmark:"✓",chi:"χ",Chi:"Χ",cir:"○",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",CircleDot:"⊙",circledR:"®",circledS:"Ⓢ",CircleMinus:"⊖",CirclePlus:"⊕",CircleTimes:"⊗",cire:"≗",cirE:"⧃",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",ClockwiseContourIntegral:"∲",CloseCurlyDoubleQuote:"”",CloseCurlyQuote:"’",clubs:"♣",clubsuit:"♣",colon:":",Colon:"∷",colone:"≔",Colone:"⩴",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",Congruent:"≡",conint:"∮",Conint:"∯",ContourIntegral:"∮",copf:"𝕔",Copf:"ℂ",coprod:"∐",Coproduct:"∐",copy:"©",COPY:"©",copysr:"℗",CounterClockwiseContourIntegral:"∳",crarr:"↵",cross:"✗",Cross:"⨯",cscr:"𝒸",Cscr:"𝒞",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cup:"∪",Cup:"⋓",cupbrcap:"⩈",cupcap:"⩆",CupCap:"≍",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dagger:"†",Dagger:"‡",daleth:"ℸ",darr:"↓",dArr:"⇓",Darr:"↡",dash:"‐",dashv:"⊣",Dashv:"⫤",dbkarow:"⤏",dblac:"˝",dcaron:"ď",Dcaron:"Ď",dcy:"д",Dcy:"Д",dd:"ⅆ",DD:"ⅅ",ddagger:"‡",ddarr:"⇊",DDotrahd:"⤑",ddotseq:"⩷",deg:"°",Del:"∇",delta:"δ",Delta:"Δ",demptyv:"⦱",dfisht:"⥿",dfr:"𝔡",Dfr:"𝔇",dHar:"⥥",dharl:"⇃",dharr:"⇂",DiacriticalAcute:"´",DiacriticalDot:"˙",DiacriticalDoubleAcute:"˝",DiacriticalGrave:"`",DiacriticalTilde:"˜",diam:"⋄",diamond:"⋄",Diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",DifferentialD:"ⅆ",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",djcy:"ђ",DJcy:"Ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",dopf:"𝕕",Dopf:"𝔻",dot:"˙",Dot:"¨",DotDot:"⃜",doteq:"≐",doteqdot:"≑",DotEqual:"≐",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",DoubleContourIntegral:"∯",DoubleDot:"¨",DoubleDownArrow:"⇓",DoubleLeftArrow:"⇐",DoubleLeftRightArrow:"⇔",DoubleLeftTee:"⫤",DoubleLongLeftArrow:"⟸",DoubleLongLeftRightArrow:"⟺",DoubleLongRightArrow:"⟹",DoubleRightArrow:"⇒",DoubleRightTee:"⊨",DoubleUpArrow:"⇑",DoubleUpDownArrow:"⇕",DoubleVerticalBar:"∥",downarrow:"↓",Downarrow:"⇓",DownArrow:"↓",DownArrowBar:"⤓",DownArrowUpArrow:"⇵",DownBreve:"̑",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",DownLeftRightVector:"⥐",DownLeftTeeVector:"⥞",DownLeftVector:"↽",DownLeftVectorBar:"⥖",DownRightTeeVector:"⥟",DownRightVector:"⇁",DownRightVectorBar:"⥗",DownTee:"⊤",DownTeeArrow:"↧",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",dscr:"𝒹",Dscr:"𝒟",dscy:"ѕ",DScy:"Ѕ",dsol:"⧶",dstrok:"đ",Dstrok:"Đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",dzcy:"џ",DZcy:"Џ",dzigrarr:"⟿",eacute:"é",Eacute:"É",easter:"⩮",ecaron:"ě",Ecaron:"Ě",ecir:"≖",ecirc:"ê",Ecirc:"Ê",ecolon:"≕",ecy:"э",Ecy:"Э",eDDot:"⩷",edot:"ė",eDot:"≑",Edot:"Ė",ee:"ⅇ",efDot:"≒",efr:"𝔢",Efr:"𝔈",eg:"⪚",egrave:"è",Egrave:"È",egs:"⪖",egsdot:"⪘",el:"⪙",Element:"∈",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",emacr:"ē",Emacr:"Ē",empty:"∅",emptyset:"∅",EmptySmallSquare:"◻",emptyv:"∅",EmptyVerySmallSquare:"▫",emsp:" ",emsp13:" ",emsp14:" ",eng:"ŋ",ENG:"Ŋ",ensp:" ",eogon:"ę",Eogon:"Ę",eopf:"𝕖",Eopf:"𝔼",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",epsilon:"ε",Epsilon:"Ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",Equal:"⩵",equals:"=",EqualTilde:"≂",equest:"≟",Equilibrium:"⇌",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erarr:"⥱",erDot:"≓",escr:"ℯ",Escr:"ℰ",esdot:"≐",esim:"≂",Esim:"⩳",eta:"η",Eta:"Η",eth:"ð",ETH:"Ð",euml:"ë",Euml:"Ë",euro:"€",excl:"!",exist:"∃",Exists:"∃",expectation:"ℰ",exponentiale:"ⅇ",ExponentialE:"ⅇ",fallingdotseq:"≒",fcy:"ф",Fcy:"Ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",ffr:"𝔣",Ffr:"𝔉",filig:"fi",FilledSmallSquare:"◼",FilledVerySmallSquare:"▪",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",fopf:"𝕗",Fopf:"𝔽",forall:"∀",ForAll:"∀",fork:"⋔",forkv:"⫙",Fouriertrf:"ℱ",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",Fscr:"ℱ",gacute:"ǵ",gamma:"γ",Gamma:"Γ",gammad:"ϝ",Gammad:"Ϝ",gap:"⪆",gbreve:"ğ",Gbreve:"Ğ",Gcedil:"Ģ",gcirc:"ĝ",Gcirc:"Ĝ",gcy:"г",Gcy:"Г",gdot:"ġ",Gdot:"Ġ",ge:"≥",gE:"≧",gel:"⋛",gEl:"⪌",geq:"≥",geqq:"≧",geqslant:"⩾",ges:"⩾",gescc:"⪩",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",gfr:"𝔤",Gfr:"𝔊",gg:"≫",Gg:"⋙",ggg:"⋙",gimel:"ℷ",gjcy:"ѓ",GJcy:"Ѓ",gl:"≷",gla:"⪥",glE:"⪒",glj:"⪤",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gnE:"≩",gneq:"⪈",gneqq:"≩",gnsim:"⋧",gopf:"𝕘",Gopf:"𝔾",grave:"`",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",gscr:"ℊ",Gscr:"𝒢",gsim:"≳",gsime:"⪎",gsiml:"⪐",gt:">",Gt:"≫",GT:">",gtcc:"⪧",gtcir:"⩺",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",hardcy:"ъ",HARDcy:"Ъ",harr:"↔",hArr:"⇔",harrcir:"⥈",harrw:"↭",Hat:"^",hbar:"ℏ",hcirc:"ĥ",Hcirc:"Ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",Hfr:"ℌ",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",Hopf:"ℍ",horbar:"―",HorizontalLine:"─",hscr:"𝒽",Hscr:"ℋ",hslash:"ℏ",hstrok:"ħ",Hstrok:"Ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",iacute:"í",Iacute:"Í",ic:"⁣",icirc:"î",Icirc:"Î",icy:"и",Icy:"И",Idot:"İ",iecy:"е",IEcy:"Е",iexcl:"¡",iff:"⇔",ifr:"𝔦",Ifr:"ℑ",igrave:"ì",Igrave:"Ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",ijlig:"ij",IJlig:"IJ",Im:"ℑ",imacr:"ī",Imacr:"Ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",imof:"⊷",imped:"Ƶ",Implies:"⇒",in:"∈",incare:"℅",infin:"∞",infintie:"⧝",inodot:"ı",int:"∫",Int:"∬",intcal:"⊺",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"⁣",InvisibleTimes:"⁢",iocy:"ё",IOcy:"Ё",iogon:"į",Iogon:"Į",iopf:"𝕚",Iopf:"𝕀",iota:"ι",Iota:"Ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",Iscr:"ℐ",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",itilde:"ĩ",Itilde:"Ĩ",iukcy:"і",Iukcy:"І",iuml:"ï",Iuml:"Ï",jcirc:"ĵ",Jcirc:"Ĵ",jcy:"й",Jcy:"Й",jfr:"𝔧",Jfr:"𝔍",jmath:"ȷ",jopf:"𝕛",Jopf:"𝕁",jscr:"𝒿",Jscr:"𝒥",jsercy:"ј",Jsercy:"Ј",jukcy:"є",Jukcy:"Є",kappa:"κ",Kappa:"Κ",kappav:"ϰ",kcedil:"ķ",Kcedil:"Ķ",kcy:"к",Kcy:"К",kfr:"𝔨",Kfr:"𝔎",kgreen:"ĸ",khcy:"х",KHcy:"Х",kjcy:"ќ",KJcy:"Ќ",kopf:"𝕜",Kopf:"𝕂",kscr:"𝓀",Kscr:"𝒦",lAarr:"⇚",lacute:"ĺ",Lacute:"Ĺ",laemptyv:"⦴",lagran:"ℒ",lambda:"λ",Lambda:"Λ",lang:"⟨",Lang:"⟪",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",larr:"←",lArr:"⇐",Larr:"↞",larrb:"⇤",larrbfs:"⤟",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",lat:"⪫",latail:"⤙",lAtail:"⤛",late:"⪭",lates:"⪭︀",lbarr:"⤌",lBarr:"⤎",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",lcaron:"ľ",Lcaron:"Ľ",lcedil:"ļ",Lcedil:"Ļ",lceil:"⌈",lcub:"{",lcy:"л",Lcy:"Л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",lE:"≦",LeftAngleBracket:"⟨",leftarrow:"←",Leftarrow:"⇐",LeftArrow:"←",LeftArrowBar:"⇤",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVector:"⇃",LeftDownVectorBar:"⥙",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",Leftrightarrow:"⇔",LeftRightArrow:"↔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTee:"⊣",LeftTeeArrow:"↤",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangle:"⊲",LeftTriangleBar:"⧏",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVector:"↿",LeftUpVectorBar:"⥘",LeftVector:"↼",LeftVectorBar:"⥒",leg:"⋚",lEg:"⪋",leq:"≤",leqq:"≦",leqslant:"⩽",les:"⩽",lescc:"⪨",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",lfr:"𝔩",Lfr:"𝔏",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",ljcy:"љ",LJcy:"Љ",ll:"≪",Ll:"⋘",llarr:"⇇",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",lmidot:"ŀ",Lmidot:"Ŀ",lmoust:"⎰",lmoustache:"⎰",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lnE:"≨",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",Longleftarrow:"⟸",LongLeftArrow:"⟵",longleftrightarrow:"⟷",Longleftrightarrow:"⟺",LongLeftRightArrow:"⟷",longmapsto:"⟼",longrightarrow:"⟶",Longrightarrow:"⟹",LongRightArrow:"⟶",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",lopf:"𝕝",Lopf:"𝕃",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",Lscr:"ℒ",lsh:"↰",Lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",lstrok:"ł",Lstrok:"Ł",lt:"<",Lt:"≪",LT:"<",ltcc:"⪦",ltcir:"⩹",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",map:"↦",Map:"⤅",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",mcy:"м",Mcy:"М",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",mfr:"𝔪",Mfr:"𝔐",mho:"℧",micro:"µ",mid:"∣",midast:"*",midcir:"⫰",middot:"·",minus:"−",minusb:"⊟",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",mopf:"𝕞",Mopf:"𝕄",mp:"∓",mscr:"𝓂",Mscr:"ℳ",mstpos:"∾",mu:"μ",Mu:"Μ",multimap:"⊸",mumap:"⊸",nabla:"∇",nacute:"ń",Nacute:"Ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natur:"♮",natural:"♮",naturals:"ℕ",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",ncaron:"ň",Ncaron:"Ň",ncedil:"ņ",Ncedil:"Ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",ncy:"н",Ncy:"Н",ndash:"–",ne:"≠",nearhk:"⤤",nearr:"↗",neArr:"⇗",nearrow:"↗",nedot:"≐̸",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",nfr:"𝔫",Nfr:"𝔑",nge:"≱",ngE:"≧̸",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",ngt:"≯",nGt:"≫⃒",ngtr:"≯",nGtv:"≫̸",nharr:"↮",nhArr:"⇎",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",njcy:"њ",NJcy:"Њ",nlarr:"↚",nlArr:"⇍",nldr:"‥",nle:"≰",nlE:"≦̸",nleftarrow:"↚",nLeftarrow:"⇍",nleftrightarrow:"↮",nLeftrightarrow:"⇎",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nlt:"≮",nLt:"≪⃒",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"⁠",NonBreakingSpace:" ",nopf:"𝕟",Nopf:"ℕ",not:"¬",Not:"⫬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangle:"⋪",NotLeftTriangleBar:"⧏̸",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangle:"⋫",NotRightTriangleBar:"⧐̸",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",npar:"∦",nparallel:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",npre:"⪯̸",nprec:"⊀",npreceq:"⪯̸",nrarr:"↛",nrArr:"⇏",nrarrc:"⤳̸",nrarrw:"↝̸",nrightarrow:"↛",nRightarrow:"⇏",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",nscr:"𝓃",Nscr:"𝒩",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsube:"⊈",nsubE:"⫅̸",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupe:"⊉",nsupE:"⫆̸",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",ntilde:"ñ",Ntilde:"Ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",nu:"ν",Nu:"Ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nvdash:"⊬",nvDash:"⊭",nVdash:"⊮",nVDash:"⊯",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwarr:"↖",nwArr:"⇖",nwarrow:"↖",nwnear:"⤧",oacute:"ó",Oacute:"Ó",oast:"⊛",ocir:"⊚",ocirc:"ô",Ocirc:"Ô",ocy:"о",Ocy:"О",odash:"⊝",odblac:"ő",Odblac:"Ő",odiv:"⨸",odot:"⊙",odsold:"⦼",oelig:"œ",OElig:"Œ",ofcir:"⦿",ofr:"𝔬",Ofr:"𝔒",ogon:"˛",ograve:"ò",Ograve:"Ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",omacr:"ō",Omacr:"Ō",omega:"ω",Omega:"Ω",omicron:"ο",Omicron:"Ο",omid:"⦶",ominus:"⊖",oopf:"𝕠",Oopf:"𝕆",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",or:"∨",Or:"⩔",orarr:"↻",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",oscr:"ℴ",Oscr:"𝒪",oslash:"ø",Oslash:"Ø",osol:"⊘",otilde:"õ",Otilde:"Õ",otimes:"⊗",Otimes:"⨷",otimesas:"⨶",ouml:"ö",Ouml:"Ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",par:"∥",para:"¶",parallel:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",pcy:"п",Pcy:"П",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",pfr:"𝔭",Pfr:"𝔓",phi:"φ",Phi:"Φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",pi:"π",Pi:"Π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plus:"+",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",popf:"𝕡",Popf:"ℙ",pound:"£",pr:"≺",Pr:"⪻",prap:"⪷",prcue:"≼",pre:"⪯",prE:"⪳",prec:"≺",precapprox:"⪷",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",precsim:"≾",prime:"′",Prime:"″",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportion:"∷",Proportional:"∝",propto:"∝",prsim:"≾",prurel:"⊰",pscr:"𝓅",Pscr:"𝒫",psi:"ψ",Psi:"Ψ",puncsp:" ",qfr:"𝔮",Qfr:"𝔔",qint:"⨌",qopf:"𝕢",Qopf:"ℚ",qprime:"⁗",qscr:"𝓆",Qscr:"𝒬",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',QUOT:'"',rAarr:"⇛",race:"∽̱",racute:"ŕ",Racute:"Ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",Rang:"⟫",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarr:"→",rArr:"⇒",Rarr:"↠",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",rarrtl:"↣",Rarrtl:"⤖",rarrw:"↝",ratail:"⤚",rAtail:"⤜",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rBarr:"⤏",RBarr:"⤐",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",rcaron:"ř",Rcaron:"Ř",rcedil:"ŗ",Rcedil:"Ŗ",rceil:"⌉",rcub:"}",rcy:"р",Rcy:"Р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",Re:"ℜ",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",rect:"▭",reg:"®",REG:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",Rfr:"ℜ",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",rho:"ρ",Rho:"Ρ",rhov:"ϱ",RightAngleBracket:"⟩",rightarrow:"→",Rightarrow:"⇒",RightArrow:"→",RightArrowBar:"⇥",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVector:"⇂",RightDownVectorBar:"⥕",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTee:"⊢",RightTeeArrow:"↦",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangle:"⊳",RightTriangleBar:"⧐",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVector:"↾",RightUpVectorBar:"⥔",RightVector:"⇀",RightVectorBar:"⥓",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoust:"⎱",rmoustache:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",Ropf:"ℝ",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",rscr:"𝓇",Rscr:"ℛ",rsh:"↱",Rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",sacute:"ś",Sacute:"Ś",sbquo:"‚",sc:"≻",Sc:"⪼",scap:"⪸",scaron:"š",Scaron:"Š",sccue:"≽",sce:"⪰",scE:"⪴",scedil:"ş",Scedil:"Ş",scirc:"ŝ",Scirc:"Ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",scy:"с",Scy:"С",sdot:"⋅",sdotb:"⊡",sdote:"⩦",searhk:"⤥",searr:"↘",seArr:"⇘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",sfr:"𝔰",Sfr:"𝔖",sfrown:"⌢",sharp:"♯",shchcy:"щ",SHCHcy:"Щ",shcy:"ш",SHcy:"Ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"­",sigma:"σ",Sigma:"Σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",softcy:"ь",SOFTcy:"Ь",sol:"/",solb:"⧄",solbar:"⌿",sopf:"𝕤",Sopf:"𝕊",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",squ:"□",square:"□",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squf:"▪",srarr:"→",sscr:"𝓈",Sscr:"𝒮",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",star:"☆",Star:"⋆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",Sub:"⋐",subdot:"⪽",sube:"⊆",subE:"⫅",subedot:"⫃",submult:"⫁",subne:"⊊",subnE:"⫋",subplus:"⪿",subrarr:"⥹",subset:"⊂",Subset:"⋐",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succ:"≻",succapprox:"⪸",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",sum:"∑",Sum:"∑",sung:"♪",sup:"⊃",Sup:"⋑",sup1:"¹",sup2:"²",sup3:"³",supdot:"⪾",supdsub:"⫘",supe:"⊇",supE:"⫆",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supne:"⊋",supnE:"⫌",supplus:"⫀",supset:"⊃",Supset:"⋑",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swarr:"↙",swArr:"⇙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",tau:"τ",Tau:"Τ",tbrk:"⎴",tcaron:"ť",Tcaron:"Ť",tcedil:"ţ",Tcedil:"Ţ",tcy:"т",Tcy:"Т",tdot:"⃛",telrec:"⌕",tfr:"𝔱",Tfr:"𝔗",there4:"∴",therefore:"∴",Therefore:"∴",theta:"θ",Theta:"Θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:"  ",thinsp:" ",ThinSpace:" ",thkap:"≈",thksim:"∼",thorn:"þ",THORN:"Þ",tilde:"˜",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",times:"×",timesb:"⊠",timesbar:"⨱",timesd:"⨰",tint:"∭",toea:"⤨",top:"⊤",topbot:"⌶",topcir:"⫱",topf:"𝕥",Topf:"𝕋",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",TRADE:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",tscr:"𝓉",Tscr:"𝒯",tscy:"ц",TScy:"Ц",tshcy:"ћ",TSHcy:"Ћ",tstrok:"ŧ",Tstrok:"Ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",uacute:"ú",Uacute:"Ú",uarr:"↑",uArr:"⇑",Uarr:"↟",Uarrocir:"⥉",ubrcy:"ў",Ubrcy:"Ў",ubreve:"ŭ",Ubreve:"Ŭ",ucirc:"û",Ucirc:"Û",ucy:"у",Ucy:"У",udarr:"⇅",udblac:"ű",Udblac:"Ű",udhar:"⥮",ufisht:"⥾",ufr:"𝔲",Ufr:"𝔘",ugrave:"ù",Ugrave:"Ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",umacr:"ū",Umacr:"Ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",uogon:"ų",Uogon:"Ų",uopf:"𝕦",Uopf:"𝕌",uparrow:"↑",Uparrow:"⇑",UpArrow:"↑",UpArrowBar:"⤒",UpArrowDownArrow:"⇅",updownarrow:"↕",Updownarrow:"⇕",UpDownArrow:"↕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",upsi:"υ",Upsi:"ϒ",upsih:"ϒ",upsilon:"υ",Upsilon:"Υ",UpTee:"⊥",UpTeeArrow:"↥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",uring:"ů",Uring:"Ů",urtri:"◹",uscr:"𝓊",Uscr:"𝒰",utdot:"⋰",utilde:"ũ",Utilde:"Ũ",utri:"▵",utrif:"▴",uuarr:"⇈",uuml:"ü",Uuml:"Ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",vArr:"⇕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vBar:"⫨",Vbar:"⫫",vBarv:"⫩",vcy:"в",Vcy:"В",vdash:"⊢",vDash:"⊨",Vdash:"⊩",VDash:"⊫",Vdashl:"⫦",vee:"∨",Vee:"⋁",veebar:"⊻",veeeq:"≚",vellip:"⋮",verbar:"|",Verbar:"‖",vert:"|",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",vfr:"𝔳",Vfr:"𝔙",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",vopf:"𝕧",Vopf:"𝕍",vprop:"∝",vrtri:"⊳",vscr:"𝓋",Vscr:"𝒱",vsubne:"⊊︀",vsubnE:"⫋︀",vsupne:"⊋︀",vsupnE:"⫌︀",Vvdash:"⊪",vzigzag:"⦚",wcirc:"ŵ",Wcirc:"Ŵ",wedbar:"⩟",wedge:"∧",Wedge:"⋀",wedgeq:"≙",weierp:"℘",wfr:"𝔴",Wfr:"𝔚",wopf:"𝕨",Wopf:"𝕎",wp:"℘",wr:"≀",wreath:"≀",wscr:"𝓌",Wscr:"𝒲",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",xfr:"𝔵",Xfr:"𝔛",xharr:"⟷",xhArr:"⟺",xi:"ξ",Xi:"Ξ",xlarr:"⟵",xlArr:"⟸",xmap:"⟼",xnis:"⋻",xodot:"⨀",xopf:"𝕩",Xopf:"𝕏",xoplus:"⨁",xotime:"⨂",xrarr:"⟶",xrArr:"⟹",xscr:"𝓍",Xscr:"𝒳",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",yacute:"ý",Yacute:"Ý",yacy:"я",YAcy:"Я",ycirc:"ŷ",Ycirc:"Ŷ",ycy:"ы",Ycy:"Ы",yen:"¥",yfr:"𝔶",Yfr:"𝔜",yicy:"ї",YIcy:"Ї",yopf:"𝕪",Yopf:"𝕐",yscr:"𝓎",Yscr:"𝒴",yucy:"ю",YUcy:"Ю",yuml:"ÿ",Yuml:"Ÿ",zacute:"ź",Zacute:"Ź",zcaron:"ž",Zcaron:"Ž",zcy:"з",Zcy:"З",zdot:"ż",Zdot:"Ż",zeetrf:"ℨ",ZeroWidthSpace:"​",zeta:"ζ",Zeta:"Ζ",zfr:"𝔷",Zfr:"ℨ",zhcy:"ж",ZHcy:"Ж",zigrarr:"⇝",zopf:"𝕫",Zopf:"ℤ",zscr:"𝓏",Zscr:"𝒵",zwj:"‍",zwnj:"‌"},h={aacute:"á",Aacute:"Á",acirc:"â",Acirc:"Â",acute:"´",aelig:"æ",AElig:"Æ",agrave:"à",Agrave:"À",amp:"&",AMP:"&",aring:"å",Aring:"Å",atilde:"ã",Atilde:"Ã",auml:"ä",Auml:"Ä",brvbar:"¦",ccedil:"ç",Ccedil:"Ç",cedil:"¸",cent:"¢",copy:"©",COPY:"©",curren:"¤",deg:"°",divide:"÷",eacute:"é",Eacute:"É",ecirc:"ê",Ecirc:"Ê",egrave:"è",Egrave:"È",eth:"ð",ETH:"Ð",euml:"ë",Euml:"Ë",frac12:"½",frac14:"¼",frac34:"¾",gt:">",GT:">",iacute:"í",Iacute:"Í",icirc:"î",Icirc:"Î",iexcl:"¡",igrave:"ì",Igrave:"Ì",iquest:"¿",iuml:"ï",Iuml:"Ï",laquo:"«",lt:"<",LT:"<",macr:"¯",micro:"µ",middot:"·",nbsp:" ",not:"¬",ntilde:"ñ",Ntilde:"Ñ",oacute:"ó",Oacute:"Ó",ocirc:"ô",Ocirc:"Ô",ograve:"ò",Ograve:"Ò",ordf:"ª",ordm:"º",oslash:"ø",Oslash:"Ø",otilde:"õ",Otilde:"Õ",ouml:"ö",Ouml:"Ö",para:"¶",plusmn:"±",pound:"£",quot:'"',QUOT:'"',raquo:"»",reg:"®",REG:"®",sect:"§",shy:"­",sup1:"¹",sup2:"²",sup3:"³",szlig:"ß",thorn:"þ",THORN:"Þ",times:"×",uacute:"ú",Uacute:"Ú",ucirc:"û",Ucirc:"Û",ugrave:"ù",Ugrave:"Ù",uml:"¨",uuml:"ü",Uuml:"Ü",yacute:"ý",Yacute:"Ý",yen:"¥",yuml:"ÿ"},g={0:"�",128:"€",130:"‚",131:"ƒ",132:"„",133:"…",134:"†",135:"‡",136:"ˆ",137:"‰",138:"Š",139:"‹",140:"Œ",142:"Ž",145:"‘",146:"’",147:"“",148:"”",149:"•",150:"–",151:"—",152:"˜",153:"™",154:"š",155:"›",156:"œ",158:"ž",159:"Ÿ"},E=[1,2,3,4,5,6,7,8,11,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,64976,64977,64978,64979,64980,64981,64982,64983,64984,64985,64986,64987,64988,64989,64990,64991,64992,64993,64994,64995,64996,64997,64998,64999,65e3,65001,65002,65003,65004,65005,65006,65007,65534,65535,131070,131071,196606,196607,262142,262143,327678,327679,393214,393215,458750,458751,524286,524287,589822,589823,655358,655359,720894,720895,786430,786431,851966,851967,917502,917503,983038,983039,1048574,1048575,1114110,1114111],_=String.fromCharCode,v={}.hasOwnProperty,T=function(C,L){return v.call(C,L)},H=function(C,L){if(!C)return L;var $,k={};for($ in L)k[$]=T(C,$)?C[$]:L[$];return k},I=function(C,L){var k="";return C>=55296&&C<=57343||C>1114111?(L&&K("character reference outside the permissible Unicode range"),"�"):T(g,C)?(L&&K("disallowed character reference"),g[C]):(L&&function(C,L){for(var k=-1,$=C.length;++k<$;)if(C[k]==L)return!0;return!1}(E,C)&&K("disallowed character reference"),C>65535&&(k+=_((C-=65536)>>>10&1023|55296),C=56320|1023&C),k+=_(C))},W=function(C){return"&#x"+C.toString(16).toUpperCase()+";"},le=function(C){return"&#"+C+";"},K=function(C){throw Error("Parse error: "+C)},O=function(C,L){(L=H(L,O.options)).strict&&b.test(C)&&K("forbidden code point");var $=L.encodeEverything,j=L.useNamedReferences,ge=L.allowUnsafeSymbols,pe=L.decimal?le:W,ue=function(ce){return pe(ce.charCodeAt(0))};return $?(C=C.replace(i,(function(ce){return j&&T(c,ce)?"&"+c[ce]+";":ue(ce)})),j&&(C=C.replace(/>\u20D2/g,">⃒").replace(/<\u20D2/g,"<⃒").replace(/fj/g,"fj")),j&&(C=C.replace(u,(function(ce){return"&"+c[ce]+";"})))):j?(ge||(C=C.replace(p,(function(ce){return"&"+c[ce]+";"}))),C=(C=C.replace(/>\u20D2/g,">⃒").replace(/<\u20D2/g,"<⃒")).replace(u,(function(ce){return"&"+c[ce]+";"}))):ge||(C=C.replace(p,ue)),C.replace(s,(function(ce){var bt=ce.charCodeAt(0),Ut=ce.charCodeAt(1);return pe(1024*(bt-55296)+Ut-56320+65536)})).replace(l,ue)};O.options={allowUnsafeSymbols:!1,encodeEverything:!1,strict:!1,useNamedReferences:!1,decimal:!1};var V=function(C,L){var k=(L=H(L,V.options)).strict;return k&&f.test(C)&&K("malformed character reference"),C.replace(A,(function($,j,ge,pe,ue,ce,bt,Ut,At){var je,xt,ne,Oe,Be,Ne;return j?y[Be=j]:ge?(Be=ge,(Ne=pe)&&L.isAttributeValue?(k&&"="==Ne&&K("`&` did not start a character reference"),$):(k&&K("named character reference was not terminated by a semicolon"),h[Be]+(Ne||""))):ue?(ne=ue,xt=ce,k&&!xt&&K("character reference was not terminated by a semicolon"),je=parseInt(ne,10),I(je,k)):bt?(Oe=bt,xt=Ut,k&&!xt&&K("character reference was not terminated by a semicolon"),je=parseInt(Oe,16),I(je,k)):(k&&K("named character reference was not terminated by a semicolon"),$)}))};V.options={isAttributeValue:!1,strict:!1};var x={version:"1.2.0",encode:O,decode:V,escape:function(C){return C.replace(p,(function(L){return d[L]}))},unescape:V};if(r&&!r.nodeType)if(o)o.exports=x;else for(var S in x)T(x,S)&&(r[S]=x[S]);else n.he=x}(xe)}(Ns,Ns.exports);var Z_=Ns.exports,ie={},i2={Aacute:"Á",aacute:"á",Abreve:"Ă",abreve:"ă",ac:"∾",acd:"∿",acE:"∾̳",Acirc:"Â",acirc:"â",acute:"´",Acy:"А",acy:"а",AElig:"Æ",aelig:"æ",af:"⁡",Afr:"𝔄",afr:"𝔞",Agrave:"À",agrave:"à",alefsym:"ℵ",aleph:"ℵ",Alpha:"Α",alpha:"α",Amacr:"Ā",amacr:"ā",amalg:"⨿",amp:"&",AMP:"&",andand:"⩕",And:"⩓",and:"∧",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angmsd:"∡",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",Aogon:"Ą",aogon:"ą",Aopf:"𝔸",aopf:"𝕒",apacir:"⩯",ap:"≈",apE:"⩰",ape:"≊",apid:"≋",apos:"'",ApplyFunction:"⁡",approx:"≈",approxeq:"≊",Aring:"Å",aring:"å",Ascr:"𝒜",ascr:"𝒶",Assign:"≔",ast:"*",asymp:"≈",asympeq:"≍",Atilde:"Ã",atilde:"ã",Auml:"Ä",auml:"ä",awconint:"∳",awint:"⨑",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",Backslash:"∖",Barv:"⫧",barvee:"⊽",barwed:"⌅",Barwed:"⌆",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",Bcy:"Б",bcy:"б",bdquo:"„",becaus:"∵",because:"∵",Because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",Bernoullis:"ℬ",Beta:"Β",beta:"β",beth:"ℶ",between:"≬",Bfr:"𝔅",bfr:"𝔟",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bNot:"⫭",bnot:"⌐",Bopf:"𝔹",bopf:"𝕓",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxbox:"⧉",boxdl:"┐",boxdL:"╕",boxDl:"╖",boxDL:"╗",boxdr:"┌",boxdR:"╒",boxDr:"╓",boxDR:"╔",boxh:"─",boxH:"═",boxhd:"┬",boxHd:"╤",boxhD:"╥",boxHD:"╦",boxhu:"┴",boxHu:"╧",boxhU:"╨",boxHU:"╩",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxul:"┘",boxuL:"╛",boxUl:"╜",boxUL:"╝",boxur:"└",boxuR:"╘",boxUr:"╙",boxUR:"╚",boxv:"│",boxV:"║",boxvh:"┼",boxvH:"╪",boxVh:"╫",boxVH:"╬",boxvl:"┤",boxvL:"╡",boxVl:"╢",boxVL:"╣",boxvr:"├",boxvR:"╞",boxVr:"╟",boxVR:"╠",bprime:"‵",breve:"˘",Breve:"˘",brvbar:"¦",bscr:"𝒷",Bscr:"ℬ",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsolb:"⧅",bsol:"\\",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpE:"⪮",bumpe:"≏",Bumpeq:"≎",bumpeq:"≏",Cacute:"Ć",cacute:"ć",capand:"⩄",capbrcup:"⩉",capcap:"⩋",cap:"∩",Cap:"⋒",capcup:"⩇",capdot:"⩀",CapitalDifferentialD:"ⅅ",caps:"∩︀",caret:"⁁",caron:"ˇ",Cayleys:"ℭ",ccaps:"⩍",Ccaron:"Č",ccaron:"č",Ccedil:"Ç",ccedil:"ç",Ccirc:"Ĉ",ccirc:"ĉ",Cconint:"∰",ccups:"⩌",ccupssm:"⩐",Cdot:"Ċ",cdot:"ċ",cedil:"¸",Cedilla:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",CenterDot:"·",cfr:"𝔠",Cfr:"ℭ",CHcy:"Ч",chcy:"ч",check:"✓",checkmark:"✓",Chi:"Χ",chi:"χ",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",CircleDot:"⊙",circledR:"®",circledS:"Ⓢ",CircleMinus:"⊖",CirclePlus:"⊕",CircleTimes:"⊗",cir:"○",cirE:"⧃",cire:"≗",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",ClockwiseContourIntegral:"∲",CloseCurlyDoubleQuote:"”",CloseCurlyQuote:"’",clubs:"♣",clubsuit:"♣",colon:":",Colon:"∷",Colone:"⩴",colone:"≔",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",Congruent:"≡",conint:"∮",Conint:"∯",ContourIntegral:"∮",copf:"𝕔",Copf:"ℂ",coprod:"∐",Coproduct:"∐",copy:"©",COPY:"©",copysr:"℗",CounterClockwiseContourIntegral:"∳",crarr:"↵",cross:"✗",Cross:"⨯",Cscr:"𝒞",cscr:"𝒸",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cupbrcap:"⩈",cupcap:"⩆",CupCap:"≍",cup:"∪",Cup:"⋓",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dagger:"†",Dagger:"‡",daleth:"ℸ",darr:"↓",Darr:"↡",dArr:"⇓",dash:"‐",Dashv:"⫤",dashv:"⊣",dbkarow:"⤏",dblac:"˝",Dcaron:"Ď",dcaron:"ď",Dcy:"Д",dcy:"д",ddagger:"‡",ddarr:"⇊",DD:"ⅅ",dd:"ⅆ",DDotrahd:"⤑",ddotseq:"⩷",deg:"°",Del:"∇",Delta:"Δ",delta:"δ",demptyv:"⦱",dfisht:"⥿",Dfr:"𝔇",dfr:"𝔡",dHar:"⥥",dharl:"⇃",dharr:"⇂",DiacriticalAcute:"´",DiacriticalDot:"˙",DiacriticalDoubleAcute:"˝",DiacriticalGrave:"`",DiacriticalTilde:"˜",diam:"⋄",diamond:"⋄",Diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",DifferentialD:"ⅆ",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",DJcy:"Ђ",djcy:"ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",Dopf:"𝔻",dopf:"𝕕",Dot:"¨",dot:"˙",DotDot:"⃜",doteq:"≐",doteqdot:"≑",DotEqual:"≐",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",DoubleContourIntegral:"∯",DoubleDot:"¨",DoubleDownArrow:"⇓",DoubleLeftArrow:"⇐",DoubleLeftRightArrow:"⇔",DoubleLeftTee:"⫤",DoubleLongLeftArrow:"⟸",DoubleLongLeftRightArrow:"⟺",DoubleLongRightArrow:"⟹",DoubleRightArrow:"⇒",DoubleRightTee:"⊨",DoubleUpArrow:"⇑",DoubleUpDownArrow:"⇕",DoubleVerticalBar:"∥",DownArrowBar:"⤓",downarrow:"↓",DownArrow:"↓",Downarrow:"⇓",DownArrowUpArrow:"⇵",DownBreve:"̑",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",DownLeftRightVector:"⥐",DownLeftTeeVector:"⥞",DownLeftVectorBar:"⥖",DownLeftVector:"↽",DownRightTeeVector:"⥟",DownRightVectorBar:"⥗",DownRightVector:"⇁",DownTeeArrow:"↧",DownTee:"⊤",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",Dscr:"𝒟",dscr:"𝒹",DScy:"Ѕ",dscy:"ѕ",dsol:"⧶",Dstrok:"Đ",dstrok:"đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",DZcy:"Џ",dzcy:"џ",dzigrarr:"⟿",Eacute:"É",eacute:"é",easter:"⩮",Ecaron:"Ě",ecaron:"ě",Ecirc:"Ê",ecirc:"ê",ecir:"≖",ecolon:"≕",Ecy:"Э",ecy:"э",eDDot:"⩷",Edot:"Ė",edot:"ė",eDot:"≑",ee:"ⅇ",efDot:"≒",Efr:"𝔈",efr:"𝔢",eg:"⪚",Egrave:"È",egrave:"è",egs:"⪖",egsdot:"⪘",el:"⪙",Element:"∈",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",Emacr:"Ē",emacr:"ē",empty:"∅",emptyset:"∅",EmptySmallSquare:"◻",emptyv:"∅",EmptyVerySmallSquare:"▫",emsp13:" ",emsp14:" ",emsp:" ",ENG:"Ŋ",eng:"ŋ",ensp:" ",Eogon:"Ę",eogon:"ę",Eopf:"𝔼",eopf:"𝕖",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",Epsilon:"Ε",epsilon:"ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",Equal:"⩵",equals:"=",EqualTilde:"≂",equest:"≟",Equilibrium:"⇌",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erarr:"⥱",erDot:"≓",escr:"ℯ",Escr:"ℰ",esdot:"≐",Esim:"⩳",esim:"≂",Eta:"Η",eta:"η",ETH:"Ð",eth:"ð",Euml:"Ë",euml:"ë",euro:"€",excl:"!",exist:"∃",Exists:"∃",expectation:"ℰ",exponentiale:"ⅇ",ExponentialE:"ⅇ",fallingdotseq:"≒",Fcy:"Ф",fcy:"ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",Ffr:"𝔉",ffr:"𝔣",filig:"fi",FilledSmallSquare:"◼",FilledVerySmallSquare:"▪",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",Fopf:"𝔽",fopf:"𝕗",forall:"∀",ForAll:"∀",fork:"⋔",forkv:"⫙",Fouriertrf:"ℱ",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",Fscr:"ℱ",gacute:"ǵ",Gamma:"Γ",gamma:"γ",Gammad:"Ϝ",gammad:"ϝ",gap:"⪆",Gbreve:"Ğ",gbreve:"ğ",Gcedil:"Ģ",Gcirc:"Ĝ",gcirc:"ĝ",Gcy:"Г",gcy:"г",Gdot:"Ġ",gdot:"ġ",ge:"≥",gE:"≧",gEl:"⪌",gel:"⋛",geq:"≥",geqq:"≧",geqslant:"⩾",gescc:"⪩",ges:"⩾",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",Gfr:"𝔊",gfr:"𝔤",gg:"≫",Gg:"⋙",ggg:"⋙",gimel:"ℷ",GJcy:"Ѓ",gjcy:"ѓ",gla:"⪥",gl:"≷",glE:"⪒",glj:"⪤",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gnE:"≩",gneq:"⪈",gneqq:"≩",gnsim:"⋧",Gopf:"𝔾",gopf:"𝕘",grave:"`",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",Gscr:"𝒢",gscr:"ℊ",gsim:"≳",gsime:"⪎",gsiml:"⪐",gtcc:"⪧",gtcir:"⩺",gt:">",GT:">",Gt:"≫",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",Hacek:"ˇ",hairsp:" ",half:"½",hamilt:"ℋ",HARDcy:"Ъ",hardcy:"ъ",harrcir:"⥈",harr:"↔",hArr:"⇔",harrw:"↭",Hat:"^",hbar:"ℏ",Hcirc:"Ĥ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",Hfr:"ℌ",HilbertSpace:"ℋ",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",Hopf:"ℍ",horbar:"―",HorizontalLine:"─",hscr:"𝒽",Hscr:"ℋ",hslash:"ℏ",Hstrok:"Ħ",hstrok:"ħ",HumpDownHump:"≎",HumpEqual:"≏",hybull:"⁃",hyphen:"‐",Iacute:"Í",iacute:"í",ic:"⁣",Icirc:"Î",icirc:"î",Icy:"И",icy:"и",Idot:"İ",IEcy:"Е",iecy:"е",iexcl:"¡",iff:"⇔",ifr:"𝔦",Ifr:"ℑ",Igrave:"Ì",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",IJlig:"IJ",ijlig:"ij",Imacr:"Ī",imacr:"ī",image:"ℑ",ImaginaryI:"ⅈ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",Im:"ℑ",imof:"⊷",imped:"Ƶ",Implies:"⇒",incare:"℅",in:"∈",infin:"∞",infintie:"⧝",inodot:"ı",intcal:"⊺",int:"∫",Int:"∬",integers:"ℤ",Integral:"∫",intercal:"⊺",Intersection:"⋂",intlarhk:"⨗",intprod:"⨼",InvisibleComma:"⁣",InvisibleTimes:"⁢",IOcy:"Ё",iocy:"ё",Iogon:"Į",iogon:"į",Iopf:"𝕀",iopf:"𝕚",Iota:"Ι",iota:"ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",Iscr:"ℐ",isin:"∈",isindot:"⋵",isinE:"⋹",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",Itilde:"Ĩ",itilde:"ĩ",Iukcy:"І",iukcy:"і",Iuml:"Ï",iuml:"ï",Jcirc:"Ĵ",jcirc:"ĵ",Jcy:"Й",jcy:"й",Jfr:"𝔍",jfr:"𝔧",jmath:"ȷ",Jopf:"𝕁",jopf:"𝕛",Jscr:"𝒥",jscr:"𝒿",Jsercy:"Ј",jsercy:"ј",Jukcy:"Є",jukcy:"є",Kappa:"Κ",kappa:"κ",kappav:"ϰ",Kcedil:"Ķ",kcedil:"ķ",Kcy:"К",kcy:"к",Kfr:"𝔎",kfr:"𝔨",kgreen:"ĸ",KHcy:"Х",khcy:"х",KJcy:"Ќ",kjcy:"ќ",Kopf:"𝕂",kopf:"𝕜",Kscr:"𝒦",kscr:"𝓀",lAarr:"⇚",Lacute:"Ĺ",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",Lambda:"Λ",lambda:"λ",lang:"⟨",Lang:"⟪",langd:"⦑",langle:"⟨",lap:"⪅",Laplacetrf:"ℒ",laquo:"«",larrb:"⇤",larrbfs:"⤟",larr:"←",Larr:"↞",lArr:"⇐",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",latail:"⤙",lAtail:"⤛",lat:"⪫",late:"⪭",lates:"⪭︀",lbarr:"⤌",lBarr:"⤎",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",Lcaron:"Ľ",lcaron:"ľ",Lcedil:"Ļ",lcedil:"ļ",lceil:"⌈",lcub:"{",Lcy:"Л",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",lE:"≦",LeftAngleBracket:"⟨",LeftArrowBar:"⇤",leftarrow:"←",LeftArrow:"←",Leftarrow:"⇐",LeftArrowRightArrow:"⇆",leftarrowtail:"↢",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVectorBar:"⥙",LeftDownVector:"⇃",LeftFloor:"⌊",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",LeftRightArrow:"↔",Leftrightarrow:"⇔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",LeftRightVector:"⥎",LeftTeeArrow:"↤",LeftTee:"⊣",LeftTeeVector:"⥚",leftthreetimes:"⋋",LeftTriangleBar:"⧏",LeftTriangle:"⊲",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVectorBar:"⥘",LeftUpVector:"↿",LeftVectorBar:"⥒",LeftVector:"↼",lEg:"⪋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",lescc:"⪨",les:"⩽",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",lessgtr:"≶",LessLess:"⪡",lesssim:"≲",LessSlantEqual:"⩽",LessTilde:"≲",lfisht:"⥼",lfloor:"⌊",Lfr:"𝔏",lfr:"𝔩",lg:"≶",lgE:"⪑",lHar:"⥢",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",LJcy:"Љ",ljcy:"љ",llarr:"⇇",ll:"≪",Ll:"⋘",llcorner:"⌞",Lleftarrow:"⇚",llhard:"⥫",lltri:"◺",Lmidot:"Ŀ",lmidot:"ŀ",lmoustache:"⎰",lmoust:"⎰",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lnE:"≨",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",LongLeftArrow:"⟵",Longleftarrow:"⟸",longleftrightarrow:"⟷",LongLeftRightArrow:"⟷",Longleftrightarrow:"⟺",longmapsto:"⟼",longrightarrow:"⟶",LongRightArrow:"⟶",Longrightarrow:"⟹",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",Lopf:"𝕃",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",LowerLeftArrow:"↙",LowerRightArrow:"↘",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",Lscr:"ℒ",lsh:"↰",Lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",Lstrok:"Ł",lstrok:"ł",ltcc:"⪦",ltcir:"⩹",lt:"<",LT:"<",Lt:"≪",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltri:"◃",ltrie:"⊴",ltrif:"◂",ltrPar:"⦖",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",macr:"¯",male:"♂",malt:"✠",maltese:"✠",Map:"⤅",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",Mcy:"М",mcy:"м",mdash:"—",mDDot:"∺",measuredangle:"∡",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",mfr:"𝔪",mho:"℧",micro:"µ",midast:"*",midcir:"⫰",mid:"∣",middot:"·",minusb:"⊟",minus:"−",minusd:"∸",minusdu:"⨪",MinusPlus:"∓",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",Mopf:"𝕄",mopf:"𝕞",mp:"∓",mscr:"𝓂",Mscr:"ℳ",mstpos:"∾",Mu:"Μ",mu:"μ",multimap:"⊸",mumap:"⊸",nabla:"∇",Nacute:"Ń",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natural:"♮",naturals:"ℕ",natur:"♮",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",Ncaron:"Ň",ncaron:"ň",Ncedil:"Ņ",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",Ncy:"Н",ncy:"н",ndash:"–",nearhk:"⤤",nearr:"↗",neArr:"⇗",nearrow:"↗",ne:"≠",nedot:"≐̸",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",nequiv:"≢",nesear:"⤨",nesim:"≂̸",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",nexist:"∄",nexists:"∄",Nfr:"𝔑",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",nGg:"⋙̸",ngsim:"≵",nGt:"≫⃒",ngt:"≯",ngtr:"≯",nGtv:"≫̸",nharr:"↮",nhArr:"⇎",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",NJcy:"Њ",njcy:"њ",nlarr:"↚",nlArr:"⇍",nldr:"‥",nlE:"≦̸",nle:"≰",nleftarrow:"↚",nLeftarrow:"⇍",nleftrightarrow:"↮",nLeftrightarrow:"⇎",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nLl:"⋘̸",nlsim:"≴",nLt:"≪⃒",nlt:"≮",nltri:"⋪",nltrie:"⋬",nLtv:"≪̸",nmid:"∤",NoBreak:"⁠",NonBreakingSpace:" ",nopf:"𝕟",Nopf:"ℕ",Not:"⫬",not:"¬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",notin:"∉",notindot:"⋵̸",notinE:"⋹̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",NotLeftTriangleBar:"⧏̸",NotLeftTriangle:"⋪",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangleBar:"⧐̸",NotRightTriangle:"⋫",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",nparallel:"∦",npar:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",nprec:"⊀",npreceq:"⪯̸",npre:"⪯̸",nrarrc:"⤳̸",nrarr:"↛",nrArr:"⇏",nrarrw:"↝̸",nrightarrow:"↛",nRightarrow:"⇏",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",Nscr:"𝒩",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",Ntilde:"Ñ",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",Nu:"Ν",nu:"ν",num:"#",numero:"№",numsp:" ",nvap:"≍⃒",nvdash:"⊬",nvDash:"⊭",nVdash:"⊮",nVDash:"⊯",nvge:"≥⃒",nvgt:">⃒",nvHarr:"⤄",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwarhk:"⤣",nwarr:"↖",nwArr:"⇖",nwarrow:"↖",nwnear:"⤧",Oacute:"Ó",oacute:"ó",oast:"⊛",Ocirc:"Ô",ocirc:"ô",ocir:"⊚",Ocy:"О",ocy:"о",odash:"⊝",Odblac:"Ő",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",OElig:"Œ",oelig:"œ",ofcir:"⦿",Ofr:"𝔒",ofr:"𝔬",ogon:"˛",Ograve:"Ò",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",Omacr:"Ō",omacr:"ō",Omega:"Ω",omega:"ω",Omicron:"Ο",omicron:"ο",omid:"⦶",ominus:"⊖",Oopf:"𝕆",oopf:"𝕠",opar:"⦷",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",operp:"⦹",oplus:"⊕",orarr:"↻",Or:"⩔",or:"∨",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oS:"Ⓢ",Oscr:"𝒪",oscr:"ℴ",Oslash:"Ø",oslash:"ø",osol:"⊘",Otilde:"Õ",otilde:"õ",otimesas:"⨶",Otimes:"⨷",otimes:"⊗",Ouml:"Ö",ouml:"ö",ovbar:"⌽",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",para:"¶",parallel:"∥",par:"∥",parsim:"⫳",parsl:"⫽",part:"∂",PartialD:"∂",Pcy:"П",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",Pfr:"𝔓",pfr:"𝔭",Phi:"Φ",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",Pi:"Π",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plus:"+",plusdo:"∔",plusdu:"⨥",pluse:"⩲",PlusMinus:"±",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",Poincareplane:"ℌ",pointint:"⨕",popf:"𝕡",Popf:"ℙ",pound:"£",prap:"⪷",Pr:"⪻",pr:"≺",prcue:"≼",precapprox:"⪷",prec:"≺",preccurlyeq:"≼",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",pre:"⪯",prE:"⪳",precsim:"≾",prime:"′",Prime:"″",primes:"ℙ",prnap:"⪹",prnE:"⪵",prnsim:"⋨",prod:"∏",Product:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",Proportional:"∝",Proportion:"∷",propto:"∝",prsim:"≾",prurel:"⊰",Pscr:"𝒫",pscr:"𝓅",Psi:"Ψ",psi:"ψ",puncsp:" ",Qfr:"𝔔",qfr:"𝔮",qint:"⨌",qopf:"𝕢",Qopf:"ℚ",qprime:"⁗",Qscr:"𝒬",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',QUOT:'"',rAarr:"⇛",race:"∽̱",Racute:"Ŕ",racute:"ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",Rang:"⟫",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarr:"→",Rarr:"↠",rArr:"⇒",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",Rarrtl:"⤖",rarrtl:"↣",rarrw:"↝",ratail:"⤚",rAtail:"⤜",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rBarr:"⤏",RBarr:"⤐",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",Rcaron:"Ř",rcaron:"ř",Rcedil:"Ŗ",rcedil:"ŗ",rceil:"⌉",rcub:"}",Rcy:"Р",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",Re:"ℜ",rect:"▭",reg:"®",REG:"®",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",Rfr:"ℜ",rHar:"⥤",rhard:"⇁",rharu:"⇀",rharul:"⥬",Rho:"Ρ",rho:"ρ",rhov:"ϱ",RightAngleBracket:"⟩",RightArrowBar:"⇥",rightarrow:"→",RightArrow:"→",Rightarrow:"⇒",RightArrowLeftArrow:"⇄",rightarrowtail:"↣",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVectorBar:"⥕",RightDownVector:"⇂",RightFloor:"⌋",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",RightTeeArrow:"↦",RightTee:"⊢",RightTeeVector:"⥛",rightthreetimes:"⋌",RightTriangleBar:"⧐",RightTriangle:"⊳",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVectorBar:"⥔",RightUpVector:"↾",RightVectorBar:"⥓",RightVector:"⇀",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoustache:"⎱",rmoust:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",Ropf:"ℝ",roplus:"⨮",rotimes:"⨵",RoundImplies:"⥰",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",Rrightarrow:"⇛",rsaquo:"›",rscr:"𝓇",Rscr:"ℛ",rsh:"↱",Rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",RuleDelayed:"⧴",ruluhar:"⥨",rx:"℞",Sacute:"Ś",sacute:"ś",sbquo:"‚",scap:"⪸",Scaron:"Š",scaron:"š",Sc:"⪼",sc:"≻",sccue:"≽",sce:"⪰",scE:"⪴",Scedil:"Ş",scedil:"ş",Scirc:"Ŝ",scirc:"ŝ",scnap:"⪺",scnE:"⪶",scnsim:"⋩",scpolint:"⨓",scsim:"≿",Scy:"С",scy:"с",sdotb:"⊡",sdot:"⋅",sdote:"⩦",searhk:"⤥",searr:"↘",seArr:"⇘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",Sfr:"𝔖",sfr:"𝔰",sfrown:"⌢",sharp:"♯",SHCHcy:"Щ",shchcy:"щ",SHcy:"Ш",shcy:"ш",ShortDownArrow:"↓",ShortLeftArrow:"←",shortmid:"∣",shortparallel:"∥",ShortRightArrow:"→",ShortUpArrow:"↑",shy:"­",Sigma:"Σ",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",SmallCircle:"∘",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",SOFTcy:"Ь",softcy:"ь",solbar:"⌿",solb:"⧄",sol:"/",Sopf:"𝕊",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",Sqrt:"√",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",square:"□",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",squarf:"▪",squ:"□",squf:"▪",srarr:"→",Sscr:"𝒮",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",Star:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",Sub:"⋐",subdot:"⪽",subE:"⫅",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",subset:"⊂",Subset:"⋐",subseteq:"⊆",subseteqq:"⫅",SubsetEqual:"⊆",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succapprox:"⪸",succ:"≻",succcurlyeq:"≽",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",SuchThat:"∋",sum:"∑",Sum:"∑",sung:"♪",sup1:"¹",sup2:"²",sup3:"³",sup:"⊃",Sup:"⋑",supdot:"⪾",supdsub:"⫘",supE:"⫆",supe:"⊇",supedot:"⫄",Superset:"⊃",SupersetEqual:"⊇",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",supset:"⊃",Supset:"⋑",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swarhk:"⤦",swarr:"↙",swArr:"⇙",swarrow:"↙",swnwar:"⤪",szlig:"ß",Tab:"\t",target:"⌖",Tau:"Τ",tau:"τ",tbrk:"⎴",Tcaron:"Ť",tcaron:"ť",Tcedil:"Ţ",tcedil:"ţ",Tcy:"Т",tcy:"т",tdot:"⃛",telrec:"⌕",Tfr:"𝔗",tfr:"𝔱",there4:"∴",therefore:"∴",Therefore:"∴",Theta:"Θ",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",ThickSpace:"  ",ThinSpace:" ",thinsp:" ",thkap:"≈",thksim:"∼",THORN:"Þ",thorn:"þ",tilde:"˜",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",timesbar:"⨱",timesb:"⊠",times:"×",timesd:"⨰",tint:"∭",toea:"⤨",topbot:"⌶",topcir:"⫱",top:"⊤",Topf:"𝕋",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",TRADE:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",TripleDot:"⃛",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",Tscr:"𝒯",tscr:"𝓉",TScy:"Ц",tscy:"ц",TSHcy:"Ћ",tshcy:"ћ",Tstrok:"Ŧ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",Uacute:"Ú",uacute:"ú",uarr:"↑",Uarr:"↟",uArr:"⇑",Uarrocir:"⥉",Ubrcy:"Ў",ubrcy:"ў",Ubreve:"Ŭ",ubreve:"ŭ",Ucirc:"Û",ucirc:"û",Ucy:"У",ucy:"у",udarr:"⇅",Udblac:"Ű",udblac:"ű",udhar:"⥮",ufisht:"⥾",Ufr:"𝔘",ufr:"𝔲",Ugrave:"Ù",ugrave:"ù",uHar:"⥣",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",Umacr:"Ū",umacr:"ū",uml:"¨",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",uogon:"ų",Uopf:"𝕌",uopf:"𝕦",UpArrowBar:"⤒",uparrow:"↑",UpArrow:"↑",Uparrow:"⇑",UpArrowDownArrow:"⇅",updownarrow:"↕",UpDownArrow:"↕",Updownarrow:"⇕",UpEquilibrium:"⥮",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",UpperLeftArrow:"↖",UpperRightArrow:"↗",upsi:"υ",Upsi:"ϒ",upsih:"ϒ",Upsilon:"Υ",upsilon:"υ",UpTeeArrow:"↥",UpTee:"⊥",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",Uring:"Ů",uring:"ů",urtri:"◹",Uscr:"𝒰",uscr:"𝓊",utdot:"⋰",Utilde:"Ũ",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",Uuml:"Ü",uuml:"ü",uwangle:"⦧",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",vArr:"⇕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vBar:"⫨",Vbar:"⫫",vBarv:"⫩",Vcy:"В",vcy:"в",vdash:"⊢",vDash:"⊨",Vdash:"⊩",VDash:"⊫",Vdashl:"⫦",veebar:"⊻",vee:"∨",Vee:"⋁",veeeq:"≚",vellip:"⋮",verbar:"|",Verbar:"‖",vert:"|",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",Vopf:"𝕍",vopf:"𝕧",vprop:"∝",vrtri:"⊳",Vscr:"𝒱",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",Vvdash:"⊪",vzigzag:"⦚",Wcirc:"Ŵ",wcirc:"ŵ",wedbar:"⩟",wedge:"∧",Wedge:"⋀",wedgeq:"≙",weierp:"℘",Wfr:"𝔚",wfr:"𝔴",Wopf:"𝕎",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",Wscr:"𝒲",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",Xfr:"𝔛",xfr:"𝔵",xharr:"⟷",xhArr:"⟺",Xi:"Ξ",xi:"ξ",xlarr:"⟵",xlArr:"⟸",xmap:"⟼",xnis:"⋻",xodot:"⨀",Xopf:"𝕏",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrarr:"⟶",xrArr:"⟹",Xscr:"𝒳",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",Yacute:"Ý",yacute:"ý",YAcy:"Я",yacy:"я",Ycirc:"Ŷ",ycirc:"ŷ",Ycy:"Ы",ycy:"ы",yen:"¥",Yfr:"𝔜",yfr:"𝔶",YIcy:"Ї",yicy:"ї",Yopf:"𝕐",yopf:"𝕪",Yscr:"𝒴",yscr:"𝓎",YUcy:"Ю",yucy:"ю",yuml:"ÿ",Yuml:"Ÿ",Zacute:"Ź",zacute:"ź",Zcaron:"Ž",zcaron:"ž",Zcy:"З",zcy:"з",Zdot:"Ż",zdot:"ż",zeetrf:"ℨ",ZeroWidthSpace:"​",Zeta:"Ζ",zeta:"ζ",zfr:"𝔷",Zfr:"ℨ",ZHcy:"Ж",zhcy:"ж",zigrarr:"⇝",zopf:"𝕫",Zopf:"ℤ",Zscr:"𝒵",zscr:"𝓏",zwj:"‍",zwnj:"‌"},wu=/[!-#%-\*,-\/:;\?@\[-\]_\{\}\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4E\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD803[\uDF55-\uDF59]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDF3C-\uDF3E]|\uD806[\uDC3B\uDE3F-\uDE46\uDE9A-\uDE9C\uDE9E-\uDEA2]|\uD807[\uDC41-\uDC45\uDC70\uDC71\uDEF7\uDEF8]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD81B[\uDE97-\uDE9A]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/,Ir={},l2={};function Ts(t,e,n){var r,o,a,s,i,l="";for("string"!=typeof e&&(n=e,e=Ts.defaultChars),typeof n>"u"&&(n=!0),i=function(t){var e,n,r=l2[t];if(r)return r;for(r=l2[t]=[],e=0;e<128;e++)n=String.fromCharCode(e),/^[0-9a-z]$/i.test(n)?r.push(n):r.push("%"+("0"+e.toString(16).toUpperCase()).slice(-2));for(e=0;e=55296&&a<=57343){if(a>=55296&&a<=56319&&r+1=56320&&s<=57343)){l+=encodeURIComponent(t[r]+t[r+1]),r++;continue}l+="%EF%BF%BD"}else l+=encodeURIComponent(t[r]);return l}Ts.defaultChars=";/?:@&=+$,-_.!~*'()#",Ts.componentChars="-_.!~*'()";var W_=Ts,u2={};function ws(t,e){var n;return"string"!=typeof e&&(e=ws.defaultChars),n=function(t){var e,n,r=u2[t];if(r)return r;for(r=u2[t]=[],e=0;e<128;e++)n=String.fromCharCode(e),r.push(n);for(e=0;e=55296&&c<=57343?"���":String.fromCharCode(c),o+=6):240==(248&s)&&o+91114111?p+="����":(c-=65536,p+=String.fromCharCode(55296+(c>>10),56320+(1023&c))),o+=9):p+="�";return p}))}ws.defaultChars=";/?:@&=+$,#",ws.componentChars="";var Y_=ws;function xs(){this.protocol=null,this.slashes=null,this.auth=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.pathname=null}var Q_=/^([a-z0-9.+-]+:)/i,J_=/:[0-9]*$/,ev=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,nv=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),rv=["'"].concat(nv),c2=["%","/","?",";","#"].concat(rv),d2=["/","?","#"],p2=/^[+a-z0-9A-Z_-]{0,63}$/,av=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,f2={javascript:!0,"javascript:":!0},m2={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};xs.prototype.parse=function(t,e){var n,r,o,a,s,i=t;if(i=i.trim(),!e&&1===t.split("#").length){var l=ev.exec(i);if(l)return this.pathname=l[1],l[2]&&(this.search=l[2]),this}var u=Q_.exec(i);if(u&&(o=(u=u[0]).toLowerCase(),this.protocol=u,i=i.substr(u.length)),(e||u||i.match(/^\/\/[^@\/]+@[^@\/]+/))&&((s="//"===i.substr(0,2))&&!(u&&f2[u])&&(i=i.substr(2),this.slashes=!0)),!f2[u]&&(s||u&&!m2[u])){var p,d,c=-1;for(n=0;n127?h+="x":h+=y[g];if(!h.match(p2)){var _=A.slice(0,n),N=A.slice(n+1),v=y.match(av);v&&(_.push(v[1]),N.unshift(v[2])),N.length&&(i=N.join(".")+i),this.hostname=_.join(".");break}}}}this.hostname.length>255&&(this.hostname=""),b&&(this.hostname=this.hostname.substr(1,this.hostname.length-2))}var T=i.indexOf("#");-1!==T&&(this.hash=i.substr(T),i=i.slice(0,T));var R=i.indexOf("?");return-1!==R&&(this.search=i.substr(R),i=i.slice(0,R)),i&&(this.pathname=i),m2[o]&&this.hostname&&!this.pathname&&(this.pathname=""),this},xs.prototype.parseHost=function(t){var e=J_.exec(t);e&&(":"!==(e=e[0])&&(this.port=e.substr(1)),t=t.substr(0,t.length-e.length)),t&&(this.hostname=t)};var iv=function(t,e){if(t&&t instanceof xs)return t;var n=new xs;return n.parse(t,e),n};Ir.encode=W_,Ir.decode=Y_,Ir.format=function(e){var n="";return n+=e.protocol||"",n+=e.slashes?"//":"",n+=e.auth?e.auth+"@":"",e.hostname&&-1!==e.hostname.indexOf(":")?n+="["+e.hostname+"]":n+=e.hostname||"",n+=e.port?":"+e.port:"",n+=e.pathname||"",n+=e.search||"",n+=e.hash||""},Ir.parse=iv;var xu,g2,Ru,E2,Lu,A2,Ou,_2,y2,Xn={};function h2(){return g2||(g2=1,xu=/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/),xu}function b2(){return E2||(E2=1,Ru=/[\0-\x1F\x7F-\x9F]/),Ru}function v2(){return _2||(_2=1,Ou=/[ \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/),Ou}function uv(){return y2||(y2=1,Xn.Any=h2(),Xn.Cc=b2(),Xn.Cf=(A2||(A2=1,Lu=/[\xAD\u0600-\u0605\u061C\u06DD\u070F\u08E2\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF\uFFF9-\uFFFB]|\uD804[\uDCBD\uDCCD]|\uD82F[\uDCA0-\uDCA3]|\uD834[\uDD73-\uDD7A]|\uDB40[\uDC01\uDC20-\uDC7F]/),Lu),Xn.P=wu,Xn.Z=v2()),Xn}!function(t){var r=Object.prototype.hasOwnProperty;function o(O,V){return r.call(O,V)}function i(O){return!(O>=55296&&O<=57343||O>=64976&&O<=65007||65535==(65535&O)||65534==(65535&O)||O>=0&&O<=8||11===O||O>=14&&O<=31||O>=127&&O<=159||O>1114111)}function l(O){if(O>65535){var V=55296+((O-=65536)>>10),B=56320+(1023&O);return String.fromCharCode(V,B)}return String.fromCharCode(O)}var u=/\\([!"#$%&'()*+,\-.\/:;<=>?@[\\\]^_`{|}~])/g,p=new RegExp(u.source+"|"+/&([a-z#][a-z0-9]{1,31});/gi.source,"gi"),d=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))$/i,f=i2;var h=/[&<>"]/,g=/[&<>"]/g,E={"&":"&","<":"<",">":">",'"':"""};function _(O){return E[O]}var v=/[.?*+^$[\]\\(){}|-]/g;var I=wu;t.lib={},t.lib.mdurl=Ir,t.lib.ucmicro=uv(),t.assign=function(O){return Array.prototype.slice.call(arguments,1).forEach((function(B){if(B){if("object"!=typeof B)throw new TypeError(B+"must be object");Object.keys(B).forEach((function(x){O[x]=B[x]}))}})),O},t.isString=function(O){return"[object String]"===function(O){return Object.prototype.toString.call(O)}(O)},t.has=o,t.unescapeMd=function(O){return O.indexOf("\\")<0?O:O.replace(u,"$1")},t.unescapeAll=function(O){return O.indexOf("\\")<0&&O.indexOf("&")<0?O:O.replace(p,(function(V,B,x){return B||function(O,V){var B;return o(f,V)?f[V]:35===V.charCodeAt(0)&&d.test(V)&&i(B="x"===V[1].toLowerCase()?parseInt(V.slice(2),16):parseInt(V.slice(1),10))?l(B):O}(V,x)}))},t.isValidEntityCode=i,t.fromCodePoint=l,t.escapeHtml=function(O){return h.test(O)?O.replace(g,_):O},t.arrayReplaceAt=function(O,V,B){return[].concat(O.slice(0,V),B,O.slice(V+1))},t.isSpace=function(O){switch(O){case 9:case 32:return!0}return!1},t.isWhiteSpace=function(O){if(O>=8192&&O<=8202)return!0;switch(O){case 9:case 10:case 11:case 12:case 13:case 32:case 160:case 5760:case 8239:case 8287:case 12288:return!0}return!1},t.isMdAsciiPunct=function(O){switch(O){case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 41:case 42:case 43:case 44:case 45:case 46:case 47:case 58:case 59:case 60:case 61:case 62:case 63:case 64:case 91:case 92:case 93:case 94:case 95:case 96:case 123:case 124:case 125:case 126:return!0;default:return!1}},t.isPunctChar=function(O){return I.test(O)},t.escapeRE=function(O){return O.replace(v,"\\$&")},t.normalizeReference=function(O){return O=O.trim().replace(/\s+/g," "),"Ṿ"==="ẞ".toLowerCase()&&(O=O.replace(/ẞ/g,"ß")),O.toLowerCase().toUpperCase()}}(ie);var Rs={},D2=ie.unescapeAll,pv=ie.unescapeAll;Rs.parseLinkLabel=function(e,n,r){var o,a,s,i,l=-1,u=e.posMax,c=e.pos;for(e.pos=n+1,o=1;e.pos32)return i;if(41===o){if(0===a)break;a--}s++}return n===s||0!==a||(i.str=D2(e.slice(n,s)),i.pos=s,i.ok=!0),i},Rs.parseLinkTitle=function(e,n,r){var o,a,s=0,i=n,l={ok:!1,pos:0,lines:0,str:""};if(i>=r||34!==(a=e.charCodeAt(i))&&39!==a&&40!==a)return l;for(i++,40===a&&(a=41);i"+Qn(a.content)+""},jt.code_block=function(t,e,n,r,o){var a=t[e];return""+Qn(t[e].content)+"\n"},jt.fence=function(t,e,n,r,o){var u,c,p,d,f,a=t[e],s=a.info?gv(a.info).trim():"",i="",l="";return s&&(i=(p=s.split(/(\s+)/g))[0],l=p.slice(2).join("")),0===(u=n.highlight&&n.highlight(a.content,i,l)||Qn(a.content)).indexOf(""+u+"\n"):"
"+u+"
\n"},jt.image=function(t,e,n,r,o){var a=t[e];return a.attrs[a.attrIndex("alt")][1]=o.renderInlineAsText(a.children,n,r),o.renderToken(t,e,n)},jt.hardbreak=function(t,e,n){return n.xhtmlOut?"
\n":"
\n"},jt.softbreak=function(t,e,n){return n.breaks?n.xhtmlOut?"
\n":"
\n":"\n"},jt.text=function(t,e){return Qn(t[e].content)},jt.html_block=function(t,e){return t[e].content},jt.html_inline=function(t,e){return t[e].content},Mr.prototype.renderAttrs=function(e){var n,r,o;if(!e.attrs)return"";for(o="",n=0,r=e.attrs.length;n\n":">")},Mr.prototype.renderInline=function(t,e,n){for(var r,o="",a=this.rules,s=0,i=t.length;s\s]/i.test(t)}function Cv(t){return/^<\/a\s*>/i.test(t)}var C2=/\+-|\.\.|\?\?\?\?|!!!!|,,|--/,Nv=/\((c|tm|r)\)/i,Tv=/\((c|tm|r)\)/gi,wv={c:"©",r:"®",tm:"™"};function xv(t,e){return wv[e.toLowerCase()]}function Rv(t){var e,n,r=0;for(e=t.length-1;e>=0;e--)"text"===(n=t[e]).type&&!r&&(n.content=n.content.replace(Tv,xv)),"link_open"===n.type&&"auto"===n.info&&r--,"link_close"===n.type&&"auto"===n.info&&r++}function Lv(t){var e,n,r=0;for(e=t.length-1;e>=0;e--)"text"===(n=t[e]).type&&!r&&C2.test(n.content)&&(n.content=n.content.replace(/\+-/g,"±").replace(/\.{2,}/g,"…").replace(/([?!])…/g,"$1..").replace(/([?!]){4,}/g,"$1$1$1").replace(/,{2,}/g,",").replace(/(^|[^-])---(?=[^-]|$)/gm,"$1—").replace(/(^|\s)--(?=\s|$)/gm,"$1–").replace(/(^|[^-\s])--(?=[^-\s]|$)/gm,"$1–")),"link_open"===n.type&&"auto"===n.info&&r--,"link_close"===n.type&&"auto"===n.info&&r++}var S2=ie.isWhiteSpace,N2=ie.isPunctChar,T2=ie.isMdAsciiPunct,kv=/['"]/,w2=/['"]/g;function Ls(t,e,n){return t.slice(0,e)+n+t.slice(e+1)}function Iv(t,e){var n,r,o,a,s,i,l,u,c,p,d,f,b,A,y,h,g,E,_,N,v;for(_=[],n=0;n=0&&!(_[g].level<=l);g--);if(_.length=g+1,"text"===r.type){s=0,i=(o=r.content).length;e:for(;s=0)c=o.charCodeAt(a.index-1);else for(g=n-1;g>=0&&"softbreak"!==t[g].type&&"hardbreak"!==t[g].type;g--)if(t[g].content){c=t[g].content.charCodeAt(t[g].content.length-1);break}if(p=32,s=48&&c<=57&&(h=y=!1),y&&h&&(y=d,h=f),y||h){if(h)for(g=_.length-1;g>=0&&(u=_[g],!(_[g].level=0&&(r=this.attrs[n][1]),r},Fr.prototype.attrJoin=function(e,n){var r=this.attrIndex(e);r<0?this.attrPush([e,n]):this.attrs[r][1]=this.attrs[r][1]+" "+n};var Iu=Fr,Bv=Iu;function R2(t,e,n){this.src=t,this.env=n,this.tokens=[],this.inlineMode=!1,this.md=e}R2.prototype.Token=Bv;var Pv=R2,Uv=ku,Mu=[["normalize",function(e){var n;n=(n=e.src.replace(Ev,"\n")).replace(bv,"�"),e.src=n}],["block",function(e){var n;e.inlineMode?((n=new e.Token("inline","",0)).content=e.src,n.map=[0,1],n.children=[],e.tokens.push(n)):e.md.block.parse(e.src,e.md,e.env,e.tokens)}],["inline",function(e){var r,o,a,n=e.tokens;for(o=0,a=n.length;o=0;n--)if("link_close"!==(i=a[n]).type){if("html_inline"===i.type&&(Dv(i.content)&&b>0&&b--,Cv(i.content)&&b++),!(b>0)&&"text"===i.type&&e.md.linkify.test(i.content)){for(c=i.content,E=e.md.linkify.match(c),l=[],f=i.level,d=0,E.length>0&&0===E[0].index&&n>0&&"text_special"===a[n-1].type&&(E=E.slice(1)),u=0;ud&&((s=new e.Token("text","",0)).content=c.slice(d,p),s.level=f,l.push(s)),(s=new e.Token("link_open","a",1)).attrs=[["href",y]],s.level=f++,s.markup="linkify",s.info="auto",l.push(s),(s=new e.Token("text","",0)).content=h,s.level=f,l.push(s),(s=new e.Token("link_close","a",-1)).level=--f,s.markup="linkify",s.info="auto",l.push(s),d=E[u].lastIndex);d=0;n--)"inline"===e.tokens[n].type&&(Nv.test(e.tokens[n].content)&&Rv(e.tokens[n].children),C2.test(e.tokens[n].content)&&Lv(e.tokens[n].children))}],["smartquotes",function(e){var n;if(e.md.options.typographer)for(n=e.tokens.length-1;n>=0;n--)"inline"!==e.tokens[n].type||!kv.test(e.tokens[n].content)||Iv(e.tokens[n].children,e)}],["text_join",function(e){var n,r,o,a,s,i,l=e.tokens;for(n=0,r=l.length;n=a||((n=t.src.charCodeAt(o++))<48||n>57))return-1;for(;;){if(o>=a)return-1;if(!((n=t.src.charCodeAt(o++))>=48&&n<=57)){if(41===n||46===n)break;return-1}if(o-r>=10)return-1}return o`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>",F2="<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>",uy=new RegExp("^(?:"+M2+"|"+F2+"|\x3c!----\x3e|\x3c!--(?:-?[^>-])(?:-?[^-])*--\x3e|<[?][\\s\\S]*?[?]>|]*>|)"),cy=new RegExp("^(?:"+M2+"|"+F2+")");ks.HTML_TAG_RE=uy,ks.HTML_OPEN_CLOSE_TAG_RE=cy;var dy=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","section","source","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],py=ks.HTML_OPEN_CLOSE_TAG_RE,Br=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^/,!0],[/^<\?/,/\?>/,!0],[/^/,!0],[/^/,!0],[new RegExp("^|$))","i"),/^$/,!0],[new RegExp(py.source+"\\s*$"),/^$/,!1]],B2=ie.isSpace,P2=Iu,Is=ie.isSpace;function Wt(t,e,n,r){var o,a,s,i,l,u,c,p;for(this.src=t,this.md=e,this.env=n,this.tokens=r,this.bMarks=[],this.eMarks=[],this.tShift=[],this.sCount=[],this.bsCount=[],this.blkIndent=0,this.line=0,this.lineMax=0,this.tight=!1,this.ddIndent=-1,this.listIndent=-1,this.parentType="root",this.level=0,this.result="",p=!1,s=i=u=c=0,l=(a=this.src).length;i0&&this.level++,this.tokens.push(r),r},Wt.prototype.isEmpty=function(e){return this.bMarks[e]+this.tShift[e]>=this.eMarks[e]},Wt.prototype.skipEmptyLines=function(e){for(var n=this.lineMax;en;)if(!Is(this.src.charCodeAt(--e)))return e+1;return e},Wt.prototype.skipChars=function(e,n){for(var r=this.src.length;er;)if(n!==this.src.charCodeAt(--e))return e+1;return e},Wt.prototype.getLines=function(e,n,r,o){var a,s,i,l,u,c,p,d=e;if(e>=n)return"";for(c=new Array(n-e),a=0;dr?new Array(s-r+1).join(" ")+this.src.slice(l,u):this.src.slice(l,u)}return c.join("")},Wt.prototype.Token=P2;var Ey=Wt,by=ku,Ms=[["table",function(e,n,r,o){var a,s,i,l,u,c,p,d,f,b,A,y,h,g,E,_,N,v;if(n+2>r||(c=n+1,e.sCount[c]=4||(i=e.bMarks[c]+e.tShift[c])>=e.eMarks[c]||124!==(N=e.src.charCodeAt(i++))&&45!==N&&58!==N||i>=e.eMarks[c]||124!==(v=e.src.charCodeAt(i++))&&45!==v&&58!==v&&!Bu(v)||45===N&&Bu(v))return!1;for(;i=4||((p=L2(s)).length&&""===p[0]&&p.shift(),p.length&&""===p[p.length-1]&&p.pop(),0===(d=p.length)||d!==b.length))return!1;if(o)return!0;for(g=e.parentType,e.parentType="table",_=e.md.block.ruler.getRules("blockquote"),(f=e.push("table_open","table",1)).map=y=[n,0],(f=e.push("thead_open","thead",1)).map=[n,n+1],(f=e.push("tr_open","tr",1)).map=[n,n+1],l=0;l=4)break;for((p=L2(s)).length&&""===p[0]&&p.shift(),p.length&&""===p[p.length-1]&&p.pop(),c===n+2&&((f=e.push("tbody_open","tbody",1)).map=h=[n+2,0]),(f=e.push("tr_open","tr",1)).map=[c,c+1],l=0;l=4))break;a=++o}return e.line=a,(s=e.push("code_block","code",0)).content=e.getLines(n,a,4+e.blkIndent,!1)+"\n",s.map=[n,e.line],!0}],["fence",function(e,n,r,o){var a,s,i,l,u,c,p,d=!1,f=e.bMarks[n]+e.tShift[n],b=e.eMarks[n];if(e.sCount[n]-e.blkIndent>=4||f+3>b||126!==(a=e.src.charCodeAt(f))&&96!==a||(u=f,(s=(f=e.skipChars(f,a))-u)<3)||(p=e.src.slice(u,f),i=e.src.slice(f,b),96===a&&i.indexOf(String.fromCharCode(a))>=0))return!1;if(o)return!0;for(l=n;!(++l>=r||(f=u=e.bMarks[l]+e.tShift[l],b=e.eMarks[l],f=4||(f=e.skipChars(f,a),f-u=4||62!==e.src.charCodeAt(I))return!1;if(o)return!0;for(b=[],A=[],g=[],E=[],v=e.md.block.ruler.getRules("blockquote"),h=e.parentType,e.parentType="blockquote",d=n;d=(W=e.eMarks[d])));d++)if(62!==e.src.charCodeAt(I++)||R){if(c)break;for(N=!1,i=0,u=v.length;i=W,A.push(e.bsCount[d]),e.bsCount[d]=e.sCount[d]+1+(_?1:0),g.push(e.sCount[d]),e.sCount[d]=f-l,E.push(e.tShift[d]),e.tShift[d]=I-e.bMarks[d]}for(y=e.blkIndent,e.blkIndent=0,(T=e.push("blockquote_open","blockquote",1)).markup=">",T.map=p=[n,0],e.md.block.tokenize(e,n,d),(T=e.push("blockquote_close","blockquote",-1)).markup=">",e.lineMax=H,e.parentType=h,p[1]=e.line,i=0;i=4||42!==(a=e.src.charCodeAt(u++))&&45!==a&&95!==a)return!1;for(s=1;u=4||e.listIndent>=0&&e.sCount[B]-e.listIndent>=4&&e.sCount[B]=e.blkIndent&&(x=!0),(I=I2(e,B))>=0){if(p=!0,le=e.bMarks[B]+e.tShift[B],h=Number(e.src.slice(le,I-1)),x&&1!==h)return!1}else{if(!((I=k2(e,B))>=0))return!1;p=!1}if(x&&e.skipSpaces(I)>=e.eMarks[B])return!1;if(o)return!0;for(y=e.src.charCodeAt(I-1),A=e.tokens.length,p?(V=e.push("ordered_list_open","ol",1),1!==h&&(V.attrs=[["start",h]])):V=e.push("bullet_list_open","ul",1),V.map=b=[B,0],V.markup=String.fromCharCode(y),W=!1,O=e.md.block.ruler.getRules("list"),N=e.parentType,e.parentType="list";B=g?1:E-c)>4&&(u=1),l=c+u,(V=e.push("list_item_open","li",1)).markup=String.fromCharCode(y),V.map=d=[B,0],p&&(V.info=e.src.slice(le,I-1)),R=e.tight,T=e.tShift[B],v=e.sCount[B],_=e.listIndent,e.listIndent=e.blkIndent,e.blkIndent=l,e.tight=!0,e.tShift[B]=s-e.bMarks[B],e.sCount[B]=E,s>=g&&e.isEmpty(B+1)?e.line=Math.min(e.line+2,r):e.md.block.tokenize(e,B,r,!0),(!e.tight||W)&&(S=!1),W=e.line-B>1&&e.isEmpty(e.line-1),e.blkIndent=e.listIndent,e.listIndent=_,e.tShift[B]=T,e.sCount[B]=v,e.tight=R,(V=e.push("list_item_close","li",-1)).markup=String.fromCharCode(y),B=e.line,d[1]=B,B>=r||e.sCount[B]=4)break;for(K=!1,i=0,f=O.length;i=4||91!==e.src.charCodeAt(v))return!1;for(;++v3||e.sCount[R]<0)){for(g=!1,c=0,p=E.length;c"u"&&(e.env.references={}),typeof e.env.references[d]>"u"&&(e.env.references[d]={title:_,href:u}),e.parentType=b,e.line=n+N+1),!0)}],["html_block",function(e,n,r,o){var a,s,i,l,u=e.bMarks[n]+e.tShift[n],c=e.eMarks[n];if(e.sCount[n]-e.blkIndent>=4||!e.md.options.html||60!==e.src.charCodeAt(u))return!1;for(l=e.src.slice(u,c),a=0;a=4||(35!==(a=e.src.charCodeAt(u))||u>=c))return!1;for(s=1,a=e.src.charCodeAt(++u);35===a&&u6||uu&&B2(e.src.charCodeAt(i-1))&&(c=i),e.line=n+1,(l=e.push("heading_open","h"+String(s),1)).markup="########".slice(0,s),l.map=[n,e.line],(l=e.push("inline","",0)).content=e.src.slice(u,c).trim(),l.map=[n,e.line],l.children=[],(l=e.push("heading_close","h"+String(s),-1)).markup="########".slice(0,s)),!0)},["paragraph","reference","blockquote"]],["lheading",function(e,n,r){var o,a,s,i,l,u,c,p,d,b,f=n+1,A=e.md.block.ruler.getRules("paragraph");if(e.sCount[n]-e.blkIndent>=4)return!1;for(b=e.parentType,e.parentType="paragraph";f3)){if(e.sCount[f]>=e.blkIndent&&((u=e.bMarks[f]+e.tShift[f])<(c=e.eMarks[f])&&((45===(d=e.src.charCodeAt(u))||61===d)&&(u=e.skipChars(u,d),(u=e.skipSpaces(u))>=c)))){p=61===d?1:2;break}if(!(e.sCount[f]<0)){for(a=!1,s=0,i=A.length;s3||e.sCount[c]<0)){for(a=!1,s=0,i=p.length;s=n||t.sCount[l]=c){t.line=n;break}for(a=t.line,o=0;o=t.line)throw new Error("block rule didn't increment state.line");break}if(!r)throw new Error("none of the block rules matched");t.tight=!u,t.isEmpty(t.line-1)&&(u=!0),(l=t.line)?@[]^_`{|}~-".split("").forEach((function(t){Uu[t.charCodeAt(0)]=1}));var Bs={};function q2(t,e){var n,r,o,a,s,i=[],l=e.length;for(n=0;n=0;n--)(95===(r=e[n]).marker||42===r.marker)&&-1!==r.end&&(o=e[r.end],i=n>0&&e[n-1].end===r.end+1&&e[n-1].marker===r.marker&&e[n-1].token===r.token-1&&e[r.end+1].token===o.token+1,s=String.fromCharCode(r.marker),(a=t.tokens[r.token]).type=i?"strong_open":"em_open",a.tag=i?"strong":"em",a.nesting=1,a.markup=i?s+s:s,a.content="",(a=t.tokens[o.token]).type=i?"strong_close":"em_close",a.tag=i?"strong":"em",a.nesting=-1,a.markup=i?s+s:s,a.content="",i&&(t.tokens[e[n-1].token].content="",t.tokens[e[r.end+1].token].content="",n--))}Ps.tokenize=function(e,n){var r,o,s=e.pos,i=e.src.charCodeAt(s);if(n||95!==i&&42!==i)return!1;for(o=e.scanDelims(e.pos,42===i),r=0;r\x00-\x20]*)$/,Fy=ks.HTML_TAG_RE;var V2=i2,Hy=ie.has,Vy=ie.isValidEntityCode,$2=ie.fromCodePoint,$y=/^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i,zy=/^&([a-z][a-z0-9]{1,31});/i;function z2(t){var e,n,r,o,a,s,i,l,u={},c=t.length;if(c){var p=0,d=-2,f=[];for(e=0;ea;n-=f[n]+1)if((o=t[n]).marker===r.marker&&o.open&&o.end<0&&(i=!1,(o.close||r.open)&&(o.length+r.length)%3==0&&(o.length%3!=0||r.length%3!=0)&&(i=!0),!i)){l=n>0&&!t[n-1].open?f[n-1]+1:0,f[e]=e-n+l,f[n]=l,r.open=!1,o.end=e,o.close=!1,s=-1,d=-2;break}-1!==s&&(u[r.marker][(r.open?3:0)+(r.length||0)%3]=s)}}}var Vu=Iu,G2=ie.isWhiteSpace,Z2=ie.isPunctChar,j2=ie.isMdAsciiPunct;function Vo(t,e,n,r){this.src=t,this.env=n,this.md=e,this.tokens=r,this.tokens_meta=Array(r.length),this.pos=0,this.posMax=this.src.length,this.level=0,this.pending="",this.pendingLevel=0,this.cache={},this.delimiters=[],this._prev_delimiters=[],this.backticks={},this.backticksScanned=!1,this.linkLevel=0}Vo.prototype.pushPending=function(){var t=new Vu("text","",0);return t.content=this.pending,t.level=this.pendingLevel,this.tokens.push(t),this.pending="",t},Vo.prototype.push=function(t,e,n){this.pending&&this.pushPending();var r=new Vu(t,e,n),o=null;return n<0&&(this.level--,this.delimiters=this._prev_delimiters.pop()),r.level=this.level,n>0&&(this.level++,this._prev_delimiters.push(this.delimiters),this.delimiters=[],o={delimiters:this.delimiters}),this.pendingLevel=this.level,this.tokens.push(r),this.tokens_meta.push(o),r},Vo.prototype.scanDelims=function(t,e){var r,o,a,s,i,l,u,c,p,n=t,d=!0,f=!0,b=this.posMax,A=this.src.charCodeAt(t);for(r=t>0?this.src.charCodeAt(t-1):32;n0||(r=e.pos,o=e.posMax,r+3>o)||58!==e.src.charCodeAt(r)||47!==e.src.charCodeAt(r+1)||47!==e.src.charCodeAt(r+2)||(a=e.pending.match(yy),!a)||(s=a[1],i=e.md.linkify.matchAtStart(e.src.slice(r-s.length)),!i)||(l=i.url,l.length<=s.length)||(l=l.replace(/\*+$/,""),u=e.md.normalizeLink(l),!e.md.validateLink(u))||(n||(e.pending=e.pending.slice(0,-s.length),(c=e.push("link_open","a",1)).attrs=[["href",u]],c.markup="linkify",c.info="auto",(c=e.push("text","",0)).content=e.md.normalizeLinkText(l),(c=e.push("link_close","a",-1)).markup="linkify",c.info="auto"),e.pos+=l.length-s.length,0))}],["newline",function(e,n){var r,o,a,s=e.pos;if(10!==e.src.charCodeAt(s))return!1;if(r=e.pending.length-1,o=e.posMax,!n)if(r>=0&&32===e.pending.charCodeAt(r))if(r>=1&&32===e.pending.charCodeAt(r-1)){for(a=r-1;a>=1&&32===e.pending.charCodeAt(a-1);)a--;e.pending=e.pending.slice(0,a),e.push("hardbreak","br",0)}else e.pending=e.pending.slice(0,-1),e.push("softbreak","br",0);else e.push("softbreak","br",0);for(s++;s=u)return!1;if(10===(r=e.src.charCodeAt(l))){for(n||e.push("hardbreak","br",0),l++;l=55296&&r<=56319&&l+1=56320&&o<=57343&&(s+=e.src[l+1],l++)),a="\\"+s,n||(i=e.push("text_special","",0),r<256&&0!==Uu[r]?i.content=s:i.content=a,i.markup=a,i.info="escape"),e.pos=l+1,!0}],["backticks",function(e,n){var r,o,a,s,i,l,u,c,p=e.pos;if(96!==e.src.charCodeAt(p))return!1;for(r=p,p++,o=e.posMax;p=A)return!1;if(y=l,(u=e.md.helpers.parseLinkDestination(e.src,l,e.posMax)).ok){for(d=e.md.normalizeLink(u.str),e.md.validateLink(d)?l=u.pos:d="",y=l;l=A||41!==e.src.charCodeAt(l))&&(h=!0),l++}if(h){if(typeof e.env.references>"u")return!1;if(l=0?a=e.src.slice(y,l++):l=s+1):l=s+1,a||(a=e.src.slice(i,s)),!(c=e.env.references[xy(a)]))return e.pos=b,!1;d=c.href,f=c.title}return n||(e.pos=i,e.posMax=s,e.push("link_open","a",1).attrs=r=[["href",d]],f&&r.push(["title",f]),e.linkLevel++,e.md.inline.tokenize(e),e.linkLevel--,e.push("link_close","a",-1)),e.pos=l,e.posMax=A,!0}],["image",function(e,n){var r,o,a,s,i,l,u,c,p,d,f,b,A,y="",h=e.pos,g=e.posMax;if(33!==e.src.charCodeAt(e.pos)||91!==e.src.charCodeAt(e.pos+1)||(l=e.pos+2,(i=e.md.helpers.parseLinkLabel(e,e.pos+1,!1))<0))return!1;if((u=i+1)=g)return!1;for(A=u,(p=e.md.helpers.parseLinkDestination(e.src,u,e.posMax)).ok&&(y=e.md.normalizeLink(p.str),e.md.validateLink(y)?u=p.pos:y=""),A=u;u=g||41!==e.src.charCodeAt(u))return e.pos=h,!1;u++}else{if(typeof e.env.references>"u")return!1;if(u=0?s=e.src.slice(A,u++):u=i+1):u=i+1,s||(s=e.src.slice(l,i)),!(c=e.env.references[Ly(s)]))return e.pos=h,!1;y=c.href,d=c.title}return n||(a=e.src.slice(l,i),e.md.inline.parse(a,e.md,e.env,b=[]),(f=e.push("image","img",0)).attrs=r=[["src",y],["alt",""]],f.children=b,f.content=a,d&&r.push(["title",d])),e.pos=u,e.posMax=g,!0}],["autolink",function(e,n){var r,o,a,s,i,l,u=e.pos;if(60!==e.src.charCodeAt(u))return!1;for(i=e.pos,l=e.posMax;;){if(++u>=l||60===(s=e.src.charCodeAt(u)))return!1;if(62===s)break}return r=e.src.slice(i+1,u),Iy.test(r)?(o=e.md.normalizeLink(r),!!e.md.validateLink(o)&&(n||((a=e.push("link_open","a",1)).attrs=[["href",o]],a.markup="autolink",a.info="auto",(a=e.push("text","",0)).content=e.md.normalizeLinkText(r),(a=e.push("link_close","a",-1)).markup="autolink",a.info="auto"),e.pos+=r.length+2,!0)):!!ky.test(r)&&(o=e.md.normalizeLink("mailto:"+r),!!e.md.validateLink(o)&&(n||((a=e.push("link_open","a",1)).attrs=[["href",o]],a.markup="autolink",a.info="auto",(a=e.push("text","",0)).content=e.md.normalizeLinkText(r),(a=e.push("link_close","a",-1)).markup="autolink",a.info="auto"),e.pos+=r.length+2,!0))}],["html_inline",function(e,n){var r,o,a,s,i=e.pos;return!(!e.md.options.html||(a=e.posMax,60!==e.src.charCodeAt(i)||i+2>=a)||(r=e.src.charCodeAt(i+1),33!==r&&63!==r&&47!==r&&!function(t){var e=32|t;return e>=97&&e<=122}(r))||(o=e.src.slice(i).match(Fy),!o))&&(n||((s=e.push("html_inline","",0)).content=o[0],function(t){return/^\s]/i.test(t)}(s.content)&&e.linkLevel++,function(t){return/^<\/a\s*>/i.test(t)}(s.content)&&e.linkLevel--),e.pos+=o[0].length,!0)}],["entity",function(e,n){var o,a,s,i=e.pos,l=e.posMax;if(38!==e.src.charCodeAt(i)||i+1>=l)return!1;if(35===e.src.charCodeAt(i+1)){if(a=e.src.slice(i).match($y))return n||(o="x"===a[1][0].toLowerCase()?parseInt(a[1].slice(1),16):parseInt(a[1],10),(s=e.push("text_special","",0)).content=Vy(o)?$2(o):$2(65533),s.markup=a[0],s.info="entity"),e.pos+=a[0].length,!0}else if((a=e.src.slice(i).match(zy))&&Hy(V2,a[1]))return n||((s=e.push("text_special","",0)).content=V2[a[1]],s.markup=a[0],s.info="entity"),e.pos+=a[0].length,!0;return!1}]],zu=[["balance_pairs",function(e){var n,r=e.tokens_meta,o=e.tokens_meta.length;for(z2(e.delimiters),n=0;n0&&o++,"text"===a[n].type&&n+1=t.pos)throw new Error("inline rule didn't increment state.pos");break}}else t.pos=t.posMax;e||t.pos++,i[r]=t.pos}},$o.prototype.tokenize=function(t){for(var e,n,r,o=this.ruler.getRules(""),a=o.length,s=t.posMax,i=t.md.options.maxNesting;t.pos=t.pos)throw new Error("inline rule didn't increment state.pos");break}if(e){if(t.pos>=s)break}else t.pending+=t.src[t.pos++]}t.pending&&t.pushPending()},$o.prototype.parse=function(t,e,n,r){var o,a,s,i=new this.State(t,e,n,r);for(this.tokenize(i),s=(a=this.ruler2.getRules("")).length,o=0;o=3&&":"===t[e-3]||e>=3&&"/"===t[e-3]?0:r.match(n.re.no_http)[0].length:0}},"mailto:":{validate:function(t,e,n){var r=t.slice(e);return n.re.mailto||(n.re.mailto=new RegExp("^"+n.re.src_email_name+"@"+n.re.src_host_strict,"i")),n.re.mailto.test(r)?r.match(n.re.mailto)[0].length:0}}},rD="a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]",oD="biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф".split("|");function qs(t){var e=t.re=(K2||(K2=1,Gu=function(t){var e={};t=t||{},e.src_Any=h2().source,e.src_Cc=b2().source,e.src_Z=v2().source,e.src_P=wu.source,e.src_ZPCc=[e.src_Z,e.src_P,e.src_Cc].join("|"),e.src_ZCc=[e.src_Z,e.src_Cc].join("|");var n="[><|]";return e.src_pseudo_letter="(?:(?![><|]|"+e.src_ZPCc+")"+e.src_Any+")",e.src_ip4="(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)",e.src_auth="(?:(?:(?!"+e.src_ZCc+"|[@/\\[\\]()]).)+@)?",e.src_port="(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?",e.src_host_terminator="(?=$|[><|]|"+e.src_ZPCc+")(?!"+(t["---"]?"-(?!--)|":"-|")+"_|:\\d|\\.-|\\.(?!$|"+e.src_ZPCc+"))",e.src_path="(?:[/?#](?:(?!"+e.src_ZCc+"|"+n+"|[()[\\]{}.,\"'?!\\-;]).|\\[(?:(?!"+e.src_ZCc+"|\\]).)*\\]|\\((?:(?!"+e.src_ZCc+"|[)]).)*\\)|\\{(?:(?!"+e.src_ZCc+'|[}]).)*\\}|\\"(?:(?!'+e.src_ZCc+'|["]).)+\\"|\\\'(?:(?!'+e.src_ZCc+"|[']).)+\\'|\\'(?="+e.src_pseudo_letter+"|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!"+e.src_ZCc+"|[.]|$)|"+(t["---"]?"\\-(?!--(?:[^-]|$))(?:-*)|":"\\-+|")+",(?!"+e.src_ZCc+"|$)|;(?!"+e.src_ZCc+"|$)|\\!+(?!"+e.src_ZCc+"|[!]|$)|\\?(?!"+e.src_ZCc+"|[?]|$))+|\\/)?",e.src_email_name='[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*',e.src_xn="xn--[a-z0-9\\-]{1,59}",e.src_domain_root="(?:"+e.src_xn+"|"+e.src_pseudo_letter+"{1,63})",e.src_domain="(?:"+e.src_xn+"|(?:"+e.src_pseudo_letter+")|(?:"+e.src_pseudo_letter+"(?:-|"+e.src_pseudo_letter+"){0,61}"+e.src_pseudo_letter+"))",e.src_host="(?:(?:(?:(?:"+e.src_domain+")\\.)*"+e.src_domain+"))",e.tpl_host_fuzzy="(?:"+e.src_ip4+"|(?:(?:(?:"+e.src_domain+")\\.)+(?:%TLDS%)))",e.tpl_host_no_ip_fuzzy="(?:(?:(?:"+e.src_domain+")\\.)+(?:%TLDS%))",e.src_host_strict=e.src_host+e.src_host_terminator,e.tpl_host_fuzzy_strict=e.tpl_host_fuzzy+e.src_host_terminator,e.src_host_port_strict=e.src_host+e.src_port+e.src_host_terminator,e.tpl_host_port_fuzzy_strict=e.tpl_host_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_port_no_ip_fuzzy_strict=e.tpl_host_no_ip_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_fuzzy_test="localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:"+e.src_ZPCc+"|>|$))",e.tpl_email_fuzzy='(^|[><|]|"|\\(|'+e.src_ZCc+")("+e.src_email_name+"@"+e.tpl_host_fuzzy_strict+")",e.tpl_link_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`||]|"+e.src_ZPCc+"))((?![$+<=>^`||])"+e.tpl_host_port_fuzzy_strict+e.src_path+")",e.tpl_link_no_ip_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`||]|"+e.src_ZPCc+"))((?![$+<=>^`||])"+e.tpl_host_port_no_ip_fuzzy_strict+e.src_path+")",e}),Gu)(t.__opts__),n=t.__tlds__.slice();function r(i){return i.replace("%TLDS%",e.src_tlds)}t.onCompile(),t.__tlds_replaced__||n.push(rD),n.push(e.src_xn),e.src_tlds=n.join("|"),e.email_fuzzy=RegExp(r(e.tpl_email_fuzzy),"i"),e.link_fuzzy=RegExp(r(e.tpl_link_fuzzy),"i"),e.link_no_ip_fuzzy=RegExp(r(e.tpl_link_no_ip_fuzzy),"i"),e.host_fuzzy_test=RegExp(r(e.tpl_host_fuzzy_test),"i");var o=[];function a(i,l){throw new Error('(LinkifyIt) Invalid schema "'+i+'": '+l)}t.__compiled__={},Object.keys(t.__schemas__).forEach((function(i){var l=t.__schemas__[i];if(null!==l){var u={validate:null,link:null};if(t.__compiled__[i]=u,function(t){return"[object Object]"===Us(t)}(l))return!function(t){return"[object RegExp]"===Us(t)}(l.validate)?Y2(l.validate)?u.validate=l.validate:a(i,l):u.validate=function(t){return function(e,n){var r=e.slice(n);return t.test(r)?r.match(t)[0].length:0}}(l.validate),void(Y2(l.normalize)?u.normalize=l.normalize:l.normalize?a(i,l):u.normalize=function(t,e){e.normalize(t)});if(function(t){return"[object String]"===Us(t)}(l))return void o.push(i);a(i,l)}})),o.forEach((function(i){t.__compiled__[t.__schemas__[i]]&&(t.__compiled__[i].validate=t.__compiled__[t.__schemas__[i]].validate,t.__compiled__[i].normalize=t.__compiled__[t.__schemas__[i]].normalize)})),t.__compiled__[""]={validate:null,normalize:function(t,e){e.normalize(t)}};var s=Object.keys(t.__compiled__).filter((function(i){return i.length>0&&t.__compiled__[i]})).map(eD).join("|");t.re.schema_test=RegExp("(^|(?!_)(?:[><|]|"+e.src_ZPCc+"))("+s+")","i"),t.re.schema_search=RegExp("(^|(?!_)(?:[><|]|"+e.src_ZPCc+"))("+s+")","ig"),t.re.schema_at_start=RegExp("^"+t.re.schema_search.source,"i"),t.re.pretest=RegExp("("+t.re.schema_test.source+")|("+t.re.host_fuzzy_test.source+")|@","i"),function(t){t.__index__=-1,t.__text_cache__=""}(t)}function iD(t,e){var n=t.__index__,r=t.__last_index__,o=t.__text_cache__.slice(n,r);this.schema=t.__schema__.toLowerCase(),this.index=n+e,this.lastIndex=r+e,this.raw=o,this.text=o,this.url=o}function ju(t,e){var n=new iD(t,e);return t.__compiled__[n.schema].normalize(n,t),n}function ht(t,e){if(!(this instanceof ht))return new ht(t,e);e||function(t){return Object.keys(t||{}).reduce((function(e,n){return e||X2.hasOwnProperty(n)}),!1)}(t)&&(e=t,t={}),this.__opts__=Zu({},X2,e),this.__index__=-1,this.__last_index__=-1,this.__schema__="",this.__text_cache__="",this.__schemas__=Zu({},nD,t),this.__compiled__={},this.__tlds__=oD,this.__tlds_replaced__=!1,this.re={},qs(this)}ht.prototype.add=function(e,n){return this.__schemas__[e]=n,qs(this),this},ht.prototype.set=function(e){return this.__opts__=Zu(this.__opts__,e),this},ht.prototype.test=function(e){if(this.__text_cache__=e,this.__index__=-1,!e.length)return!1;var n,r,o,a,s,i,l,u;if(this.re.schema_test.test(e))for((l=this.re.schema_search).lastIndex=0;null!==(n=l.exec(e));)if(a=this.testSchemaAt(e,n[2],l.lastIndex)){this.__schema__=n[2],this.__index__=n.index+n[1].length,this.__last_index__=n.index+n[0].length+a;break}return this.__opts__.fuzzyLink&&this.__compiled__["http:"]&&((u=e.search(this.re.host_fuzzy_test))>=0&&(this.__index__<0||u=0&&null!==(o=e.match(this.re.email_fuzzy))&&(s=o.index+o[1].length,i=o.index+o[0].length,(this.__index__<0||sthis.__last_index__)&&(this.__schema__="mailto:",this.__index__=s,this.__last_index__=i))),this.__index__>=0},ht.prototype.pretest=function(e){return this.re.pretest.test(e)},ht.prototype.testSchemaAt=function(e,n,r){return this.__compiled__[n.toLowerCase()]?this.__compiled__[n.toLowerCase()].validate(e,r,this):0},ht.prototype.match=function(e){var n=0,r=[];this.__index__>=0&&this.__text_cache__===e&&(r.push(ju(this,n)),n=this.__last_index__);for(var o=n?e.slice(n):e;this.test(o);)r.push(ju(this,n)),o=o.slice(this.__last_index__),n+=this.__last_index__;return r.length?r:null},ht.prototype.matchAtStart=function(e){if(this.__text_cache__=e,this.__index__=-1,!e.length)return null;var n=this.re.schema_at_start.exec(e);if(!n)return null;var r=this.testSchemaAt(e,n[2],n[0].length);return r?(this.__schema__=n[2],this.__index__=n.index+n[1].length,this.__last_index__=n.index+n[0].length+r,ju(this,0)):null},ht.prototype.tlds=function(e,n){return e=Array.isArray(e)?e:[e],n?(this.__tlds__=this.__tlds__.concat(e).sort().filter((function(r,o,a){return r!==a[o-1]})).reverse(),qs(this),this):(this.__tlds__=e.slice(),this.__tlds_replaced__=!0,qs(this),this)},ht.prototype.normalize=function(e){e.schema||(e.url="http://"+e.url),"mailto:"===e.schema&&!/^mailto:/i.test(e.url)&&(e.url="mailto:"+e.url)},ht.prototype.onCompile=function(){};var lD=ht;const Pr=2147483647,dD=/^xn--/,pD=/[^\0-\x7F]/,fD=/[\x2E\u3002\uFF0E\uFF61]/g,mD={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},Yt=Math.floor,Yu=String.fromCharCode;function On(t){throw new RangeError(mD[t])}function nm(t,e){const n=t.split("@");let r="";n.length>1&&(r=n[0]+"@",t=n[1]);const a=function(t,e){const n=[];let r=t.length;for(;r--;)n[r]=e(t[r]);return n}((t=t.replace(fD,".")).split("."),e).join(".");return r+a}function Xu(t){const e=[];let n=0;const r=t.length;for(;n=55296&&o<=56319&&nString.fromCodePoint(...t),hD=function(t){return t>=48&&t<58?t-48+26:t>=65&&t<91?t-65:t>=97&&t<123?t-97:36},om=function(t,e){return t+22+75*(t<26)-((0!=e)<<5)},am=function(t,e,n){let r=0;for(t=n?Yt(t/700):t>>1,t+=Yt(t/e);t>455;r+=36)t=Yt(t/35);return Yt(r+36*t/(t+38))},Qu=function(t){const e=[],n=t.length;let r=0,o=128,a=72,s=t.lastIndexOf("-");s<0&&(s=0);for(let i=0;i=128&&On("not-basic"),e.push(t.charCodeAt(i));for(let i=s>0?s+1:0;i=n&&On("invalid-input");const d=hD(t.charCodeAt(i++));d>=36&&On("invalid-input"),d>Yt((Pr-r)/c)&&On("overflow"),r+=d*c;const f=p<=a?1:p>=a+26?26:p-a;if(dYt(Pr/b)&&On("overflow"),c*=b}const u=e.length+1;a=am(r-l,u,0==l),Yt(r/u)>Pr-o&&On("overflow"),o+=Yt(r/u),r%=u,e.splice(r++,0,o)}return String.fromCodePoint(...e)},Ju=function(t){const e=[],n=(t=Xu(t)).length;let r=128,o=0,a=72;for(const l of t)l<128&&e.push(Yu(l));const s=e.length;let i=s;for(s&&e.push("-");i=r&&cYt((Pr-o)/u)&&On("overflow"),o+=(l-r)*u,r=l;for(const c of t)if(cPr&&On("overflow"),c===r){let p=o;for(let d=36;;d+=36){const f=d<=a?1:d>=a+26?26:d-a;if(p=0))try{e.hostname=lm.toASCII(e.hostname)}catch{}return Jn.encode(Jn.format(e))}function OD(t){var e=Jn.parse(t,!0);if(e.hostname&&(!e.protocol||um.indexOf(e.protocol)>=0))try{e.hostname=lm.toUnicode(e.hostname)}catch{}return Jn.decode(Jn.format(e),Jn.decode.defaultChars+"%")}function Tt(t,e){if(!(this instanceof Tt))return new Tt(t,e);e||Go.isString(t)||(e=t||{},t="default"),this.inline=new SD,this.block=new CD,this.core=new DD,this.renderer=new yD,this.linkify=new ND,this.validateLink=RD,this.normalizeLink=LD,this.normalizeLinkText=OD,this.utils=Go,this.helpers=Go.assign({},vD),this.options={},this.configure(t),e&&this.set(e)}Tt.prototype.set=function(t){return Go.assign(this.options,t),this},Tt.prototype.configure=function(t){var n,e=this;if(Go.isString(t)&&!(t=TD[n=t]))throw new Error('Wrong `markdown-it` preset "'+n+'", check name');if(!t)throw new Error("Wrong `markdown-it` preset, can't be empty");return t.options&&e.set(t.options),t.components&&Object.keys(t.components).forEach((function(r){t.components[r].rules&&e[r].ruler.enableOnly(t.components[r].rules),t.components[r].rules2&&e[r].ruler2.enableOnly(t.components[r].rules2)})),this},Tt.prototype.enable=function(t,e){var n=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach((function(o){n=n.concat(this[o].ruler.enable(t,!0))}),this),n=n.concat(this.inline.ruler2.enable(t,!0));var r=t.filter((function(o){return n.indexOf(o)<0}));if(r.length&&!e)throw new Error("MarkdownIt. Failed to enable unknown rule(s): "+r);return this},Tt.prototype.disable=function(t,e){var n=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach((function(o){n=n.concat(this[o].ruler.disable(t,!0))}),this),n=n.concat(this.inline.ruler2.disable(t,!0));var r=t.filter((function(o){return n.indexOf(o)<0}));if(r.length&&!e)throw new Error("MarkdownIt. Failed to disable unknown rule(s): "+r);return this},Tt.prototype.use=function(t){var e=[this].concat(Array.prototype.slice.call(arguments,1));return t.apply(t,e),this},Tt.prototype.parse=function(t,e){if("string"!=typeof t)throw new Error("Input data should be a String");var n=new this.core.State(t,this,e);return this.core.process(n),n.tokens},Tt.prototype.render=function(t,e){return e=e||{},this.renderer.render(this.parse(t,e),this.options,e)},Tt.prototype.parseInline=function(t,e){var n=new this.core.State(t,this,e);return n.inlineMode=!0,this.core.process(n),n.tokens},Tt.prototype.renderInline=function(t,e){return e=e||{},this.renderer.render(this.parseInline(t,e),this.options,e)};const MD=Ht(Tt);function cm(t){return t instanceof Map?t.clear=t.delete=t.set=function(){throw new Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=function(){throw new Error("set is read-only")}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((e=>{const n=t[e],r=typeof n;("object"===r||"function"===r)&&!Object.isFrozen(n)&&cm(n)})),t}class dm{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function pm(t){return t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function kn(t,...e){const n=Object.create(null);for(const r in t)n[r]=t[r];return e.forEach((function(r){for(const o in r)n[o]=r[o]})),n}const fm=t=>!!t.scope;class PD{constructor(e,n){this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){this.buffer+=pm(e)}openNode(e){if(!fm(e))return;const n=((t,{prefix:e})=>{if(t.startsWith("language:"))return t.replace("language:","language-");if(t.includes(".")){const n=t.split(".");return[`${e}${n.shift()}`,...n.map(((r,o)=>`${r}${"_".repeat(o+1)}`))].join(" ")}return`${e}${t}`})(e.scope,{prefix:this.classPrefix});this.span(n)}closeNode(e){fm(e)&&(this.buffer+="")}value(){return this.buffer}span(e){this.buffer+=``}}const mm=(t={})=>{const e={children:[]};return Object.assign(e,t),e};class ec{constructor(){this.rootNode=mm(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){this.top.children.push(e)}openNode(e){const n=mm({scope:e});this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n),n.children.forEach((r=>this._walk(e,r))),e.closeNode(n)),e}static _collapse(e){"string"!=typeof e&&e.children&&(e.children.every((n=>"string"==typeof n))?e.children=[e.children.join("")]:e.children.forEach((n=>{ec._collapse(n)})))}}class UD extends ec{constructor(e){super(),this.options=e}addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){this.closeNode()}__addSublanguage(e,n){const r=e.root;n&&(r.scope=`language:${n}`),this.add(r)}toHTML(){return new PD(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}}function Zo(t){return t?"string"==typeof t?t:t.source:null}function gm(t){return er("(?=",t,")")}function qD(t){return er("(?:",t,")*")}function HD(t){return er("(?:",t,")?")}function er(...t){return t.map((n=>Zo(n))).join("")}function tc(...t){return"("+(function(t){const e=t[t.length-1];return"object"==typeof e&&e.constructor===Object?(t.splice(t.length-1,1),e):{}}(t).capture?"":"?:")+t.map((r=>Zo(r))).join("|")+")"}function hm(t){return new RegExp(t.toString()+"|").exec("").length-1}const zD=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function nc(t,{joinWith:e}){let n=0;return t.map((r=>{n+=1;const o=n;let a=Zo(r),s="";for(;a.length>0;){const i=zD.exec(a);if(!i){s+=a;break}s+=a.substring(0,i.index),a=a.substring(i.index+i[0].length),"\\"===i[0][0]&&i[1]?s+="\\"+String(Number(i[1])+o):(s+=i[0],"("===i[0]&&n++)}return s})).map((r=>`(${r})`)).join(e)}const Em="[a-zA-Z]\\w*",rc="[a-zA-Z_]\\w*",bm="\\b\\d+(\\.\\d+)?",Am="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",_m="\\b(0b[01]+)",jo={begin:"\\\\[\\s\\S]",relevance:0},WD={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[jo]},KD={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[jo]},Hs=function(t,e,n={}){const r=kn({scope:"comment",begin:t,end:e,contains:[]},n);r.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});const o=tc("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return r.contains.push({begin:er(/[ ]+/,"(",o,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),r},XD=Hs("//","$"),QD=Hs("/\\*","\\*/"),JD=Hs("#","$"),e3={scope:"number",begin:bm,relevance:0},t3={scope:"number",begin:Am,relevance:0},n3={scope:"number",begin:_m,relevance:0},r3={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[jo,{begin:/\[/,end:/\]/,relevance:0,contains:[jo]}]},o3={scope:"title",begin:Em,relevance:0},a3={scope:"title",begin:rc,relevance:0},s3={begin:"\\.\\s*"+rc,relevance:0};var Vs=Object.freeze({__proto__:null,APOS_STRING_MODE:WD,BACKSLASH_ESCAPE:jo,BINARY_NUMBER_MODE:n3,BINARY_NUMBER_RE:_m,COMMENT:Hs,C_BLOCK_COMMENT_MODE:QD,C_LINE_COMMENT_MODE:XD,C_NUMBER_MODE:t3,C_NUMBER_RE:Am,END_SAME_AS_BEGIN:function(t){return Object.assign(t,{"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{n.data._beginMatch!==e[1]&&n.ignoreMatch()}})},HASH_COMMENT_MODE:JD,IDENT_RE:Em,MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:s3,NUMBER_MODE:e3,NUMBER_RE:bm,PHRASAL_WORDS_MODE:{begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},QUOTE_STRING_MODE:KD,REGEXP_MODE:r3,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(t={})=>{const e=/^#![ ]*\//;return t.binary&&(t.begin=er(e,/.*\b/,t.binary,/\b.*/)),kn({scope:"meta",begin:e,end:/$/,relevance:0,"on:begin":(n,r)=>{0!==n.index&&r.ignoreMatch()}},t)},TITLE_MODE:o3,UNDERSCORE_IDENT_RE:rc,UNDERSCORE_TITLE_MODE:a3});function i3(t,e){"."===t.input[t.index-1]&&e.ignoreMatch()}function l3(t,e){void 0!==t.className&&(t.scope=t.className,delete t.className)}function u3(t,e){e&&t.beginKeywords&&(t.begin="\\b("+t.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",t.__beforeBegin=i3,t.keywords=t.keywords||t.beginKeywords,delete t.beginKeywords,void 0===t.relevance&&(t.relevance=0))}function c3(t,e){Array.isArray(t.illegal)&&(t.illegal=tc(...t.illegal))}function d3(t,e){if(t.match){if(t.begin||t.end)throw new Error("begin & end are not supported with match");t.begin=t.match,delete t.match}}function p3(t,e){void 0===t.relevance&&(t.relevance=1)}const f3=(t,e)=>{if(!t.beforeMatch)return;if(t.starts)throw new Error("beforeMatch cannot be used with starts");const n=Object.assign({},t);Object.keys(t).forEach((r=>{delete t[r]})),t.keywords=n.keywords,t.begin=er(n.beforeMatch,gm(n.begin)),t.starts={relevance:0,contains:[Object.assign(n,{endsParent:!0})]},t.relevance=0,delete n.beforeMatch},m3=["of","and","for","in","not","or","if","then","parent","list","value"],g3="keyword";function vm(t,e,n=g3){const r=Object.create(null);return"string"==typeof t?o(n,t.split(" ")):Array.isArray(t)?o(n,t):Object.keys(t).forEach((function(a){Object.assign(r,vm(t[a],e,a))})),r;function o(a,s){e&&(s=s.map((i=>i.toLowerCase()))),s.forEach((function(i){const l=i.split("|");r[l[0]]=[a,h3(l[0],l[1])]}))}}function h3(t,e){return e?Number(e):function(t){return m3.includes(t.toLowerCase())}(t)?0:1}const ym={},tr=t=>{console.error(t)},Dm=(t,...e)=>{console.log(`WARN: ${t}`,...e)},Ur=(t,e)=>{ym[`${t}/${e}`]||(console.log(`Deprecated as of ${t}. ${e}`),ym[`${t}/${e}`]=!0)},$s=new Error;function Cm(t,e,{key:n}){let r=0;const o=t[n],a={},s={};for(let i=1;i<=e.length;i++)s[i+r]=o[i],a[i+r]=!0,r+=hm(e[i-1]);t[n]=s,t[n]._emit=a,t[n]._multi=!0}function v3(t){(function(t){t.scope&&"object"==typeof t.scope&&null!==t.scope&&(t.beginScope=t.scope,delete t.scope)})(t),"string"==typeof t.beginScope&&(t.beginScope={_wrap:t.beginScope}),"string"==typeof t.endScope&&(t.endScope={_wrap:t.endScope}),function(t){if(Array.isArray(t.begin)){if(t.skip||t.excludeBegin||t.returnBegin)throw tr("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),$s;if("object"!=typeof t.beginScope||null===t.beginScope)throw tr("beginScope must be object"),$s;Cm(t,t.begin,{key:"beginScope"}),t.begin=nc(t.begin,{joinWith:""})}}(t),function(t){if(Array.isArray(t.end)){if(t.skip||t.excludeEnd||t.returnEnd)throw tr("skip, excludeEnd, returnEnd not compatible with endScope: {}"),$s;if("object"!=typeof t.endScope||null===t.endScope)throw tr("endScope must be object"),$s;Cm(t,t.end,{key:"endScope"}),t.end=nc(t.end,{joinWith:""})}}(t)}function y3(t){function e(s,i){return new RegExp(Zo(s),"m"+(t.case_insensitive?"i":"")+(t.unicodeRegex?"u":"")+(i?"g":""))}class n{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(i,l){l.position=this.position++,this.matchIndexes[this.matchAt]=l,this.regexes.push([l,i]),this.matchAt+=hm(i)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const i=this.regexes.map((l=>l[1]));this.matcherRe=e(nc(i,{joinWith:"|"}),!0),this.lastIndex=0}exec(i){this.matcherRe.lastIndex=this.lastIndex;const l=this.matcherRe.exec(i);if(!l)return null;const u=l.findIndex(((p,d)=>d>0&&void 0!==p)),c=this.matchIndexes[u];return l.splice(0,u),Object.assign(l,c)}}class r{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(i){if(this.multiRegexes[i])return this.multiRegexes[i];const l=new n;return this.rules.slice(i).forEach((([u,c])=>l.addRule(u,c))),l.compile(),this.multiRegexes[i]=l,l}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(i,l){this.rules.push([i,l]),"begin"===l.type&&this.count++}exec(i){const l=this.getMatcher(this.regexIndex);l.lastIndex=this.lastIndex;let u=l.exec(i);if(this.resumingScanAtSamePosition()&&(!u||u.index!==this.lastIndex)){const c=this.getMatcher(0);c.lastIndex=this.lastIndex+1,u=c.exec(i)}return u&&(this.regexIndex+=u.position+1,this.regexIndex===this.count&&this.considerAll()),u}}if(t.compilerExtensions||(t.compilerExtensions=[]),t.contains&&t.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return t.classNameAliases=kn(t.classNameAliases||{}),function a(s,i){const l=s;if(s.isCompiled)return l;[l3,d3,v3,f3].forEach((c=>c(s,i))),t.compilerExtensions.forEach((c=>c(s,i))),s.__beforeBegin=null,[u3,c3,p3].forEach((c=>c(s,i))),s.isCompiled=!0;let u=null;return"object"==typeof s.keywords&&s.keywords.$pattern&&(s.keywords=Object.assign({},s.keywords),u=s.keywords.$pattern,delete s.keywords.$pattern),u=u||/\w+/,s.keywords&&(s.keywords=vm(s.keywords,t.case_insensitive)),l.keywordPatternRe=e(u,!0),i&&(s.begin||(s.begin=/\B|\b/),l.beginRe=e(l.begin),!s.end&&!s.endsWithParent&&(s.end=/\B|\b/),s.end&&(l.endRe=e(l.end)),l.terminatorEnd=Zo(l.end)||"",s.endsWithParent&&i.terminatorEnd&&(l.terminatorEnd+=(s.end?"|":"")+i.terminatorEnd)),s.illegal&&(l.illegalRe=e(s.illegal)),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((function(c){return function(t){return t.variants&&!t.cachedVariants&&(t.cachedVariants=t.variants.map((function(e){return kn(t,{variants:null},e)}))),t.cachedVariants?t.cachedVariants:Sm(t)?kn(t,{starts:t.starts?kn(t.starts):null}):Object.isFrozen(t)?kn(t):t}("self"===c?s:c)}))),s.contains.forEach((function(c){a(c,l)})),s.starts&&a(s.starts,i),l.matcher=function(s){const i=new r;return s.contains.forEach((l=>i.addRule(l.begin,{rule:l,type:"begin"}))),s.terminatorEnd&&i.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&i.addRule(s.illegal,{type:"illegal"}),i}(l),l}(t)}function Sm(t){return!!t&&(t.endsWithParent||Sm(t.starts))}class S3 extends Error{constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}}const oc=pm,Nm=kn,Tm=Symbol("nomatch"),wm=function(t){const e=Object.create(null),n=Object.create(null),r=[];let o=!0;const a="Could not find the language '{}', did you forget to load/include a language module?",s={disableAutodetect:!0,name:"Plain text",contains:[]};let i={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:UD};function l(x){return i.noHighlightRe.test(x)}function c(x,S,C){let L="",k="";"object"==typeof S?(L=x,C=S.ignoreIllegals,k=S.language):(Ur("10.7.0","highlight(lang, code, ...args) has been deprecated."),Ur("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),k=x,L=S),void 0===C&&(C=!0);const $={code:L,language:k};V("before:highlight",$);const j=$.result?$.result:p($.language,$.code,C);return j.code=$.code,V("after:highlight",j),j}function p(x,S,C,L){const k=Object.create(null);function $(P,z){return P.keywords[z]}function j(){if(!X.keywords)return void Ce.addText(se);let P=0;X.keywordPatternRe.lastIndex=0;let z=X.keywordPatternRe.exec(se),Q="";for(;z;){Q+=se.substring(P,z.index);const re=Ne.case_insensitive?z[0].toLowerCase():z[0],he=$(X,re);if(he){const[We,ra]=he;if(Ce.addText(Q),Q="",k[re]=(k[re]||0)+1,k[re]<=7&&(qt+=ra),We.startsWith("_"))Q+=z[0];else{const oa=Ne.classNameAliases[We]||We;ue(z[0],oa)}}else Q+=z[0];P=X.keywordPatternRe.lastIndex,z=X.keywordPatternRe.exec(se)}Q+=se.substring(P),Ce.addText(Q)}function pe(){null!=X.subLanguage?function(){if(""===se)return;let P=null;if("string"==typeof X.subLanguage){if(!e[X.subLanguage])return void Ce.addText(se);P=p(X.subLanguage,se,!0,Zr[X.subLanguage]),Zr[X.subLanguage]=P._top}else P=f(se,X.subLanguage.length?X.subLanguage:null);X.relevance>0&&(qt+=P.relevance),Ce.__addSublanguage(P._emitter,P.language)}():j(),se=""}function ue(P,z){""!==P&&(Ce.startScope(z),Ce.addText(P),Ce.endScope())}function ce(P,z){let Q=1;const re=z.length-1;for(;Q<=re;){if(!P._emit[Q]){Q++;continue}const he=Ne.classNameAliases[P[Q]]||P[Q],We=z[Q];he?ue(We,he):(se=We,j(),se=""),Q++}}function bt(P,z){return P.scope&&"string"==typeof P.scope&&Ce.openNode(Ne.classNameAliases[P.scope]||P.scope),P.beginScope&&(P.beginScope._wrap?(ue(se,Ne.classNameAliases[P.beginScope._wrap]||P.beginScope._wrap),se=""):P.beginScope._multi&&(ce(P.beginScope,z),se="")),X=Object.create(P,{parent:{value:X}}),X}function Ut(P,z,Q){let re=function(t,e){const n=t&&t.exec(e);return n&&0===n.index}(P.endRe,Q);if(re){if(P["on:end"]){const he=new dm(P);P["on:end"](z,he),he.isMatchIgnored&&(re=!1)}if(re){for(;P.endsParent&&P.parent;)P=P.parent;return P}}if(P.endsWithParent)return Ut(P.parent,z,Q)}function At(P){return 0===X.matcher.regexIndex?(se+=P[0],1):(Wr=!0,0)}function xt(P){const z=P[0],Q=S.substring(P.index),re=Ut(X,P,Q);if(!re)return Tm;const he=X;X.endScope&&X.endScope._wrap?(pe(),ue(z,X.endScope._wrap)):X.endScope&&X.endScope._multi?(pe(),ce(X.endScope,P)):he.skip?se+=z:(he.returnEnd||he.excludeEnd||(se+=z),pe(),he.excludeEnd&&(se=z));do{X.scope&&Ce.closeNode(),!X.skip&&!X.subLanguage&&(qt+=X.relevance),X=X.parent}while(X!==re.parent);return re.starts&&bt(re.starts,P),he.returnEnd?0:z.length}let Oe={};function Be(P,z){const Q=z&&z[0];if(se+=P,null==Q)return pe(),0;if("begin"===Oe.type&&"end"===z.type&&Oe.index===z.index&&""===Q){if(se+=S.slice(z.index,z.index+1),!o){const re=new Error(`0 width match regex (${x})`);throw re.languageName=x,re.badRule=Oe.rule,re}return 1}if(Oe=z,"begin"===z.type)return function(P){const z=P[0],Q=P.rule,re=new dm(Q),he=[Q.__beforeBegin,Q["on:begin"]];for(const We of he)if(We&&(We(P,re),re.isMatchIgnored))return At(z);return Q.skip?se+=z:(Q.excludeBegin&&(se+=z),pe(),!Q.returnBegin&&!Q.excludeBegin&&(se=z)),bt(Q,P),Q.returnBegin?0:z.length}(z);if("illegal"===z.type&&!C){const re=new Error('Illegal lexeme "'+Q+'" for mode "'+(X.scope||"")+'"');throw re.mode=X,re}if("end"===z.type){const re=xt(z);if(re!==Tm)return re}if("illegal"===z.type&&""===Q)return 1;if(jr>1e5&&jr>3*z.index)throw new Error("potential infinite loop, way more iterations than matches");return se+=Q,Q.length}const Ne=H(x);if(!Ne)throw tr(a.replace("{}",x)),new Error('Unknown language: "'+x+'"');const ii=y3(Ne);let Gr="",X=L||ii;const Zr={},Ce=new i.__emitter(i);!function(){const P=[];for(let z=X;z!==Ne;z=z.parent)z.scope&&P.unshift(z.scope);P.forEach((z=>Ce.openNode(z)))}();let se="",qt=0,Qt=0,jr=0,Wr=!1;try{if(Ne.__emitTokens)Ne.__emitTokens(S,Ce);else{for(X.matcher.considerAll();;){jr++,Wr?Wr=!1:X.matcher.considerAll(),X.matcher.lastIndex=Qt;const P=X.matcher.exec(S);if(!P)break;const Q=Be(S.substring(Qt,P.index),P);Qt=P.index+Q}Be(S.substring(Qt))}return Ce.finalize(),Gr=Ce.toHTML(),{language:x,value:Gr,relevance:qt,illegal:!1,_emitter:Ce,_top:X}}catch(P){if(P.message&&P.message.includes("Illegal"))return{language:x,value:oc(S),illegal:!0,relevance:0,_illegalBy:{message:P.message,index:Qt,context:S.slice(Qt-100,Qt+100),mode:P.mode,resultSoFar:Gr},_emitter:Ce};if(o)return{language:x,value:oc(S),illegal:!1,relevance:0,errorRaised:P,_emitter:Ce,_top:X};throw P}}function f(x,S){S=S||i.languages||Object.keys(e);const C=function(x){const S={value:oc(x),illegal:!1,relevance:0,_top:s,_emitter:new i.__emitter(i)};return S._emitter.addText(x),S}(x),L=S.filter(H).filter(W).map((pe=>p(pe,x,!1)));L.unshift(C);const k=L.sort(((pe,ue)=>{if(pe.relevance!==ue.relevance)return ue.relevance-pe.relevance;if(pe.language&&ue.language){if(H(pe.language).supersetOf===ue.language)return 1;if(H(ue.language).supersetOf===pe.language)return-1}return 0})),[$,j]=k,ge=$;return ge.secondBest=j,ge}function A(x){let S=null;const C=function(x){let S=x.className+" ";S+=x.parentNode?x.parentNode.className:"";const C=i.languageDetectRe.exec(S);if(C){const L=H(C[1]);return L||(Dm(a.replace("{}",C[1])),Dm("Falling back to no-highlight mode for this block.",x)),L?C[1]:"no-highlight"}return S.split(/\s+/).find((L=>l(L)||H(L)))}(x);if(l(C))return;if(V("before:highlightElement",{el:x,language:C}),x.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",x);if(x.children.length>0&&(i.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(x)),i.throwUnescapedHTML))throw new S3("One of your code blocks includes unescaped HTML.",x.innerHTML);S=x;const L=S.textContent,k=C?c(L,{language:C,ignoreIllegals:!0}):f(L);x.innerHTML=k.value,x.dataset.highlighted="yes",function(x,S,C){const L=S&&n[S]||C;x.classList.add("hljs"),x.classList.add(`language-${L}`)}(x,C,k.language),x.result={language:k.language,re:k.relevance,relevance:k.relevance},k.secondBest&&(x.secondBest={language:k.secondBest.language,relevance:k.secondBest.relevance}),V("after:highlightElement",{el:x,result:k,text:L})}let E=!1;function _(){"loading"!==document.readyState?document.querySelectorAll(i.cssSelector).forEach(A):E=!0}function H(x){return x=(x||"").toLowerCase(),e[x]||e[n[x]]}function I(x,{languageName:S}){"string"==typeof x&&(x=[x]),x.forEach((C=>{n[C.toLowerCase()]=S}))}function W(x){const S=H(x);return S&&!S.disableAutodetect}function V(x,S){const C=x;r.forEach((function(L){L[C]&&L[C](S)}))}typeof window<"u"&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function(){E&&_()}),!1),Object.assign(t,{highlight:c,highlightAuto:f,highlightAll:_,highlightElement:A,highlightBlock:function(x){return Ur("10.7.0","highlightBlock will be removed entirely in v12.0"),Ur("10.7.0","Please use highlightElement now."),A(x)},configure:function(x){i=Nm(i,x)},initHighlighting:()=>{_(),Ur("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")},initHighlightingOnLoad:function(){_(),Ur("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")},registerLanguage:function(x,S){let C=null;try{C=S(t)}catch(L){if(tr("Language definition for '{}' could not be registered.".replace("{}",x)),!o)throw L;tr(L),C=s}C.name||(C.name=x),e[x]=C,C.rawDefinition=S.bind(null,t),C.aliases&&I(C.aliases,{languageName:x})},unregisterLanguage:function(x){delete e[x];for(const S of Object.keys(n))n[S]===x&&delete n[S]},listLanguages:function(){return Object.keys(e)},getLanguage:H,registerAliases:I,autoDetection:W,inherit:Nm,addPlugin:function(x){(function(x){x["before:highlightBlock"]&&!x["before:highlightElement"]&&(x["before:highlightElement"]=S=>{x["before:highlightBlock"](Object.assign({block:S.el},S))}),x["after:highlightBlock"]&&!x["after:highlightElement"]&&(x["after:highlightElement"]=S=>{x["after:highlightBlock"](Object.assign({block:S.el},S))})})(x),r.push(x)},removePlugin:function(x){const S=r.indexOf(x);-1!==S&&r.splice(S,1)}}),t.debugMode=function(){o=!1},t.safeMode=function(){o=!0},t.versionString="11.9.0",t.regex={concat:er,lookahead:gm,either:tc,optional:HD,anyNumberOfTimes:qD};for(const x in Vs)"object"==typeof Vs[x]&&cm(Vs[x]);return Object.assign(t,Vs),t},qr=wm({});qr.newInstance=()=>wm({});var T3=qr;qr.HighlightJS=qr,qr.default=qr;const J=Ht(T3);const I3=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],M3=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],F3=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],B3=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],P3=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse();var Hr="[0-9](_*[0-9])*",zs=`\\.(${Hr})`,Gs="[0-9a-fA-F](_*[0-9a-fA-F])*",xm={className:"number",variants:[{begin:`(\\b(${Hr})((${zs})|\\.)?|(${zs}))[eE][+-]?(${Hr})[fFdD]?\\b`},{begin:`\\b(${Hr})((${zs})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${zs})[fFdD]?\\b`},{begin:`\\b(${Hr})[fFdD]\\b`},{begin:`\\b0[xX]((${Gs})\\.?|(${Gs})?\\.(${Gs}))[pP][+-]?(${Hr})[fFdD]?\\b`},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${Gs})[lL]?\\b`},{begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],relevance:0};function Rm(t,e,n){return-1===n?"":t.replace(e,(r=>Rm(t,e,n-1)))}const Lm="[A-Za-z$_][0-9A-Za-z$_]*",G3=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],Z3=["true","false","null","undefined","NaN","Infinity"],Om=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],km=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Im=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],j3=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],W3=[].concat(Im,Om,km);var Vr="[0-9](_*[0-9])*",Zs=`\\.(${Vr})`,js="[0-9a-fA-F](_*[0-9a-fA-F])*",X3={className:"number",variants:[{begin:`(\\b(${Vr})((${Zs})|\\.)?|(${Zs}))[eE][+-]?(${Vr})[fFdD]?\\b`},{begin:`\\b(${Vr})((${Zs})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${Zs})[fFdD]?\\b`},{begin:`\\b(${Vr})[fFdD]\\b`},{begin:`\\b0[xX]((${js})\\.?|(${js})?\\.(${js}))[pP][+-]?(${Vr})[fFdD]?\\b`},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${js})[lL]?\\b`},{begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],relevance:0};const e8=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t8=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],Mm=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],Fm=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],n8=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),r8=Mm.concat(Fm);const v8=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],y8=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],D8=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],C8=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],S8=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse();function Bm(t){return t?"string"==typeof t?t:t.source:null}function Ws(t){return fe("(?=",t,")")}function fe(...t){return t.map((n=>Bm(n))).join("")}function it(...t){return"("+(function(t){const e=t[t.length-1];return"object"==typeof e&&e.constructor===Object?(t.splice(t.length-1,1),e):{}}(t).capture?"":"?:")+t.map((r=>Bm(r))).join("|")+")"}const ac=t=>fe(/\b/,t,/\w$/.test(t)?/\b/:/\B/),R8=["Protocol","Type"].map(ac),Pm=["init","self"].map(ac),L8=["Any","Self"],sc=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Um=["false","nil","true"],O8=["assignment","associativity","higherThan","left","lowerThan","none","right"],k8=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],qm=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Hm=it(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Vm=it(Hm,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),ic=fe(Hm,Vm,"*"),$m=it(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Ks=it($m,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),cn=fe($m,Ks,"*"),lc=fe(/[A-Z]/,Ks,"*"),I8=["attached","autoclosure",fe(/convention\(/,it("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",fe(/objc\(/,cn,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],M8=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"];const Ys="[A-Za-z$_][0-9A-Za-z$_]*",zm=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],Gm=["true","false","null","undefined","NaN","Infinity"],Zm=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],jm=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Wm=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],Km=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],Ym=[].concat(Wm,Zm,jm);J.registerLanguage("apache",(function(t){const r={className:"number",begin:/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?/};return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,contains:[t.HASH_COMMENT_MODE,{className:"section",begin:/<\/?/,end:/>/,contains:[r,{className:"number",begin:/:\d{1,5}/},t.inherit(t.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",begin:/\w+/,relevance:0,keywords:{_:["order","deny","allow","setenv","rewriterule","rewriteengine","rewritecond","documentroot","sethandler","errordocument","loadmodule","options","header","listen","serverroot","servername"]},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},contains:[{className:"meta",begin:/\s\[/,end:/\]$/},{className:"variable",begin:/[\$%]\{/,end:/\}/,contains:["self",{className:"number",begin:/[$%]\d+/}]},r,{className:"number",begin:/\b\d+/},t.QUOTE_STRING_MODE]}}],illegal:/\S/}})),J.registerLanguage("bash",(function(t){const e=t.regex,n={},r={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[n]}]};Object.assign(n,{className:"variable",variants:[{begin:e.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},r]});const o={className:"subst",begin:/\$\(/,end:/\)/,contains:[t.BACKSLASH_ESCAPE]},a={begin:/<<-?\s*(?=\w+)/,starts:{contains:[t.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/,contains:[t.BACKSLASH_ESCAPE,n,o]};o.contains.push(s);const c={begin:/\$?\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},t.NUMBER_MODE,n]},d=t.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),f={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[t.inherit(t.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/,keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"],literal:["true","false"],built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"]},contains:[d,t.SHEBANG(),f,c,t.HASH_COMMENT_MODE,a,{match:/(\/[a-z._-]+)+/},s,{match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},n]}})),J.registerLanguage("c",(function(t){const e=t.regex,n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]}),r="decltype\\(auto\\)",o="[a-zA-Z_]\\w*::",s="("+r+"|"+e.optional(o)+"[a-zA-Z_]\\w*"+e.optional("<[^<>]+>")+")",i={className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{match:/\batomic_[a-z]{3,6}\b/}]},u={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[t.BACKSLASH_ESCAPE]},{begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",end:"'",illegal:"."},t.END_SAME_AS_BEGIN({begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},c={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},p={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},t.inherit(u,{className:"string"}),{className:"string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},d={className:"title",begin:e.optional(o)+t.IDENT_RE,relevance:0},f=e.optional(o)+t.IDENT_RE+"\\s*\\(",y={keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"],type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"],literal:"true false NULL",built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr"},h=[p,i,n,t.C_BLOCK_COMMENT_MODE,c,u],g={variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],keywords:y,contains:h.concat([{begin:/\(/,end:/\)/,keywords:y,contains:h.concat(["self"]),relevance:0}]),relevance:0},E={begin:"("+s+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:y,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:r,keywords:y,relevance:0},{begin:f,returnBegin:!0,contains:[t.inherit(d,{className:"title.function"})],relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/,keywords:y,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,u,c,i,{begin:/\(/,end:/\)/,keywords:y,relevance:0,contains:["self",n,t.C_BLOCK_COMMENT_MODE,u,c,i]}]},i,n,t.C_BLOCK_COMMENT_MODE,p]};return{name:"C",aliases:["h"],keywords:y,disableAutodetect:!0,illegal:"=]/,contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{preprocessor:p,strings:u,keywords:y}}})),J.registerLanguage("cpp",(function(t){const e=t.regex,n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]}),r="decltype\\(auto\\)",o="[a-zA-Z_]\\w*::",s="(?!struct)("+r+"|"+e.optional(o)+"[a-zA-Z_]\\w*"+e.optional("<[^<>]+>")+")",i={className:"type",begin:"\\b[a-z\\d_]*_t\\b"},u={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[t.BACKSLASH_ESCAPE]},{begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",end:"'",illegal:"."},t.END_SAME_AS_BEGIN({begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},c={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},p={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},t.inherit(u,{className:"string"}),{className:"string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},d={className:"title",begin:e.optional(o)+t.IDENT_RE,relevance:0},f=e.optional(o)+t.IDENT_RE+"\\s*\\(",_={type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"],keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"],literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"],_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"]},N={className:"function.dispatch",relevance:0,keywords:{_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"]},begin:e.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,t.IDENT_RE,e.lookahead(/(<[^<>]+>|)\s*\(/))},v=[N,p,i,n,t.C_BLOCK_COMMENT_MODE,c,u],T={variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],keywords:_,contains:v.concat([{begin:/\(/,end:/\)/,keywords:_,contains:v.concat(["self"]),relevance:0}]),relevance:0},R={className:"function",begin:"("+s+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:_,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:r,keywords:_,relevance:0},{begin:f,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,endsWithParent:!0,contains:[u,c]},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/,keywords:_,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,u,c,i,{begin:/\(/,end:/\)/,keywords:_,relevance:0,contains:["self",n,t.C_BLOCK_COMMENT_MODE,u,c,i]}]},i,n,t.C_BLOCK_COMMENT_MODE,p]};return{name:"C++",aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:_,illegal:"",keywords:_,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:_},{match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/],className:{1:"keyword",3:"title.class"}}])}})),J.registerLanguage("csharp",(function(t){const s={keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]),built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"],literal:["default","false","null","true"]},i=t.inherit(t.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),l={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},u={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},c=t.inherit(u,{illegal:/\n/}),p={className:"subst",begin:/\{/,end:/\}/,keywords:s},d=t.inherit(p,{illegal:/\n/}),f={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/},t.BACKSLASH_ESCAPE,d]},b={className:"string",begin:/\$@"/,end:'"',contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},p]},A=t.inherit(b,{illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},d]});p.contains=[b,f,u,t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,l,t.C_BLOCK_COMMENT_MODE],d.contains=[A,f,c,t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,l,t.inherit(t.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];const y={variants:[b,f,u,t.APOS_STRING_MODE,t.QUOTE_STRING_MODE]},h={begin:"<",end:">",contains:[{beginKeywords:"in out"},i]},g=t.IDENT_RE+"(<"+t.IDENT_RE+"(\\s*,\\s*"+t.IDENT_RE+")*>)?(\\[\\])?",E={begin:"@"+t.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],keywords:s,illegal:/::/,contains:[t.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:""}]}]}),t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{keyword:"if else elif endif define undef warning error line region endregion pragma checksum"}},y,l,{beginKeywords:"class interface",relevance:0,end:/[{;=]/,illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"},i,h,t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",relevance:0,end:/[{;=]/,illegal:/[^\s:]/,contains:[i,t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},{beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/,contains:[i,h,t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+g+"\\s+)+"+t.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0,end:/\s*[{;=]/,excludeEnd:!0,keywords:s,contains:[{beginKeywords:["public","private","protected","static","internal","protected","abstract","async","extern","override","unsafe","virtual","new","sealed","partial"].join(" "),relevance:0},{begin:t.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0,contains:[t.TITLE_MODE,h],relevance:0},{match:/\(\)/},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:s,relevance:0,contains:[y,l,t.C_BLOCK_COMMENT_MODE]},t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},E]}})),J.registerLanguage("css",(function(t){const e=t.regex,n=(t=>({IMPORTANT:{scope:"meta",begin:"!important"},BLOCK_COMMENT:t.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/},ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{scope:"number",begin:t.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/}}))(t),i=[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE];return{name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"},contains:[n.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/},n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{begin:":("+F3.join("|")+")"},{begin:":(:)?("+B3.join("|")+")"}]},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+P3.join("|")+")\\b"},{begin:/:/,end:/[;}{]/,contains:[n.BLOCK_COMMENT,n.HEXCOLOR,n.IMPORTANT,n.CSS_NUMBER_MODE,...i,{begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri"},contains:[...i,{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}]},n.FUNCTION_DISPATCH]},{begin:e.lookahead(/@/),end:"[{;]",relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",attribute:M3.join(" ")},contains:[{begin:/[a-z-]+(?=:)/,className:"attribute"},...i,n.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"\\b("+I3.join("|")+")\\b"}]}})),J.registerLanguage("diff",(function(t){const e=t.regex;return{name:"Diff",aliases:["patch"],contains:[{className:"meta",relevance:10,match:e.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/)},{className:"comment",variants:[{begin:e.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/),end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/,end:/$/}]}})),J.registerLanguage("go",(function(t){const a={keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"],type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"],literal:["true","false","iota","nil"],built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"]};return{name:"Go",aliases:["golang"],keywords:a,illegal:")?",/~~~/g,2),l={keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"],literal:["false","true","null"],type:["char","boolean","long","float","int","byte","short","double"],built_in:["super","this"]},u={className:"meta",begin:"@"+n,contains:[{begin:/\(/,end:/\)/,contains:["self"]}]},c={className:"params",begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:[t.C_BLOCK_COMMENT_MODE],endsParent:!0};return{name:"Java",aliases:["jsp"],keywords:l,illegal:/<\/|#/,contains:[t.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{begin:/import java\.[a-z]+\./,keywords:"import",relevance:2},t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/,className:"string",contains:[t.BACKSLASH_ESCAPE]},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,{match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,n],className:{1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{begin:[e.concat(/(?!else)/,n),/\s+/,n,/\s+/,/=(?!=)/],className:{1:"type",3:"variable",5:"operator"}},{begin:[/record/,/\s+/,n],className:{1:"keyword",3:"title.class"},contains:[c,t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},{beginKeywords:"new throw return else",relevance:0},{begin:["(?:"+r+"\\s+)",t.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{2:"title.function"},keywords:l,contains:[{className:"params",begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:[u,t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,xm,t.C_BLOCK_COMMENT_MODE]},t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},xm,u]}})),J.registerLanguage("javascript",(function(t){const e=t.regex,r=Lm,o_begin="<>",o_end="",s={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(S,C)=>{const L=S[0].length+S.index,k=S.input[L];if("<"===k||","===k)return void C.ignoreMatch();let $;">"===k&&(((S,{after:C})=>{const L="",x={match:[/const|var|let/,/\s+/,r,/\s*/,/=\s*/,/(async\s*)?/,e.lookahead(B)],keywords:"async",className:{1:"keyword",3:"title.function"},contains:[v]};var S;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{PARAMS_CONTAINS:N,CLASS_REFERENCE:R},illegal:/#(?![$_A-z])/,contains:[t.SHEBANG({label:"shebang",binary:"node",relevance:5}),{label:"use_strict",className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,f,b,A,y,g,{match:/\$\d+/},p,R,{className:"attr",begin:r+e.lookahead(":"),relevance:0},x,{begin:"("+t.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",relevance:0,contains:[g,t.REGEXP_MODE,{className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:N}]}]},{begin:/,/,relevance:0},{match:/\s+/,relevance:0},{variants:[{begin:o_begin,end:o_end},{match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:s.begin,"on:begin":s.isTrulyOpeningTag,end:s.end}],subLanguage:"xml",contains:[{begin:s.begin,end:s.end,skip:!0,contains:["self"]}]}]},I,{beginKeywords:"while if switch catch for"},{begin:"\\b(?!function)"+t.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,label:"func.def",contains:[v,t.inherit(t.TITLE_MODE,{begin:r,className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+r,relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},contains:[v]},K,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},T,V,{match:/\$[(.]/}]}})),J.registerLanguage("json",(function(t){const r=["true","false","null"],o={scope:"literal",beginKeywords:r.join(" ")};return{name:"JSON",keywords:{literal:r},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0},t.QUOTE_STRING_MODE,o,t.C_NUMBER_MODE,t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE],illegal:"\\S"}})),J.registerLanguage("kotlin",(function(t){const e={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual",built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},r={className:"symbol",begin:t.UNDERSCORE_IDENT_RE+"@"},o={className:"subst",begin:/\$\{/,end:/\}/,contains:[t.C_NUMBER_MODE]},a={className:"variable",begin:"\\$"+t.UNDERSCORE_IDENT_RE},s={className:"string",variants:[{begin:'"""',end:'"""(?=[^"])',contains:[a,o]},{begin:"'",end:"'",illegal:/\n/,contains:[t.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[t.BACKSLASH_ESCAPE,a,o]}]};o.contains.push(s);const i={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+t.UNDERSCORE_IDENT_RE+")?"},l={className:"meta",begin:"@"+t.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[t.inherit(s,{className:"string"}),"self"]}]},u=X3,c=t.COMMENT("/\\*","\\*/",{contains:[t.C_BLOCK_COMMENT_MODE]}),p={variants:[{className:"type",begin:t.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,contains:[]}]},d=p;return d.variants[1].contains=[p],p.variants[1].contains=[d],{name:"Kotlin",aliases:["kt","kts"],keywords:e,contains:[t.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),t.C_LINE_COMMENT_MODE,c,{className:"keyword",begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",begin:/@\w+/}]}},r,i,l,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:e,relevance:5,contains:[{begin:t.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[t.UNDERSCORE_TITLE_MODE]},{className:"type",begin://,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:e,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,endsWithParent:!0,contains:[p,t.C_LINE_COMMENT_MODE,c],relevance:0},t.C_LINE_COMMENT_MODE,c,i,l,s,t.C_NUMBER_MODE]},c]},{begin:[/class|interface|trait/,/\s+/,t.UNDERSCORE_IDENT_RE],beginScope:{3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},t.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/,excludeBegin:!0,returnEnd:!0},i,l]},s,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},u]}})),J.registerLanguage("less",(function(t){const e=(t=>({IMPORTANT:{scope:"meta",begin:"!important"},BLOCK_COMMENT:t.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/},ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{scope:"number",begin:t.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/}}))(t),n=r8,o="[\\w-]+",a="("+o+"|@\\{"+o+"\\})",s=[],i=[],l=function(E){return{className:"string",begin:"~?"+E+".*?"+E}},u=function(E,_,N){return{className:E,begin:_,relevance:N}},c={$pattern:/[a-z-]+/,keyword:"and or not only",attribute:t8.join(" ")},p={begin:"\\(",end:"\\)",contains:i,keywords:c,relevance:0};i.push(t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,l("'"),l('"'),e.CSS_NUMBER_MODE,{begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",excludeEnd:!0}},e.HEXCOLOR,p,u("variable","@@?"+o,10),u("variable","@\\{"+o+"\\}"),u("built_in","~?`[^`]*?`"),{className:"attribute",begin:o+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0},e.IMPORTANT,{beginKeywords:"and not"},e.FUNCTION_DISPATCH);const d=i.concat({begin:/\{/,end:/\}/,contains:s}),f={beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"}].concat(i)},b={begin:a+"\\s*:",returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/},e.CSS_VARIABLE,{className:"attribute",begin:"\\b("+n8.join("|")+")\\b",end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:i}}]},A={className:"keyword",begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:i,relevance:0}},y={className:"variable",variants:[{begin:"@"+o+"\\s*:",relevance:15},{begin:"@"+o}],starts:{end:"[;}]",returnEnd:!0,contains:d}},h={variants:[{begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:a,end:/\{/}],returnBegin:!0,returnEnd:!0,illegal:"[<='$\"]",relevance:0,contains:[t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,f,u("keyword","all\\b"),u("variable","@\\{"+o+"\\}"),{begin:"\\b("+e8.join("|")+")\\b",className:"selector-tag"},e.CSS_NUMBER_MODE,u("selector-tag",a,0),u("selector-id","#"+a),u("selector-class","\\."+a,0),u("selector-tag","&",0),e.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",begin:":("+Mm.join("|")+")"},{className:"selector-pseudo",begin:":(:)?("+Fm.join("|")+")"},{begin:/\(/,end:/\)/,relevance:0,contains:d},{begin:"!important"},e.FUNCTION_DISPATCH]},g={begin:o+`:(:)?(${n.join("|")})`,returnBegin:!0,contains:[h]};return s.push(t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,A,y,g,b,h,f,e.FUNCTION_DISPATCH),{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:s}})),J.registerLanguage("lua",(function(t){const e="\\[=*\\[",n="\\]=*\\]",r={begin:e,end:n,contains:["self"]},o=[t.COMMENT("--(?!"+e+")","$"),t.COMMENT("--"+e,n,{contains:[r],relevance:10})];return{name:"Lua",keywords:{$pattern:t.UNDERSCORE_IDENT_RE,literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},contains:o.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[t.inherit(t.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:o}].concat(o)},t.C_NUMBER_MODE,t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,{className:"string",begin:e,end:n,contains:[r],relevance:5}])}})),J.registerLanguage("makefile",(function(t){const e={className:"variable",variants:[{begin:"\\$\\("+t.UNDERSCORE_IDENT_RE+"\\)",contains:[t.BACKSLASH_ESCAPE]},{begin:/\$[@%",subLanguage:"xml",relevance:0},l={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/,relevance:2},{begin:t.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/),relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}]},u={className:"strong",contains:[],variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}]},c={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{begin:/_(?![_\s])/,end:/_/,relevance:0}]},p=t.inherit(u,{contains:[]}),d=t.inherit(c,{contains:[]});u.contains.push(d),c.contains.push(p);let f=[n,l];return[u,c,p,d].forEach((y=>{y.contains=y.contains.concat(f)})),f=f.concat(u,c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:f},{begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",contains:f}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",end:"\\s+",excludeEnd:!0},u,c,{className:"quote",begin:"^>\\s+",contains:f,end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},l,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}})),J.registerLanguage("nginx",(function(t){const e=t.regex,n={className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{\w+\}/},{begin:e.concat(/[$@]/,t.UNDERSCORE_IDENT_RE)}]},o={endsWithParent:!0,keywords:{$pattern:/[a-z_]{2,}|\/dev\/poll/,literal:["on","off","yes","no","true","false","none","blocked","debug","info","notice","warn","error","crit","select","break","last","permanent","redirect","kqueue","rtsig","epoll","poll","/dev/poll"]},relevance:0,illegal:"=>",contains:[t.HASH_COMMENT_MODE,{className:"string",contains:[t.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n]},{className:"regexp",contains:[t.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^",end:"\\s|\\{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|\\{|;",returnEnd:!0},{begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{className:"number",begin:"\\b\\d+[kKmMgGdshdwy]?\\b",relevance:0},n]};return{name:"Nginx config",aliases:["nginxconf"],contains:[t.HASH_COMMENT_MODE,{beginKeywords:"upstream location",end:/;|\{/,contains:o.contains,keywords:{section:"upstream location"}},{className:"section",begin:e.concat(t.UNDERSCORE_IDENT_RE+e.lookahead(/\s+\{/)),relevance:0},{begin:e.lookahead(t.UNDERSCORE_IDENT_RE+"\\s"),end:";|\\{",contains:[{className:"attribute",begin:t.UNDERSCORE_IDENT_RE,starts:o}],relevance:0}],illegal:"[^\\s\\}\\{]"}})),J.registerLanguage("objectivec",(function(t){const n=/[a-zA-Z@][a-zA-Z0-9_]*/,l={$pattern:n,keyword:["@interface","@class","@protocol","@implementation"]};return{name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"],keywords:{"variable.language":["this","super"],$pattern:n,keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"],literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"],built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"],type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"]},illegal:"/,end:/$/,illegal:"\\n"},t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE]},{className:"class",begin:"("+l.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:l,contains:[t.UNDERSCORE_TITLE_MODE]},{begin:"\\."+t.UNDERSCORE_IDENT_RE,relevance:0}]}})),J.registerLanguage("perl",(function(t){const e=t.regex,r=/[dualxmsipngr]{0,12}/,o={$pattern:/[\w.]+/,keyword:["abs","accept","alarm","and","atan2","bind","binmode","bless","break","caller","chdir","chmod","chomp","chop","chown","chr","chroot","close","closedir","connect","continue","cos","crypt","dbmclose","dbmopen","defined","delete","die","do","dump","each","else","elsif","endgrent","endhostent","endnetent","endprotoent","endpwent","endservent","eof","eval","exec","exists","exit","exp","fcntl","fileno","flock","for","foreach","fork","format","formline","getc","getgrent","getgrgid","getgrnam","gethostbyaddr","gethostbyname","gethostent","getlogin","getnetbyaddr","getnetbyname","getnetent","getpeername","getpgrp","getpriority","getprotobyname","getprotobynumber","getprotoent","getpwent","getpwnam","getpwuid","getservbyname","getservbyport","getservent","getsockname","getsockopt","given","glob","gmtime","goto","grep","gt","hex","if","index","int","ioctl","join","keys","kill","last","lc","lcfirst","length","link","listen","local","localtime","log","lstat","lt","ma","map","mkdir","msgctl","msgget","msgrcv","msgsnd","my","ne","next","no","not","oct","open","opendir","or","ord","our","pack","package","pipe","pop","pos","print","printf","prototype","push","q|0","qq","quotemeta","qw","qx","rand","read","readdir","readline","readlink","readpipe","recv","redo","ref","rename","require","reset","return","reverse","rewinddir","rindex","rmdir","say","scalar","seek","seekdir","select","semctl","semget","semop","send","setgrent","sethostent","setnetent","setpgrp","setpriority","setprotoent","setpwent","setservent","setsockopt","shift","shmctl","shmget","shmread","shmwrite","shutdown","sin","sleep","socket","socketpair","sort","splice","split","sprintf","sqrt","srand","stat","state","study","sub","substr","symlink","syscall","sysopen","sysread","sysseek","system","syswrite","tell","telldir","tie","tied","time","times","tr","truncate","uc","ucfirst","umask","undef","unless","unlink","unpack","unshift","untie","until","use","utime","values","vec","wait","waitpid","wantarray","warn","when","while","write","x|0","xor","y|0"].join(" ")},a={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:o},s={begin:/->\{/,end:/\}/},i={variants:[{begin:/\$\d/},{begin:e.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])")},{begin:/[$%@][^\s\w{]/,relevance:0}]},l=[t.BACKSLASH_ESCAPE,a,i],u=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(f,b,A="\\1")=>{const y="\\1"===A?A:e.concat(A,b);return e.concat(e.concat("(?:",f,")"),b,/(?:\\.|[^\\\/])*?/,y,/(?:\\.|[^\\\/])*?/,A,r)},p=(f,b,A)=>e.concat(e.concat("(?:",f,")"),b,/(?:\\.|[^\\\/])*?/,A,r),d=[i,t.HASH_COMMENT_MODE,t.COMMENT(/^=\w/,/=cut/,{endsWithParent:!0}),s,{className:"string",contains:l,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[t.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[t.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+t.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",relevance:0,contains:[t.HASH_COMMENT_MODE,{className:"regexp",variants:[{begin:c("s|tr|y",e.either(...u,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{begin:p("(?:m|qr)?",/\//,/\//)},{begin:p("m|qr",e.either(...u,{capture:!0}),/\1/)},{begin:p("m|qr",/\(/,/\)/)},{begin:p("m|qr",/\[/,/\]/)},{begin:p("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[t.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];return a.contains=d,s.contains=d,{name:"Perl",aliases:["pl","pm"],keywords:o,contains:d}})),J.registerLanguage("pgsql",(function(t){const e=t.COMMENT("--","$"),r="\\$([a-zA-Z_]?|[a-zA-Z_][a-zA-Z_0-9]*)\\$",l="BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10 ",u=l.trim().split(" ").map((function(A){return A.split("|")[0]})).join("|"),b="ARRAY_AGG AVG BIT_AND BIT_OR BOOL_AND BOOL_OR COUNT EVERY JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG MAX MIN MODE STRING_AGG SUM XMLAGG CORR COVAR_POP COVAR_SAMP REGR_AVGX REGR_AVGY REGR_COUNT REGR_INTERCEPT REGR_R2 REGR_SLOPE REGR_SXX REGR_SXY REGR_SYY STDDEV STDDEV_POP STDDEV_SAMP VARIANCE VAR_POP VAR_SAMP PERCENTILE_CONT PERCENTILE_DISC ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE NUM_NONNULLS NUM_NULLS ABS CBRT CEIL CEILING DEGREES DIV EXP FLOOR LN LOG MOD PI POWER RADIANS ROUND SCALE SIGN SQRT TRUNC WIDTH_BUCKET RANDOM SETSEED ACOS ACOSD ASIN ASIND ATAN ATAND ATAN2 ATAN2D COS COSD COT COTD SIN SIND TAN TAND BIT_LENGTH CHAR_LENGTH CHARACTER_LENGTH LOWER OCTET_LENGTH OVERLAY POSITION SUBSTRING TREAT TRIM UPPER ASCII BTRIM CHR CONCAT CONCAT_WS CONVERT CONVERT_FROM CONVERT_TO DECODE ENCODE INITCAP LEFT LENGTH LPAD LTRIM MD5 PARSE_IDENT PG_CLIENT_ENCODING QUOTE_IDENT|10 QUOTE_LITERAL|10 QUOTE_NULLABLE|10 REGEXP_MATCH REGEXP_MATCHES REGEXP_REPLACE REGEXP_SPLIT_TO_ARRAY REGEXP_SPLIT_TO_TABLE REPEAT REPLACE REVERSE RIGHT RPAD RTRIM SPLIT_PART STRPOS SUBSTR TO_ASCII TO_HEX TRANSLATE OCTET_LENGTH GET_BIT GET_BYTE SET_BIT SET_BYTE TO_CHAR TO_DATE TO_NUMBER TO_TIMESTAMP AGE CLOCK_TIMESTAMP|10 DATE_PART DATE_TRUNC ISFINITE JUSTIFY_DAYS JUSTIFY_HOURS JUSTIFY_INTERVAL MAKE_DATE MAKE_INTERVAL|10 MAKE_TIME MAKE_TIMESTAMP|10 MAKE_TIMESTAMPTZ|10 NOW STATEMENT_TIMESTAMP|10 TIMEOFDAY TRANSACTION_TIMESTAMP|10 ENUM_FIRST ENUM_LAST ENUM_RANGE AREA CENTER DIAMETER HEIGHT ISCLOSED ISOPEN NPOINTS PCLOSE POPEN RADIUS WIDTH BOX BOUND_BOX CIRCLE LINE LSEG PATH POLYGON ABBREV BROADCAST HOST HOSTMASK MASKLEN NETMASK NETWORK SET_MASKLEN TEXT INET_SAME_FAMILY INET_MERGE MACADDR8_SET7BIT ARRAY_TO_TSVECTOR GET_CURRENT_TS_CONFIG NUMNODE PLAINTO_TSQUERY PHRASETO_TSQUERY WEBSEARCH_TO_TSQUERY QUERYTREE SETWEIGHT STRIP TO_TSQUERY TO_TSVECTOR JSON_TO_TSVECTOR JSONB_TO_TSVECTOR TS_DELETE TS_FILTER TS_HEADLINE TS_RANK TS_RANK_CD TS_REWRITE TSQUERY_PHRASE TSVECTOR_TO_ARRAY TSVECTOR_UPDATE_TRIGGER TSVECTOR_UPDATE_TRIGGER_COLUMN XMLCOMMENT XMLCONCAT XMLELEMENT XMLFOREST XMLPI XMLROOT XMLEXISTS XML_IS_WELL_FORMED XML_IS_WELL_FORMED_DOCUMENT XML_IS_WELL_FORMED_CONTENT XPATH XPATH_EXISTS XMLTABLE XMLNAMESPACES TABLE_TO_XML TABLE_TO_XMLSCHEMA TABLE_TO_XML_AND_XMLSCHEMA QUERY_TO_XML QUERY_TO_XMLSCHEMA QUERY_TO_XML_AND_XMLSCHEMA CURSOR_TO_XML CURSOR_TO_XMLSCHEMA SCHEMA_TO_XML SCHEMA_TO_XMLSCHEMA SCHEMA_TO_XML_AND_XMLSCHEMA DATABASE_TO_XML DATABASE_TO_XMLSCHEMA DATABASE_TO_XML_AND_XMLSCHEMA XMLATTRIBUTES TO_JSON TO_JSONB ARRAY_TO_JSON ROW_TO_JSON JSON_BUILD_ARRAY JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSONB_BUILD_OBJECT JSON_OBJECT JSONB_OBJECT JSON_ARRAY_LENGTH JSONB_ARRAY_LENGTH JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_EXTRACT_PATH JSONB_EXTRACT_PATH JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_POPULATE_RECORD JSONB_POPULATE_RECORD JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT JSON_TYPEOF JSONB_TYPEOF JSON_TO_RECORD JSONB_TO_RECORD JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_STRIP_NULLS JSONB_STRIP_NULLS JSONB_SET JSONB_INSERT JSONB_PRETTY CURRVAL LASTVAL NEXTVAL SETVAL COALESCE NULLIF GREATEST LEAST ARRAY_APPEND ARRAY_CAT ARRAY_NDIMS ARRAY_DIMS ARRAY_FILL ARRAY_LENGTH ARRAY_LOWER ARRAY_POSITION ARRAY_POSITIONS ARRAY_PREPEND ARRAY_REMOVE ARRAY_REPLACE ARRAY_TO_STRING ARRAY_UPPER CARDINALITY STRING_TO_ARRAY UNNEST ISEMPTY LOWER_INC UPPER_INC LOWER_INF UPPER_INF RANGE_MERGE GENERATE_SERIES GENERATE_SUBSCRIPTS CURRENT_DATABASE CURRENT_QUERY CURRENT_SCHEMA|10 CURRENT_SCHEMAS|10 INET_CLIENT_ADDR INET_CLIENT_PORT INET_SERVER_ADDR INET_SERVER_PORT ROW_SECURITY_ACTIVE FORMAT_TYPE TO_REGCLASS TO_REGPROC TO_REGPROCEDURE TO_REGOPER TO_REGOPERATOR TO_REGTYPE TO_REGNAMESPACE TO_REGROLE COL_DESCRIPTION OBJ_DESCRIPTION SHOBJ_DESCRIPTION TXID_CURRENT TXID_CURRENT_IF_ASSIGNED TXID_CURRENT_SNAPSHOT TXID_SNAPSHOT_XIP TXID_SNAPSHOT_XMAX TXID_SNAPSHOT_XMIN TXID_VISIBLE_IN_SNAPSHOT TXID_STATUS CURRENT_SETTING SET_CONFIG BRIN_SUMMARIZE_NEW_VALUES BRIN_SUMMARIZE_RANGE BRIN_DESUMMARIZE_RANGE GIN_CLEAN_PENDING_LIST SUPPRESS_REDUNDANT_UPDATES_TRIGGER LO_FROM_BYTEA LO_PUT LO_GET LO_CREAT LO_CREATE LO_UNLINK LO_IMPORT LO_EXPORT LOREAD LOWRITE GROUPING CAST ".trim().split(" ").map((function(A){return A.split("|")[0]})).join("|");return{name:"PostgreSQL",aliases:["postgres","postgresql"],supersetOf:"sql",case_insensitive:!0,keywords:{keyword:"ABORT ALTER ANALYZE BEGIN CALL CHECKPOINT|10 CLOSE CLUSTER COMMENT COMMIT COPY CREATE DEALLOCATE DECLARE DELETE DISCARD DO DROP END EXECUTE EXPLAIN FETCH GRANT IMPORT INSERT LISTEN LOAD LOCK MOVE NOTIFY PREPARE REASSIGN|10 REFRESH REINDEX RELEASE RESET REVOKE ROLLBACK SAVEPOINT SECURITY SELECT SET SHOW START TRUNCATE UNLISTEN|10 UPDATE VACUUM|10 VALUES AGGREGATE COLLATION CONVERSION|10 DATABASE DEFAULT PRIVILEGES DOMAIN TRIGGER EXTENSION FOREIGN WRAPPER|10 TABLE FUNCTION GROUP LANGUAGE LARGE OBJECT MATERIALIZED VIEW OPERATOR CLASS FAMILY POLICY PUBLICATION|10 ROLE RULE SCHEMA SEQUENCE SERVER STATISTICS SUBSCRIPTION SYSTEM TABLESPACE CONFIGURATION DICTIONARY PARSER TEMPLATE TYPE USER MAPPING PREPARED ACCESS METHOD CAST AS TRANSFORM TRANSACTION OWNED TO INTO SESSION AUTHORIZATION INDEX PROCEDURE ASSERTION ALL ANALYSE AND ANY ARRAY ASC ASYMMETRIC|10 BOTH CASE CHECK COLLATE COLUMN CONCURRENTLY|10 CONSTRAINT CROSS DEFERRABLE RANGE DESC DISTINCT ELSE EXCEPT FOR FREEZE|10 FROM FULL HAVING ILIKE IN INITIALLY INNER INTERSECT IS ISNULL JOIN LATERAL LEADING LIKE LIMIT NATURAL NOT NOTNULL NULL OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING SIMILAR SOME SYMMETRIC TABLESAMPLE THEN TRAILING UNION UNIQUE USING VARIADIC|10 VERBOSE WHEN WHERE WINDOW WITH BY RETURNS INOUT OUT SETOF|10 IF STRICT CURRENT CONTINUE OWNER LOCATION OVER PARTITION WITHIN BETWEEN ESCAPE EXTERNAL INVOKER DEFINER WORK RENAME VERSION CONNECTION CONNECT TABLES TEMP TEMPORARY FUNCTIONS SEQUENCES TYPES SCHEMAS OPTION CASCADE RESTRICT ADD ADMIN EXISTS VALID VALIDATE ENABLE DISABLE REPLICA|10 ALWAYS PASSING COLUMNS PATH REF VALUE OVERRIDING IMMUTABLE STABLE VOLATILE BEFORE AFTER EACH ROW PROCEDURAL ROUTINE NO HANDLER VALIDATOR OPTIONS STORAGE OIDS|10 WITHOUT INHERIT DEPENDS CALLED INPUT LEAKPROOF|10 COST ROWS NOWAIT SEARCH UNTIL ENCRYPTED|10 PASSWORD CONFLICT|10 INSTEAD INHERITS CHARACTERISTICS WRITE CURSOR ALSO STATEMENT SHARE EXCLUSIVE INLINE ISOLATION REPEATABLE READ COMMITTED SERIALIZABLE UNCOMMITTED LOCAL GLOBAL SQL PROCEDURES RECURSIVE SNAPSHOT ROLLUP CUBE TRUSTED|10 INCLUDE FOLLOWING PRECEDING UNBOUNDED RANGE GROUPS UNENCRYPTED|10 SYSID FORMAT DELIMITER HEADER QUOTE ENCODING FILTER OFF FORCE_QUOTE FORCE_NOT_NULL FORCE_NULL COSTS BUFFERS TIMING SUMMARY DISABLE_PAGE_SKIPPING RESTART CYCLE GENERATED IDENTITY DEFERRED IMMEDIATE LEVEL LOGGED UNLOGGED OF NOTHING NONE EXCLUDE ATTRIBUTE USAGE ROUTINES TRUE FALSE NAN INFINITY ALIAS BEGIN CONSTANT DECLARE END EXCEPTION RETURN PERFORM|10 RAISE GET DIAGNOSTICS STACKED|10 FOREACH LOOP ELSIF EXIT WHILE REVERSE SLICE DEBUG LOG INFO NOTICE WARNING ASSERT OPEN SUPERUSER NOSUPERUSER CREATEDB NOCREATEDB CREATEROLE NOCREATEROLE INHERIT NOINHERIT LOGIN NOLOGIN REPLICATION NOREPLICATION BYPASSRLS NOBYPASSRLS ",built_in:"CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURRENT_CATALOG|10 CURRENT_DATE LOCALTIME LOCALTIMESTAMP CURRENT_ROLE|10 CURRENT_SCHEMA|10 SESSION_USER PUBLIC FOUND NEW OLD TG_NAME|10 TG_WHEN|10 TG_LEVEL|10 TG_OP|10 TG_RELID|10 TG_RELNAME|10 TG_TABLE_NAME|10 TG_TABLE_SCHEMA|10 TG_NARGS|10 TG_ARGV|10 TG_EVENT|10 TG_TAG|10 ROW_COUNT RESULT_OID|10 PG_CONTEXT|10 RETURNED_SQLSTATE COLUMN_NAME CONSTRAINT_NAME PG_DATATYPE_NAME|10 MESSAGE_TEXT TABLE_NAME SCHEMA_NAME PG_EXCEPTION_DETAIL|10 PG_EXCEPTION_HINT|10 PG_EXCEPTION_CONTEXT|10 SQLSTATE SQLERRM|10 SUCCESSFUL_COMPLETION WARNING DYNAMIC_RESULT_SETS_RETURNED IMPLICIT_ZERO_BIT_PADDING NULL_VALUE_ELIMINATED_IN_SET_FUNCTION PRIVILEGE_NOT_GRANTED PRIVILEGE_NOT_REVOKED STRING_DATA_RIGHT_TRUNCATION DEPRECATED_FEATURE NO_DATA NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED SQL_STATEMENT_NOT_YET_COMPLETE CONNECTION_EXCEPTION CONNECTION_DOES_NOT_EXIST CONNECTION_FAILURE SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION TRANSACTION_RESOLUTION_UNKNOWN PROTOCOL_VIOLATION TRIGGERED_ACTION_EXCEPTION FEATURE_NOT_SUPPORTED INVALID_TRANSACTION_INITIATION LOCATOR_EXCEPTION INVALID_LOCATOR_SPECIFICATION INVALID_GRANTOR INVALID_GRANT_OPERATION INVALID_ROLE_SPECIFICATION DIAGNOSTICS_EXCEPTION STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER CASE_NOT_FOUND CARDINALITY_VIOLATION DATA_EXCEPTION ARRAY_SUBSCRIPT_ERROR CHARACTER_NOT_IN_REPERTOIRE DATETIME_FIELD_OVERFLOW DIVISION_BY_ZERO ERROR_IN_ASSIGNMENT ESCAPE_CHARACTER_CONFLICT INDICATOR_OVERFLOW INTERVAL_FIELD_OVERFLOW INVALID_ARGUMENT_FOR_LOGARITHM INVALID_ARGUMENT_FOR_NTILE_FUNCTION INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION INVALID_ARGUMENT_FOR_POWER_FUNCTION INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION INVALID_CHARACTER_VALUE_FOR_CAST INVALID_DATETIME_FORMAT INVALID_ESCAPE_CHARACTER INVALID_ESCAPE_OCTET INVALID_ESCAPE_SEQUENCE NONSTANDARD_USE_OF_ESCAPE_CHARACTER INVALID_INDICATOR_PARAMETER_VALUE INVALID_PARAMETER_VALUE INVALID_REGULAR_EXPRESSION INVALID_ROW_COUNT_IN_LIMIT_CLAUSE INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE INVALID_TABLESAMPLE_ARGUMENT INVALID_TABLESAMPLE_REPEAT INVALID_TIME_ZONE_DISPLACEMENT_VALUE INVALID_USE_OF_ESCAPE_CHARACTER MOST_SPECIFIC_TYPE_MISMATCH NULL_VALUE_NOT_ALLOWED NULL_VALUE_NO_INDICATOR_PARAMETER NUMERIC_VALUE_OUT_OF_RANGE SEQUENCE_GENERATOR_LIMIT_EXCEEDED STRING_DATA_LENGTH_MISMATCH STRING_DATA_RIGHT_TRUNCATION SUBSTRING_ERROR TRIM_ERROR UNTERMINATED_C_STRING ZERO_LENGTH_CHARACTER_STRING FLOATING_POINT_EXCEPTION INVALID_TEXT_REPRESENTATION INVALID_BINARY_REPRESENTATION BAD_COPY_FILE_FORMAT UNTRANSLATABLE_CHARACTER NOT_AN_XML_DOCUMENT INVALID_XML_DOCUMENT INVALID_XML_CONTENT INVALID_XML_COMMENT INVALID_XML_PROCESSING_INSTRUCTION INTEGRITY_CONSTRAINT_VIOLATION RESTRICT_VIOLATION NOT_NULL_VIOLATION FOREIGN_KEY_VIOLATION UNIQUE_VIOLATION CHECK_VIOLATION EXCLUSION_VIOLATION INVALID_CURSOR_STATE INVALID_TRANSACTION_STATE ACTIVE_SQL_TRANSACTION BRANCH_TRANSACTION_ALREADY_ACTIVE HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION READ_ONLY_SQL_TRANSACTION SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED NO_ACTIVE_SQL_TRANSACTION IN_FAILED_SQL_TRANSACTION IDLE_IN_TRANSACTION_SESSION_TIMEOUT INVALID_SQL_STATEMENT_NAME TRIGGERED_DATA_CHANGE_VIOLATION INVALID_AUTHORIZATION_SPECIFICATION INVALID_PASSWORD DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST DEPENDENT_OBJECTS_STILL_EXIST INVALID_TRANSACTION_TERMINATION SQL_ROUTINE_EXCEPTION FUNCTION_EXECUTED_NO_RETURN_STATEMENT MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED INVALID_CURSOR_NAME EXTERNAL_ROUTINE_EXCEPTION CONTAINING_SQL_NOT_PERMITTED MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED EXTERNAL_ROUTINE_INVOCATION_EXCEPTION INVALID_SQLSTATE_RETURNED NULL_VALUE_NOT_ALLOWED TRIGGER_PROTOCOL_VIOLATED SRF_PROTOCOL_VIOLATED EVENT_TRIGGER_PROTOCOL_VIOLATED SAVEPOINT_EXCEPTION INVALID_SAVEPOINT_SPECIFICATION INVALID_CATALOG_NAME INVALID_SCHEMA_NAME TRANSACTION_ROLLBACK TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION SERIALIZATION_FAILURE STATEMENT_COMPLETION_UNKNOWN DEADLOCK_DETECTED SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION SYNTAX_ERROR INSUFFICIENT_PRIVILEGE CANNOT_COERCE GROUPING_ERROR WINDOWING_ERROR INVALID_RECURSION INVALID_FOREIGN_KEY INVALID_NAME NAME_TOO_LONG RESERVED_NAME DATATYPE_MISMATCH INDETERMINATE_DATATYPE COLLATION_MISMATCH INDETERMINATE_COLLATION WRONG_OBJECT_TYPE GENERATED_ALWAYS UNDEFINED_COLUMN UNDEFINED_FUNCTION UNDEFINED_TABLE UNDEFINED_PARAMETER UNDEFINED_OBJECT DUPLICATE_COLUMN DUPLICATE_CURSOR DUPLICATE_DATABASE DUPLICATE_FUNCTION DUPLICATE_PREPARED_STATEMENT DUPLICATE_SCHEMA DUPLICATE_TABLE DUPLICATE_ALIAS DUPLICATE_OBJECT AMBIGUOUS_COLUMN AMBIGUOUS_FUNCTION AMBIGUOUS_PARAMETER AMBIGUOUS_ALIAS INVALID_COLUMN_REFERENCE INVALID_COLUMN_DEFINITION INVALID_CURSOR_DEFINITION INVALID_DATABASE_DEFINITION INVALID_FUNCTION_DEFINITION INVALID_PREPARED_STATEMENT_DEFINITION INVALID_SCHEMA_DEFINITION INVALID_TABLE_DEFINITION INVALID_OBJECT_DEFINITION WITH_CHECK_OPTION_VIOLATION INSUFFICIENT_RESOURCES DISK_FULL OUT_OF_MEMORY TOO_MANY_CONNECTIONS CONFIGURATION_LIMIT_EXCEEDED PROGRAM_LIMIT_EXCEEDED STATEMENT_TOO_COMPLEX TOO_MANY_COLUMNS TOO_MANY_ARGUMENTS OBJECT_NOT_IN_PREREQUISITE_STATE OBJECT_IN_USE CANT_CHANGE_RUNTIME_PARAM LOCK_NOT_AVAILABLE OPERATOR_INTERVENTION QUERY_CANCELED ADMIN_SHUTDOWN CRASH_SHUTDOWN CANNOT_CONNECT_NOW DATABASE_DROPPED SYSTEM_ERROR IO_ERROR UNDEFINED_FILE DUPLICATE_FILE SNAPSHOT_TOO_OLD CONFIG_FILE_ERROR LOCK_FILE_EXISTS FDW_ERROR FDW_COLUMN_NAME_NOT_FOUND FDW_DYNAMIC_PARAMETER_VALUE_NEEDED FDW_FUNCTION_SEQUENCE_ERROR FDW_INCONSISTENT_DESCRIPTOR_INFORMATION FDW_INVALID_ATTRIBUTE_VALUE FDW_INVALID_COLUMN_NAME FDW_INVALID_COLUMN_NUMBER FDW_INVALID_DATA_TYPE FDW_INVALID_DATA_TYPE_DESCRIPTORS FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER FDW_INVALID_HANDLE FDW_INVALID_OPTION_INDEX FDW_INVALID_OPTION_NAME FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH FDW_INVALID_STRING_FORMAT FDW_INVALID_USE_OF_NULL_POINTER FDW_TOO_MANY_HANDLES FDW_OUT_OF_MEMORY FDW_NO_SCHEMAS FDW_OPTION_NAME_NOT_FOUND FDW_REPLY_HANDLE FDW_SCHEMA_NOT_FOUND FDW_TABLE_NOT_FOUND FDW_UNABLE_TO_CREATE_EXECUTION FDW_UNABLE_TO_CREATE_REPLY FDW_UNABLE_TO_ESTABLISH_CONNECTION PLPGSQL_ERROR RAISE_EXCEPTION NO_DATA_FOUND TOO_MANY_ROWS ASSERT_FAILURE INTERNAL_ERROR DATA_CORRUPTED INDEX_CORRUPTED "},illegal:/:==|\W\s*\(\*|(^|\s)\$[a-z]|\{\{|[a-z]:\s*$|\.\.\.|TO:|DO:/,contains:[{className:"keyword",variants:[{begin:/\bTEXT\s*SEARCH\b/},{begin:/\b(PRIMARY|FOREIGN|FOR(\s+NO)?)\s+KEY\b/},{begin:/\bPARALLEL\s+(UNSAFE|RESTRICTED|SAFE)\b/},{begin:/\bSTORAGE\s+(PLAIN|EXTERNAL|EXTENDED|MAIN)\b/},{begin:/\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/},{begin:/\bNULLS\s+(FIRST|LAST)\b/},{begin:/\bEVENT\s+TRIGGER\b/},{begin:/\b(MAPPING|OR)\s+REPLACE\b/},{begin:/\b(FROM|TO)\s+(PROGRAM|STDIN|STDOUT)\b/},{begin:/\b(SHARE|EXCLUSIVE)\s+MODE\b/},{begin:/\b(LEFT|RIGHT)\s+(OUTER\s+)?JOIN\b/},{begin:/\b(FETCH|MOVE)\s+(NEXT|PRIOR|FIRST|LAST|ABSOLUTE|RELATIVE|FORWARD|BACKWARD)\b/},{begin:/\bPRESERVE\s+ROWS\b/},{begin:/\bDISCARD\s+PLANS\b/},{begin:/\bREFERENCING\s+(OLD|NEW)\b/},{begin:/\bSKIP\s+LOCKED\b/},{begin:/\bGROUPING\s+SETS\b/},{begin:/\b(BINARY|INSENSITIVE|SCROLL|NO\s+SCROLL)\s+(CURSOR|FOR)\b/},{begin:/\b(WITH|WITHOUT)\s+HOLD\b/},{begin:/\bWITH\s+(CASCADED|LOCAL)\s+CHECK\s+OPTION\b/},{begin:/\bEXCLUDE\s+(TIES|NO\s+OTHERS)\b/},{begin:/\bFORMAT\s+(TEXT|XML|JSON|YAML)\b/},{begin:/\bSET\s+((SESSION|LOCAL)\s+)?NAMES\b/},{begin:/\bIS\s+(NOT\s+)?UNKNOWN\b/},{begin:/\bSECURITY\s+LABEL\b/},{begin:/\bSTANDALONE\s+(YES|NO|NO\s+VALUE)\b/},{begin:/\bWITH\s+(NO\s+)?DATA\b/},{begin:/\b(FOREIGN|SET)\s+DATA\b/},{begin:/\bSET\s+(CATALOG|CONSTRAINTS)\b/},{begin:/\b(WITH|FOR)\s+ORDINALITY\b/},{begin:/\bIS\s+(NOT\s+)?DOCUMENT\b/},{begin:/\bXML\s+OPTION\s+(DOCUMENT|CONTENT)\b/},{begin:/\b(STRIP|PRESERVE)\s+WHITESPACE\b/},{begin:/\bNO\s+(ACTION|MAXVALUE|MINVALUE)\b/},{begin:/\bPARTITION\s+BY\s+(RANGE|LIST|HASH)\b/},{begin:/\bAT\s+TIME\s+ZONE\b/},{begin:/\bGRANTED\s+BY\b/},{begin:/\bRETURN\s+(QUERY|NEXT)\b/},{begin:/\b(ATTACH|DETACH)\s+PARTITION\b/},{begin:/\bFORCE\s+ROW\s+LEVEL\s+SECURITY\b/},{begin:/\b(INCLUDING|EXCLUDING)\s+(COMMENTS|CONSTRAINTS|DEFAULTS|IDENTITY|INDEXES|STATISTICS|STORAGE|ALL)\b/},{begin:/\bAS\s+(ASSIGNMENT|IMPLICIT|PERMISSIVE|RESTRICTIVE|ENUM|RANGE)\b/}]},{begin:/\b(FORMAT|FAMILY|VERSION)\s*\(/},{begin:/\bINCLUDE\s*\(/,keywords:"INCLUDE"},{begin:/\bRANGE(?!\s*(BETWEEN|UNBOUNDED|CURRENT|[-0-9]+))/},{begin:/\b(VERSION|OWNER|TEMPLATE|TABLESPACE|CONNECTION\s+LIMIT|PROCEDURE|RESTRICT|JOIN|PARSER|COPY|START|END|COLLATION|INPUT|ANALYZE|STORAGE|LIKE|DEFAULT|DELIMITER|ENCODING|COLUMN|CONSTRAINT|TABLE|SCHEMA)\s*=/},{begin:/\b(PG_\w+?|HAS_[A-Z_]+_PRIVILEGE)\b/,relevance:10},{begin:/\bEXTRACT\s*\(/,end:/\bFROM\b/,returnEnd:!0,keywords:{type:"CENTURY DAY DECADE DOW DOY EPOCH HOUR ISODOW ISOYEAR MICROSECONDS MILLENNIUM MILLISECONDS MINUTE MONTH QUARTER SECOND TIMEZONE TIMEZONE_HOUR TIMEZONE_MINUTE WEEK YEAR"}},{begin:/\b(XMLELEMENT|XMLPI)\s*\(\s*NAME/,keywords:{keyword:"NAME"}},{begin:/\b(XMLPARSE|XMLSERIALIZE)\s*\(\s*(DOCUMENT|CONTENT)/,keywords:{keyword:"DOCUMENT CONTENT"}},{beginKeywords:"CACHE INCREMENT MAXVALUE MINVALUE",end:t.C_NUMBER_RE,returnEnd:!0,keywords:"BY CACHE INCREMENT MAXVALUE MINVALUE"},{className:"type",begin:/\b(WITH|WITHOUT)\s+TIME\s+ZONE\b/},{className:"type",begin:/\bINTERVAL\s+(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND)(\s+TO\s+(MONTH|HOUR|MINUTE|SECOND))?\b/},{begin:/\bRETURNS\s+(LANGUAGE_HANDLER|TRIGGER|EVENT_TRIGGER|FDW_HANDLER|INDEX_AM_HANDLER|TSM_HANDLER)\b/,keywords:{keyword:"RETURNS",type:"LANGUAGE_HANDLER TRIGGER EVENT_TRIGGER FDW_HANDLER INDEX_AM_HANDLER TSM_HANDLER"}},{begin:"\\b("+b+")\\s*\\("},{begin:"\\.("+u+")\\b"},{begin:"\\b("+u+")\\s+PATH\\b",keywords:{keyword:"PATH",type:l.replace("PATH ","")}},{className:"type",begin:"\\b("+u+")\\b"},{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:"(e|E|u&|U&)'",end:"'",contains:[{begin:"\\\\."}],relevance:10},t.END_SAME_AS_BEGIN({begin:r,end:r,contains:[{subLanguage:["pgsql","perl","python","tcl","r","lua","java","php","ruby","bash","scheme","xml","json"],endsWithParent:!0}]}),{begin:'"',end:'"',contains:[{begin:'""'}]},t.C_NUMBER_MODE,t.C_BLOCK_COMMENT_MODE,e,{className:"meta",variants:[{begin:"%(ROW)?TYPE",relevance:10},{begin:"\\$\\d+"},{begin:"^#\\w",end:"$"}]},{className:"symbol",begin:"<<\\s*[a-zA-Z_][a-zA-Z_0-9$]*\\s*>>",relevance:10}]}})),J.registerLanguage("php",(function(t){const e=t.regex,n=/(?![A-Za-z0-9])(?![$])/,r=e.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,n),o=e.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,n),a={scope:"variable",match:"\\$+"+r},i={scope:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]},l=t.inherit(t.APOS_STRING_MODE,{illegal:null}),d="[ \t\n]",f={scope:"string",variants:[t.inherit(t.QUOTE_STRING_MODE,{illegal:null,contains:t.QUOTE_STRING_MODE.contains.concat(i)}),l,{begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/,contains:t.QUOTE_STRING_MODE.contains.concat(i),"on:begin":(K,O)=>{O.data._beginMatch=K[1]||K[2]},"on:end":(K,O)=>{O.data._beginMatch!==K[1]&&O.ignoreMatch()}},t.END_SAME_AS_BEGIN({begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},b={scope:"number",variants:[{begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?"}],relevance:0},A=["false","null","true"],y=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],h=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],E={keyword:y,literal:(K=>{const O=[];return K.forEach((V=>{O.push(V),V.toLowerCase()===V?O.push(V.toUpperCase()):O.push(V.toLowerCase())})),O})(A),built_in:h},_=K=>K.map((O=>O.replace(/\|\d+$/,""))),N={variants:[{match:[/new/,e.concat(d,"+"),e.concat("(?!",_(h).join("\\b|"),"\\b)"),o],scope:{1:"keyword",4:"title.class"}}]},v=e.concat(r,"\\b(?!\\()"),T={variants:[{match:[e.concat(/::/,e.lookahead(/(?!class\b)/)),v],scope:{2:"variable.constant"}},{match:[/::/,/class/],scope:{2:"variable.language"}},{match:[o,e.concat(/::/,e.lookahead(/(?!class\b)/)),v],scope:{1:"title.class",3:"variable.constant"}},{match:[o,e.concat("::",e.lookahead(/(?!class\b)/))],scope:{1:"title.class"}},{match:[o,/::/,/class/],scope:{1:"title.class",3:"variable.language"}}]},R={scope:"attr",match:e.concat(r,e.lookahead(":"),e.lookahead(/(?!::)/))},H={relevance:0,begin:/\(/,end:/\)/,keywords:E,contains:[R,a,T,t.C_BLOCK_COMMENT_MODE,f,b,N]},I={relevance:0,match:[/\b/,e.concat("(?!fn\\b|function\\b|",_(y).join("\\b|"),"|",_(h).join("\\b|"),"\\b)"),r,e.concat(d,"*"),e.lookahead(/(?=\()/)],scope:{3:"title.function.invoke"},contains:[H]};H.contains.push(I);const W=[R,T,t.C_BLOCK_COMMENT_MODE,f,b,N];return{case_insensitive:!1,keywords:E,contains:[{begin:e.concat(/#\[\s*/,o),beginScope:"meta",end:/]/,endScope:"meta",keywords:{literal:A,keyword:["new","array"]},contains:[{begin:/\[/,end:/]/,keywords:{literal:A,keyword:["new","array"]},contains:["self",...W]},...W,{scope:"meta",match:o}]},t.HASH_COMMENT_MODE,t.COMMENT("//","$"),t.COMMENT("/\\*","\\*/",{contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/,keywords:"__halt_compiler",starts:{scope:"comment",end:t.MATCH_NOTHING_RE,contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},a,I,T,{match:[/const/,/\s/,r],scope:{1:"keyword",3:"variable.constant"}},N,{scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use"},t.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:E,contains:["self",a,T,t.C_BLOCK_COMMENT_MODE,f,b]}]},{scope:"class",variants:[{beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait",illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{beginKeywords:"extends implements"},t.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/,contains:[t.inherit(t.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{beginKeywords:"use",relevance:0,end:";",contains:[{match:/\b(as|const|function)\b/,scope:"keyword"},t.UNDERSCORE_TITLE_MODE]},f,b]}})),J.registerLanguage("php-template",(function(t){return{name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},t.inherit(t.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),t.inherit(t.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]}]}})),J.registerLanguage("plaintext",(function(t){return{name:"Plain text",aliases:["text","txt"],disableAutodetect:!0}})),J.registerLanguage("python",(function(t){const e=t.regex,n=new RegExp("[\\p{XID_Start}_]\\p{XID_Continue}*","u"),r=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={$pattern:/[A-Za-z]\w+|__\w+__/,keyword:r,built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]},l={className:"meta",begin:/^(>>>|\.\.\.) /},u={className:"subst",begin:/\{/,end:/\}/,keywords:i,illegal:/#/},c={begin:/\{\{/,relevance:0},p={className:"string",contains:[t.BACKSLASH_ESCAPE],variants:[{begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,contains:[t.BACKSLASH_ESCAPE,l],relevance:10},{begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,contains:[t.BACKSLASH_ESCAPE,l],relevance:10},{begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,contains:[t.BACKSLASH_ESCAPE,l,c,u]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,end:/"""/,contains:[t.BACKSLASH_ESCAPE,l,c,u]},{begin:/([uU]|[rR])'/,end:/'/,relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,contains:[t.BACKSLASH_ESCAPE,c,u]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,contains:[t.BACKSLASH_ESCAPE,c,u]},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE]},d="[0-9](_?[0-9])*",f=`(\\b(${d}))?\\.(${d})|\\b(${d})\\.`,b=`\\b|${r.join("|")}`,A={className:"number",relevance:0,variants:[{begin:`(\\b(${d})|(${f}))[eE][+-]?(${d})[jJ]?(?=${b})`},{begin:`(${f})[jJ]?`},{begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${b})`},{begin:`\\b0[bB](_?[01])+[lL]?(?=${b})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${b})`},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${b})`},{begin:`\\b(${d})[jJ](?=${b})`}]},y={className:"comment",begin:e.lookahead(/# type:/),end:/$/,keywords:i,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},h={className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:["self",l,A,p,t.HASH_COMMENT_MODE]}]};return u.contains=[p,A,l],{name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i,illegal:/(<\/|\?)|=>/,contains:[l,A,{begin:/\bself\b/},{beginKeywords:"if",relevance:0},p,y,t.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,n],scope:{1:"keyword",3:"title.function"},contains:[h]},{variants:[{match:[/\bclass/,/\s+/,n,/\s*/,/\(\s*/,n,/\s*\)/]},{match:[/\bclass/,/\s+/,n]}],scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[A,h,p]}]}})),J.registerLanguage("python-repl",(function(t){return{aliases:["pycon"],contains:[{className:"meta.prompt",starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}})),J.registerLanguage("r",(function(t){const e=t.regex,n=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,r=e.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),o=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,a=e.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/);return{name:"R",keywords:{$pattern:n,keyword:"function if in break next repeat else for while",literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10",built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm"},contains:[t.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/,starts:{end:e.lookahead(e.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)),endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{scope:"variable",variants:[{match:n},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}]}),t.HASH_COMMENT_MODE,{scope:"string",contains:[t.BACKSLASH_ESCAPE],variants:[t.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/}),t.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/}),t.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/}),t.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/}),t.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/}),t.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"',relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{1:"operator",2:"number"},match:[o,r]},{scope:{1:"operator",2:"number"},match:[/%[^%]*%/,r]},{scope:{1:"punctuation",2:"number"},match:[a,r]},{scope:{2:"number"},match:[/[^a-zA-Z0-9._]|^/,r]}]},{scope:{3:"operator"},match:[n,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:o},{match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:a},{begin:"`",end:"`",contains:[{begin:/\\./}]}]}})),J.registerLanguage("ruby",(function(t){const e=t.regex,n="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",r=e.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),o=e.concat(r,/(::\w+)*/),s={"variable.constant":["__FILE__","__LINE__","__ENCODING__"],"variable.language":["self","super"],keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"],built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"],literal:["true","false","nil"]},i={className:"doctag",begin:"@[A-Za-z]+"},l={begin:"#<",end:">"},u=[t.COMMENT("#","$",{contains:[i]}),t.COMMENT("^=begin","^=end",{contains:[i],relevance:10}),t.COMMENT("^__END__",t.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/,end:/\}/,keywords:s},p={className:"string",contains:[t.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{begin:e.concat(/<<[-~]?'?/,e.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)),contains:[t.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[t.BACKSLASH_ESCAPE,c]})]}]},f="[0-9](_?[0-9])*",b={className:"number",relevance:0,variants:[{begin:`\\b([1-9](_?[0-9])*|0)(\\.(${f}))?([eE][+-]?(${f})|r)?i?\\b`},{begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{begin:"\\b0(_?[0-7])+r?i?\\b"}]},A={variants:[{match:/\(\)/},{className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0,keywords:s}]},v=[p,{variants:[{match:[/class\s+/,o,/\s+<\s+/,o]},{match:[/\b(class|module)\s+/,o]}],scope:{2:"title.class",4:"title.class.inherited"},keywords:s},{match:[/(include|extend)\s+/,o],scope:{2:"title.class"},keywords:s},{relevance:0,match:[o,/\.new[. (]/],scope:{1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},{relevance:0,match:r,scope:"title.class"},{match:[/def/,/\s+/,n],scope:{1:"keyword",3:"title.function"},contains:[A]},{begin:t.IDENT_RE+"::"},{className:"symbol",begin:t.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[p,{begin:n}],relevance:0},b,{className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0,relevance:0,keywords:s},{begin:"("+t.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{className:"regexp",contains:[t.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(l,u),relevance:0}].concat(l,u);c.contains=v,A.contains=v;const I=[{begin:/^\s*=>/,starts:{end:"$",contains:v}},{className:"meta.prompt",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])",starts:{end:"$",keywords:s,contains:v}}];return u.unshift(l),{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:s,illegal:/\/\*/,contains:[t.SHEBANG({binary:"ruby"})].concat(I).concat(u).concat(v)}})),J.registerLanguage("rust",(function(t){const e=t.regex,n={className:"title.function.invoke",relevance:0,begin:e.concat(/\b/,/(?!let|for|while|if|else|match\b)/,t.IDENT_RE,e.lookahead(/\s*\(/))},r="([ui](8|16|32|64|128|size)|f(32|64))?",s=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],i=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"];return{name:"Rust",aliases:["rs"],keywords:{$pattern:t.IDENT_RE+"!?",type:i,keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"],literal:["true","false","Some","None","Ok","Err"],built_in:s},illegal:""},n]}})),J.registerLanguage("scss",(function(t){const e=(t=>({IMPORTANT:{scope:"meta",begin:"!important"},BLOCK_COMMENT:t.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/},ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{scope:"number",begin:t.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/}}))(t),n=C8,r=D8,o="@[a-z-]+",i={className:"variable",begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS",case_insensitive:!0,illegal:"[=/|']",contains:[t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,e.CSS_NUMBER_MODE,{className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0},e.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag",begin:"\\b("+v8.join("|")+")\\b",relevance:0},{className:"selector-pseudo",begin:":("+r.join("|")+")"},{className:"selector-pseudo",begin:":(:)?("+n.join("|")+")"},i,{begin:/\(/,end:/\)/,contains:[e.CSS_NUMBER_MODE]},e.CSS_VARIABLE,{className:"attribute",begin:"\\b("+S8.join("|")+")\\b"},{begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{begin:/:/,end:/[;}{]/,relevance:0,contains:[e.BLOCK_COMMENT,i,e.HEXCOLOR,e.CSS_NUMBER_MODE,t.QUOTE_STRING_MODE,t.APOS_STRING_MODE,e.IMPORTANT,e.FUNCTION_DISPATCH]},{begin:"@(page|font-face)",keywords:{$pattern:o,keyword:"@page @font-face"}},{begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",attribute:y8.join(" ")},contains:[{begin:o,className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute"},i,t.QUOTE_STRING_MODE,t.APOS_STRING_MODE,e.HEXCOLOR,e.CSS_NUMBER_MODE]},e.FUNCTION_DISPATCH]}})),J.registerLanguage("shell",(function(t){return{name:"Shell Session",aliases:["console","shellsession"],contains:[{className:"meta.prompt",begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/,subLanguage:"bash"}}]}})),J.registerLanguage("sql",(function(t){const e=t.regex,n=t.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],c=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],d=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],f=c,b=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((E=>!c.includes(E))),h={begin:e.concat(/\b/,e.either(...f),/\s*\(/),relevance:0,keywords:{built_in:f}};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{$pattern:/\b[\w\.]+/,keyword:function(E,{exceptions:_,when:N}={}){const v=N;return _=_||[],E.map((T=>T.match(/\|\d+$/)||_.includes(T)?T:v(T)?`${T}|0`:T))}(b,{when:E=>E.length<3}),literal:a,type:i,built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]},contains:[{begin:e.either(...d),relevance:0,keywords:{$pattern:/[\w\.]+/,keyword:b.concat(d),literal:a,type:i}},{className:"type",begin:e.either("double precision","large object","with timezone","without timezone")},h,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string",variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{begin:/""/}]},t.C_NUMBER_MODE,t.C_BLOCK_COMMENT_MODE,n,{className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}})),J.registerLanguage("swift",(function(t){const e={match:/\s+/,relevance:0},n=t.COMMENT("/\\*","\\*/",{contains:["self"]}),r=[t.C_LINE_COMMENT_MODE,n],o={match:[/\./,it(...R8,...Pm)],className:{2:"keyword"}},a={match:fe(/\./,it(...sc)),relevance:0},s=sc.filter((ne=>"string"==typeof ne)).concat(["_|0"]),l={variants:[{className:"keyword",match:it(...sc.filter((ne=>"string"!=typeof ne)).concat(L8).map(ac),...Pm)}]},u={$pattern:it(/\b\w+/,/#\w+/),keyword:s.concat(k8),literal:Um},c=[o,a,l],f=[{match:fe(/\./,it(...qm)),relevance:0},{className:"built_in",match:fe(/\b/,it(...qm),/(?=\()/)}],b={match:/->/,relevance:0},y=[b,{className:"operator",relevance:0,variants:[{match:ic},{match:`\\.(\\.|${Vm})+`}]}],h="([0-9]_*)+",g="([0-9a-fA-F]_*)+",E={className:"number",relevance:0,variants:[{match:`\\b(${h})(\\.(${h}))?([eE][+-]?(${h}))?\\b`},{match:`\\b0x(${g})(\\.(${g}))?([pP][+-]?(${h}))?\\b`},{match:/\b0o([0-7]_*)+\b/},{match:/\b0b([01]_*)+\b/}]},_=(ne="")=>({className:"subst",variants:[{match:fe(/\\/,ne,/[0\\tnr"']/)},{match:fe(/\\/,ne,/u\{[0-9a-fA-F]{1,8}\}/)}]}),N=(ne="")=>({className:"subst",match:fe(/\\/,ne,/[\t ]*(?:[\r\n]|\r\n)/)}),v=(ne="")=>({className:"subst",label:"interpol",begin:fe(/\\/,ne,/\(/),end:/\)/}),T=(ne="")=>({begin:fe(ne,/"""/),end:fe(/"""/,ne),contains:[_(ne),N(ne),v(ne)]}),R=(ne="")=>({begin:fe(ne,/"/),end:fe(/"/,ne),contains:[_(ne),v(ne)]}),H={className:"string",variants:[T(),T("#"),T("##"),T("###"),R(),R("#"),R("##"),R("###")]},I=[t.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[t.BACKSLASH_ESCAPE]}],W={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//,contains:I},le=ne=>{const Oe=fe(ne,/\//),Be=fe(/\//,ne);return{begin:Oe,end:Be,contains:[...I,{scope:"comment",begin:`#(?!.*${Be})`,end:/$/}]}},K={scope:"regexp",variants:[le("###"),le("##"),le("#"),W]},O={match:fe(/`/,cn,/`/)},x=[O,{className:"variable",match:/\$\d+/},{className:"variable",match:`\\$${Ks}+`}],k=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{contains:[{begin:/\(/,end:/\)/,keywords:M8,contains:[...y,E,H]}]}},{scope:"keyword",match:fe(/@/,it(...I8))},{scope:"meta",match:fe(/@/,cn)}],$={match:Ws(/\b[A-Z]/),relevance:0,contains:[{className:"type",match:fe(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Ks,"+")},{className:"type",match:lc,relevance:0},{match:/[?!]+/,relevance:0},{match:/\.\.\./,relevance:0},{match:fe(/\s+&\s+/,Ws(lc)),relevance:0}]},j={begin://,keywords:u,contains:[...r,...c,...k,b,$]};$.contains.push(j);const pe={begin:/\(/,end:/\)/,relevance:0,keywords:u,contains:["self",{match:fe(cn,/\s*:/),keywords:"_|0",relevance:0},...r,K,...c,...f,...y,E,H,...x,...k,$]},ue={begin://,keywords:"repeat each",contains:[...r,$]},bt={begin:/\(/,end:/\)/,keywords:u,contains:[{begin:it(Ws(fe(cn,/\s*:/)),Ws(fe(cn,/\s+/,cn,/\s*:/))),end:/:/,relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:cn}]},...r,...c,...y,E,H,...k,$,pe],endsParent:!0,illegal:/["']/},Ut={match:[/(func|macro)/,/\s+/,it(O.match,cn,ic)],className:{1:"keyword",3:"title.function"},contains:[ue,bt,e],illegal:[/\[/,/%/]},At={match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"},contains:[ue,bt,e],illegal:/\[|%/},je={match:[/operator/,/\s+/,ic],className:{1:"keyword",3:"title"}},xt={begin:[/precedencegroup/,/\s+/,lc],className:{1:"keyword",3:"title"},contains:[$],keywords:[...O8,...Um],end:/}/};for(const ne of H.variants){const Oe=ne.contains.find((Ne=>"interpol"===Ne.label));Oe.keywords=u;const Be=[...c,...f,...y,E,H,...x];Oe.contains=[...Be,{begin:/\(/,end:/\)/,contains:["self",...Be]}]}return{name:"Swift",keywords:u,contains:[...r,Ut,At,{beginKeywords:"struct protocol class extension enum actor",end:"\\{",excludeEnd:!0,keywords:u,contains:[t.inherit(t.TITLE_MODE,{className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c]},je,xt,{beginKeywords:"import",end:/$/,contains:[...r],relevance:0},K,...c,...f,...y,E,H,...x,...k,$,pe]}})),J.registerLanguage("typescript",(function(t){const e=function(t){const e=t.regex,r=Ys,o_begin="<>",o_end="",s={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(S,C)=>{const L=S[0].length+S.index,k=S.input[L];if("<"===k||","===k)return void C.ignoreMatch();let $;">"===k&&(((S,{after:C})=>{const L="",x={match:[/const|var|let/,/\s+/,r,/\s*/,/=\s*/,/(async\s*)?/,e.lookahead(B)],keywords:"async",className:{1:"keyword",3:"title.function"},contains:[v]};var S;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{PARAMS_CONTAINS:N,CLASS_REFERENCE:R},illegal:/#(?![$_A-z])/,contains:[t.SHEBANG({label:"shebang",binary:"node",relevance:5}),{label:"use_strict",className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,f,b,A,y,g,{match:/\$\d+/},p,R,{className:"attr",begin:r+e.lookahead(":"),relevance:0},x,{begin:"("+t.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",relevance:0,contains:[g,t.REGEXP_MODE,{className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:N}]}]},{begin:/,/,relevance:0},{match:/\s+/,relevance:0},{variants:[{begin:o_begin,end:o_end},{match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:s.begin,"on:begin":s.isTrulyOpeningTag,end:s.end}],subLanguage:"xml",contains:[{begin:s.begin,end:s.end,skip:!0,contains:["self"]}]}]},I,{beginKeywords:"while if switch catch for"},{begin:"\\b(?!function)"+t.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,label:"func.def",contains:[v,t.inherit(t.TITLE_MODE,{begin:r,className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+r,relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},contains:[v]},K,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},T,V,{match:/\$[(.]/}]}}(t),n=Ys,r=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],o={beginKeywords:"namespace",end:/\{/,excludeEnd:!0,contains:[e.exports.CLASS_REFERENCE]},a={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{keyword:"interface extends",built_in:r},contains:[e.exports.CLASS_REFERENCE]},l={$pattern:Ys,keyword:zm.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]),literal:Gm,built_in:Ym.concat(r),"variable.language":Km},u={className:"meta",begin:"@"+n},c=(d,f,b)=>{const A=d.contains.findIndex((y=>y.label===f));if(-1===A)throw new Error("can not find mode to replace");d.contains.splice(A,1,b)};return Object.assign(e.keywords,l),e.exports.PARAMS_CONTAINS.push(u),e.contains=e.contains.concat([u,o,a]),c(e,"shebang",t.SHEBANG()),c(e,"use_strict",{className:"meta",relevance:10,begin:/^\s*['"]use strict['"]/}),e.contains.find((d=>"func.def"===d.label)).relevance=0,Object.assign(e,{name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),e})),J.registerLanguage("vbnet",(function(t){const e=t.regex,o=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,s=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,i=/\d{1,2}(:\d{1,2}){1,2}/,l={className:"literal",variants:[{begin:e.concat(/# */,e.either(a,o),/ *#/)},{begin:e.concat(/# */,i,/ *#/)},{begin:e.concat(/# */,s,/ *#/)},{begin:e.concat(/# */,e.either(a,o),/ +/,e.either(s,i),/ *#/)}]},p=t.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}]}),d=t.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]});return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0,classNameAliases:{label:"symbol"},keywords:{keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield",built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort",type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort",literal:"true false nothing"},illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/,end:/"/,illegal:/\n/,contains:[{begin:/""/}]},l,{className:"number",relevance:0,variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{className:"label",begin:/^\w+:/},p,d,{className:"meta",begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/,end:/$/,keywords:{keyword:"const disable else elseif enable end externalsource if region then"},contains:[d]}]}})),J.registerLanguage("wasm",(function(t){t.regex;const e=t.COMMENT(/\(;/,/;\)/);return e.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/,keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"]},contains:[t.COMMENT(/;;/,/$/),e,{match:[/(?:offset|align)/,/\s*/,/=/],className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword",3:"title.function"}},t.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/,className:"type"},{className:"keyword",match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/},{className:"number",relevance:0,match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/}]}})),J.registerLanguage("xml",(function(t){const e=t.regex,n=e.concat(/[\p{L}_]/u,e.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),o={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},a={begin:/\s/,contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},s=t.inherit(a,{begin:/\(/,end:/\)/}),i=t.inherit(t.APOS_STRING_MODE,{className:"string"}),l=t.inherit(t.QUOTE_STRING_MODE,{className:"string"}),u={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[a,l,i,s,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[a,s,l,i]}]}]},t.COMMENT(//,{relevance:10}),{begin://,relevance:10},o,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/,relevance:10,contains:[l]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[u],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[u],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:e.concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:n,relevance:0,starts:u}]},{className:"tag",begin:e.concat(/<\//,e.lookahead(e.concat(n,/>/))),contains:[{className:"name",begin:n,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}})),J.registerLanguage("yaml",(function(t){const e="true false yes no null",n="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[t.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},s=t.inherit(a,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),d={end:",",endsWithParent:!0,excludeEnd:!0,keywords:e,relevance:0},f={begin:/\{/,end:/\}/,contains:[d],illegal:"\\n",relevance:0},b={begin:"\\[",end:"\\]",contains:[d],illegal:"\\n",relevance:0},A=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+n},{className:"type",begin:"!<"+n+">"},{className:"type",begin:"!"+n},{className:"type",begin:"!!"+n},{className:"meta",begin:"&"+t.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+t.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},t.HASH_COMMENT_MODE,{beginKeywords:e,keywords:{literal:e}},{className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},{className:"number",begin:t.C_NUMBER_RE+"\\b",relevance:0},f,b,a],y=[...A];return y.pop(),y.push(s),d.contains=y,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:A}}));const Xm=J,Qm=MD({html:!1,typographer:!0,highlight:function(t,e){const n=Kn();if(e&&Xm.getLanguage(e))try{return`
\n
\n ${e}\n \n
\n
\n
`+Xm.highlight(t,{language:e,ignoreIllegals:!0}).value+"
"}catch{}return`
\n
\n plaintext\n \n
\n
\n
`+Z_.encode(t)+"
"}}).disable(["list","lheading"]);function Jm(t=""){return Qm.render(t)}/*! @license DOMPurify 3.0.8 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.8/LICENSE */Qm.renderer.rules.link_open=(t,e)=>``;const{entries:eg,setPrototypeOf:tg,isFrozen:$8,getPrototypeOf:z8,getOwnPropertyDescriptor:uc}=Object;let{freeze:tt,seal:Pt,create:ng}=Object,{apply:cc,construct:dc}=typeof Reflect<"u"&&Reflect;tt||(tt=function(e){return e}),Pt||(Pt=function(e){return e}),cc||(cc=function(e,n,r){return e.apply(n,r)}),dc||(dc=function(e,n){return new e(...n)});const Xs=wt(Array.prototype.forEach),rg=wt(Array.prototype.pop),Wo=wt(Array.prototype.push),Qs=wt(String.prototype.toLowerCase),pc=wt(String.prototype.toString),G8=wt(String.prototype.match),Ko=wt(String.prototype.replace),Z8=wt(String.prototype.indexOf),j8=wt(String.prototype.trim),Et=wt(RegExp.prototype.test),Yo=function(t){return function(){for(var e=arguments.length,n=new Array(e),r=0;r1?n-1:0),o=1;o2&&void 0!==arguments[2]?arguments[2]:Qs;tg&&tg(t,null);let r=e.length;for(;r--;){let o=e[r];if("string"==typeof o){const a=n(o);a!==o&&($8(e)||(e[r]=a),o=a)}t[o]=!0}return t}function K8(t){for(let e=0;e/gm),eC=Pt(/\${[\w\W]*}/gm),tC=Pt(/^data-[\-\w.\u00B7-\uFFFF]/),nC=Pt(/^aria-[\-\w]+$/),lg=Pt(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),rC=Pt(/^(?:\w+script|data):/i),oC=Pt(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),ug=Pt(/^html$/i);var cg=Object.freeze({__proto__:null,MUSTACHE_EXPR:Q8,ERB_EXPR:J8,TMPLIT_EXPR:eC,DATA_ATTR:tC,ARIA_ATTR:nC,IS_ALLOWED_URI:lg,IS_SCRIPT_OR_DATA:rC,ATTR_WHITESPACE:oC,DOCTYPE_NAME:ug});var iC=function dg(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:typeof window>"u"?null:window;const e=Z=>dg(Z);if(e.version="3.0.8",e.removed=[],!t||!t.document||9!==t.document.nodeType)return e.isSupported=!1,e;let{document:n}=t;const r=n,o=r.currentScript,{DocumentFragment:a,HTMLTemplateElement:s,Node:i,Element:l,NodeFilter:u,NamedNodeMap:c=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:p,DOMParser:d,trustedTypes:f}=t,b=l.prototype,A=Js(b,"cloneNode"),y=Js(b,"nextSibling"),h=Js(b,"childNodes"),g=Js(b,"parentNode");if("function"==typeof s){const Z=n.createElement("template");Z.content&&Z.content.ownerDocument&&(n=Z.content.ownerDocument)}let E,_="";const{implementation:N,createNodeIterator:v,createDocumentFragment:T,getElementsByTagName:R}=n,{importNode:H}=r;let I={};e.isSupported="function"==typeof eg&&"function"==typeof g&&N&&void 0!==N.createHTMLDocument;const{MUSTACHE_EXPR:W,ERB_EXPR:le,TMPLIT_EXPR:K,DATA_ATTR:O,ARIA_ATTR:V,IS_SCRIPT_OR_DATA:B,ATTR_WHITESPACE:x}=cg;let{IS_ALLOWED_URI:S}=cg,C=null;const L=ee({},[...og,...fc,...mc,...gc,...ag]);let k=null;const $=ee({},[...sg,...hc,...ig,...ei]);let j=Object.seal(ng(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ge=null,pe=null,ue=!0,ce=!0,bt=!1,Ut=!0,At=!1,je=!1,xt=!1,ne=!1,Oe=!1,Be=!1,Ne=!1,ii=!0,Gr=!1,Zr=!0,Ce=!1,se={},qt=null;const Qt=ee({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let jr=null;const Wr=ee({},["audio","video","img","source","image","track"]);let P=null;const z=ee({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Q="http://www.w3.org/1998/Math/MathML",re="http://www.w3.org/2000/svg",he="http://www.w3.org/1999/xhtml";let We=he,ra=!1,oa=null;const XS=ee({},[Q,re,he],pc);let aa=null;const QS=["application/xhtml+xml","text/html"];let Pe=null,Kr=null;const eN=n.createElement("form"),$g=function(D){return D instanceof RegExp||D instanceof Function},Dc=function(){let D=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!Kr||Kr!==D){if((!D||"object"!=typeof D)&&(D={}),D=nr(D),aa=-1===QS.indexOf(D.PARSER_MEDIA_TYPE)?"text/html":D.PARSER_MEDIA_TYPE,Pe="application/xhtml+xml"===aa?pc:Qs,C="ALLOWED_TAGS"in D?ee({},D.ALLOWED_TAGS,Pe):L,k="ALLOWED_ATTR"in D?ee({},D.ALLOWED_ATTR,Pe):$,oa="ALLOWED_NAMESPACES"in D?ee({},D.ALLOWED_NAMESPACES,pc):XS,P="ADD_URI_SAFE_ATTR"in D?ee(nr(z),D.ADD_URI_SAFE_ATTR,Pe):z,jr="ADD_DATA_URI_TAGS"in D?ee(nr(Wr),D.ADD_DATA_URI_TAGS,Pe):Wr,qt="FORBID_CONTENTS"in D?ee({},D.FORBID_CONTENTS,Pe):Qt,ge="FORBID_TAGS"in D?ee({},D.FORBID_TAGS,Pe):{},pe="FORBID_ATTR"in D?ee({},D.FORBID_ATTR,Pe):{},se="USE_PROFILES"in D&&D.USE_PROFILES,ue=!1!==D.ALLOW_ARIA_ATTR,ce=!1!==D.ALLOW_DATA_ATTR,bt=D.ALLOW_UNKNOWN_PROTOCOLS||!1,Ut=!1!==D.ALLOW_SELF_CLOSE_IN_ATTR,At=D.SAFE_FOR_TEMPLATES||!1,je=D.WHOLE_DOCUMENT||!1,Oe=D.RETURN_DOM||!1,Be=D.RETURN_DOM_FRAGMENT||!1,Ne=D.RETURN_TRUSTED_TYPE||!1,ne=D.FORCE_BODY||!1,ii=!1!==D.SANITIZE_DOM,Gr=D.SANITIZE_NAMED_PROPS||!1,Zr=!1!==D.KEEP_CONTENT,Ce=D.IN_PLACE||!1,S=D.ALLOWED_URI_REGEXP||lg,We=D.NAMESPACE||he,j=D.CUSTOM_ELEMENT_HANDLING||{},D.CUSTOM_ELEMENT_HANDLING&&$g(D.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(j.tagNameCheck=D.CUSTOM_ELEMENT_HANDLING.tagNameCheck),D.CUSTOM_ELEMENT_HANDLING&&$g(D.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(j.attributeNameCheck=D.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),D.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof D.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(j.allowCustomizedBuiltInElements=D.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),At&&(ce=!1),Be&&(Oe=!0),se&&(C=ee({},ag),k=[],!0===se.html&&(ee(C,og),ee(k,sg)),!0===se.svg&&(ee(C,fc),ee(k,hc),ee(k,ei)),!0===se.svgFilters&&(ee(C,mc),ee(k,hc),ee(k,ei)),!0===se.mathMl&&(ee(C,gc),ee(k,ig),ee(k,ei))),D.ADD_TAGS&&(C===L&&(C=nr(C)),ee(C,D.ADD_TAGS,Pe)),D.ADD_ATTR&&(k===$&&(k=nr(k)),ee(k,D.ADD_ATTR,Pe)),D.ADD_URI_SAFE_ATTR&&ee(P,D.ADD_URI_SAFE_ATTR,Pe),D.FORBID_CONTENTS&&(qt===Qt&&(qt=nr(qt)),ee(qt,D.FORBID_CONTENTS,Pe)),Zr&&(C["#text"]=!0),je&&ee(C,["html","head","body"]),C.table&&(ee(C,["tbody"]),delete ge.tbody),D.TRUSTED_TYPES_POLICY){if("function"!=typeof D.TRUSTED_TYPES_POLICY.createHTML)throw Yo('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof D.TRUSTED_TYPES_POLICY.createScriptURL)throw Yo('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');E=D.TRUSTED_TYPES_POLICY,_=E.createHTML("")}else void 0===E&&(E=function(e,n){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let r=null;const o="data-tt-policy-suffix";n&&n.hasAttribute(o)&&(r=n.getAttribute(o));const a="dompurify"+(r?"#"+r:"");try{return e.createPolicy(a,{createHTML:s=>s,createScriptURL:s=>s})}catch{return console.warn("TrustedTypes policy "+a+" could not be created."),null}}(f,o)),null!==E&&"string"==typeof _&&(_=E.createHTML(""));tt&&tt(D),Kr=D}},zg=ee({},["mi","mo","mn","ms","mtext"]),Gg=ee({},["foreignobject","desc","title","annotation-xml"]),tN=ee({},["title","style","font","a","script"]),Zg=ee({},[...fc,...mc,...Y8]),jg=ee({},[...gc,...X8]),rr=function(D){Wo(e.removed,{element:D});try{D.parentNode.removeChild(D)}catch{D.remove()}},Cc=function(D,F){try{Wo(e.removed,{attribute:F.getAttributeNode(D),from:F})}catch{Wo(e.removed,{attribute:null,from:F})}if(F.removeAttribute(D),"is"===D&&!k[D])if(Oe||Be)try{rr(F)}catch{}else try{F.setAttribute(D,"")}catch{}},Wg=function(D){let F=null,G=null;if(ne)D=""+D;else{const Ye=G8(D,/^[\r\n\t ]+/);G=Ye&&Ye[0]}"application/xhtml+xml"===aa&&We===he&&(D=''+D+"");const Ee=E?E.createHTML(D):D;if(We===he)try{F=(new d).parseFromString(Ee,aa)}catch{}if(!F||!F.documentElement){F=N.createDocument(We,"template",null);try{F.documentElement.innerHTML=ra?_:Ee}catch{}}const Ke=F.body||F.documentElement;return D&&G&&Ke.insertBefore(n.createTextNode(G),Ke.childNodes[0]||null),We===he?R.call(F,je?"html":"body")[0]:je?F.documentElement:Ke},Kg=function(D){return v.call(D.ownerDocument||D,D,u.SHOW_ELEMENT|u.SHOW_COMMENT|u.SHOW_TEXT,null)},Yg=function(D){return"function"==typeof i&&D instanceof i},dn=function(D,F,G){I[D]&&Xs(I[D],(Ee=>{Ee.call(e,F,G,Kr)}))},Xg=function(D){let F=null;if(dn("beforeSanitizeElements",D,null),function(D){return D instanceof p&&("string"!=typeof D.nodeName||"string"!=typeof D.textContent||"function"!=typeof D.removeChild||!(D.attributes instanceof c)||"function"!=typeof D.removeAttribute||"function"!=typeof D.setAttribute||"string"!=typeof D.namespaceURI||"function"!=typeof D.insertBefore||"function"!=typeof D.hasChildNodes)}(D))return rr(D),!0;const G=Pe(D.nodeName);if(dn("uponSanitizeElement",D,{tagName:G,allowedTags:C}),D.hasChildNodes()&&!Yg(D.firstElementChild)&&Et(/<[/\w]/g,D.innerHTML)&&Et(/<[/\w]/g,D.textContent))return rr(D),!0;if(!C[G]||ge[G]){if(!ge[G]&&Jg(G)&&(j.tagNameCheck instanceof RegExp&&Et(j.tagNameCheck,G)||j.tagNameCheck instanceof Function&&j.tagNameCheck(G)))return!1;if(Zr&&!qt[G]){const Ee=g(D)||D.parentNode,Ke=h(D)||D.childNodes;if(Ke&&Ee){for(let lt=Ke.length-1;lt>=0;--lt)Ee.insertBefore(A(Ke[lt],!0),y(D))}}return rr(D),!0}return D instanceof l&&!function(D){let F=g(D);(!F||!F.tagName)&&(F={namespaceURI:We,tagName:"template"});const G=Qs(D.tagName),Ee=Qs(F.tagName);return!!oa[D.namespaceURI]&&(D.namespaceURI===re?F.namespaceURI===he?"svg"===G:F.namespaceURI===Q?"svg"===G&&("annotation-xml"===Ee||zg[Ee]):!!Zg[G]:D.namespaceURI===Q?F.namespaceURI===he?"math"===G:F.namespaceURI===re?"math"===G&&Gg[Ee]:!!jg[G]:D.namespaceURI===he?!(F.namespaceURI===re&&!Gg[Ee]||F.namespaceURI===Q&&!zg[Ee])&&!jg[G]&&(tN[G]||!Zg[G]):!("application/xhtml+xml"!==aa||!oa[D.namespaceURI]))}(D)||("noscript"===G||"noembed"===G||"noframes"===G)&&Et(/<\/no(script|embed|frames)/i,D.innerHTML)?(rr(D),!0):(At&&3===D.nodeType&&(F=D.textContent,Xs([W,le,K],(Ee=>{F=Ko(F,Ee," ")})),D.textContent!==F&&(Wo(e.removed,{element:D.cloneNode()}),D.textContent=F)),dn("afterSanitizeElements",D,null),!1)},Qg=function(D,F,G){if(ii&&("id"===F||"name"===F)&&(G in n||G in eN))return!1;if((!ce||pe[F]||!Et(O,F))&&(!ue||!Et(V,F)))if(!k[F]||pe[F]){if(!(Jg(D)&&(j.tagNameCheck instanceof RegExp&&Et(j.tagNameCheck,D)||j.tagNameCheck instanceof Function&&j.tagNameCheck(D))&&(j.attributeNameCheck instanceof RegExp&&Et(j.attributeNameCheck,F)||j.attributeNameCheck instanceof Function&&j.attributeNameCheck(F))||"is"===F&&j.allowCustomizedBuiltInElements&&(j.tagNameCheck instanceof RegExp&&Et(j.tagNameCheck,G)||j.tagNameCheck instanceof Function&&j.tagNameCheck(G))))return!1}else if(!P[F]&&!Et(S,Ko(G,x,""))&&("src"!==F&&"xlink:href"!==F&&"href"!==F||"script"===D||0!==Z8(G,"data:")||!jr[D])&&(!bt||Et(B,Ko(G,x,"")))&&G)return!1;return!0},Jg=function(D){return D.indexOf("-")>0},e1=function(D){dn("beforeSanitizeAttributes",D,null);const{attributes:F}=D;if(!F)return;const G={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:k};let Ee=F.length;for(;Ee--;){const Ke=F[Ee],{name:Ye,namespaceURI:lt,value:or}=Ke,sa=Pe(Ye);let ut="value"===Ye?or:j8(or);if(G.attrName=sa,G.attrValue=ut,G.keepAttr=!0,G.forceKeepAttr=void 0,dn("uponSanitizeAttribute",D,G),ut=G.attrValue,G.forceKeepAttr||(Cc(Ye,D),!G.keepAttr))continue;if(!Ut&&Et(/\/>/i,ut)){Cc(Ye,D);continue}At&&Xs([W,le,K],(n1=>{ut=Ko(ut,n1," ")}));const t1=Pe(D.nodeName);if(Qg(t1,sa,ut)){if(Gr&&("id"===sa||"name"===sa)&&(Cc(Ye,D),ut="user-content-"+ut),E&&"object"==typeof f&&"function"==typeof f.getAttributeType&&!lt)switch(f.getAttributeType(t1,sa)){case"TrustedHTML":ut=E.createHTML(ut);break;case"TrustedScriptURL":ut=E.createScriptURL(ut)}try{lt?D.setAttributeNS(lt,Ye,ut):D.setAttribute(Ye,ut),rg(e.removed)}catch{}}}dn("afterSanitizeAttributes",D,null)},oN=function Z(D){let F=null;const G=Kg(D);for(dn("beforeSanitizeShadowDOM",D,null);F=G.nextNode();)dn("uponSanitizeShadowNode",F,null),!Xg(F)&&(F.content instanceof a&&Z(F.content),e1(F));dn("afterSanitizeShadowDOM",D,null)};return e.sanitize=function(Z){let D=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},F=null,G=null,Ee=null,Ke=null;if(ra=!Z,ra&&(Z="\x3c!--\x3e"),"string"!=typeof Z&&!Yg(Z)){if("function"!=typeof Z.toString)throw Yo("toString is not a function");if("string"!=typeof(Z=Z.toString()))throw Yo("dirty is not a string, aborting")}if(!e.isSupported)return Z;if(xt||Dc(D),e.removed=[],"string"==typeof Z&&(Ce=!1),Ce){if(Z.nodeName){const or=Pe(Z.nodeName);if(!C[or]||ge[or])throw Yo("root node is forbidden and cannot be sanitized in-place")}}else if(Z instanceof i)F=Wg("\x3c!----\x3e"),G=F.ownerDocument.importNode(Z,!0),1===G.nodeType&&"BODY"===G.nodeName||"HTML"===G.nodeName?F=G:F.appendChild(G);else{if(!Oe&&!At&&!je&&-1===Z.indexOf("<"))return E&&Ne?E.createHTML(Z):Z;if(F=Wg(Z),!F)return Oe?null:Ne?_:""}F&&ne&&rr(F.firstChild);const Ye=Kg(Ce?Z:F);for(;Ee=Ye.nextNode();)Xg(Ee)||(Ee.content instanceof a&&oN(Ee.content),e1(Ee));if(Ce)return Z;if(Oe){if(Be)for(Ke=T.call(F.ownerDocument);F.firstChild;)Ke.appendChild(F.firstChild);else Ke=F;return(k.shadowroot||k.shadowrootmode)&&(Ke=H.call(r,Ke,!0)),Ke}let lt=je?F.outerHTML:F.innerHTML;return je&&C["!doctype"]&&F.ownerDocument&&F.ownerDocument.doctype&&F.ownerDocument.doctype.name&&Et(ug,F.ownerDocument.doctype.name)&&(lt="\n"+lt),At&&Xs([W,le,K],(or=>{lt=Ko(lt,or," ")})),E&&Ne?E.createHTML(lt):lt},e.setConfig=function(){Dc(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),xt=!0},e.clearConfig=function(){Kr=null,xt=!1},e.isValidAttribute=function(Z,D,F){Kr||Dc({});const G=Pe(Z),Ee=Pe(D);return Qg(G,Ee,F)},e.addHook=function(Z,D){"function"==typeof D&&(I[Z]=I[Z]||[],Wo(I[Z],D))},e.removeHook=function(Z){if(I[Z])return rg(I[Z])},e.removeHooks=function(Z){I[Z]&&(I[Z]=[])},e.removeAllHooks=function(){I={}},e}();const pg=iC(window);function fg(t){if(!t)return"";try{return new Date(1e3*t).toLocaleTimeString([],{hour:"numeric",minute:"2-digit",hour12:!0})}catch{return""}}pg.setConfig({ADD_ATTR:["target","rel"]});const lC=({thought:t})=>{const[e,n]=U.useState(!1);return t&&ae.settings.showThoughts?w.jsxs("div",{className:"allm-mb-2",children:[w.jsxs("div",{onClick:()=>n(!e),className:"allm-cursor-pointer allm-flex allm-items-center allm-gap-x-1.5 allm-text-gray-400 hover:allm-text-gray-500",children:[w.jsx(yu,{size:14,weight:"bold",className:"allm-transition-transform "+(e?"allm-rotate-180":"")}),w.jsx("span",{className:"allm-text-xs allm-font-medium",children:"View thoughts"})]}),e&&w.jsx("div",{className:"allm-mt-2 allm-mb-3 allm-pl-0 allm-border-l-2 allm-border-gray-200",children:w.jsx("div",{className:"allm-text-xs allm-text-gray-600 allm-font-mono allm-whitespace-pre-wrap",children:t.trim()})})]}):null},uC=U.forwardRef((({uuid:t=Kn(),message:e,role:n,sources:r=[],error:o=!1,errorMsg:a=null,sentAt:s},i)=>{const l=ae.settings.textSize?`allm-text-[${ae.settings.textSize}px]`:"allm-text-sm";o&&console.error(`ANYTHING_LLM_CHAT_WIDGET_ERROR: ${o}`);const c=((null==e?void 0:e.match(/([\s\S]*?)<\/think>/g))||[]).map((d=>d.replace(/|<\/think>/g,"").trim())),p=null==e?void 0:e.replace(/[\s\S]*?<\/think>/g,"").trim();return w.jsxs("div",{className:"allm-py-[5px]",children:["assistant"===n&&w.jsx("div",{className:"allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mb-2 allm-text-left allm-font-sans",children:ae.settings.assistantName||"Anything LLM Chat Assistant"}),w.jsxs("div",{ref:i,className:"allm-flex allm-items-start allm-w-full allm-h-fit "+("user"===n?"allm-justify-end":"allm-justify-start"),children:["assistant"===n&&w.jsx("img",{src:ae.settings.assistantIcon||qo,alt:"Anything LLM Icon",className:"allm-w-9 allm-h-9 allm-flex-shrink-0 allm-ml-2",id:"anything-llm-icon"}),w.jsx("div",{style:{wordBreak:"break-word",backgroundColor:"user"===n?ae.USER_STYLES.msgBg:ae.ASSISTANT_STYLES.msgBg},className:`allm-py-[11px] allm-px-4 allm-flex allm-flex-col allm-font-sans ${o?"allm-bg-red-200 allm-rounded-lg allm-mr-[37px] allm-ml-[9px]":"user"===n?`${ae.USER_STYLES.base} allm-anything-llm-user-message`:`${ae.ASSISTANT_STYLES.base} allm-anything-llm-assistant-message`} allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)]`,children:w.jsx("div",{className:"allm-flex allm-flex-col",children:o?w.jsxs("div",{className:"allm-p-2 allm-rounded-lg allm-bg-red-50 allm-text-red-500",children:[w.jsxs("span",{className:"allm-inline-block",children:[w.jsx(Du,{className:"allm-h-4 allm-w-4 allm-mb-1 allm-inline-block"})," ","Could not respond to message."]}),w.jsx("p",{className:"allm-text-xs allm-font-mono allm-mt-2 allm-border-l-2 allm-border-red-500 allm-pl-2 allm-bg-red-300 allm-p-2 allm-rounded-sm",children:a||"Server error"})]}):w.jsxs(w.Fragment,{children:["assistant"===n&&c.length>0&&w.jsx(lC,{thought:c.join("\n\n")}),w.jsx("span",{className:`allm-whitespace-pre-line allm-flex allm-flex-col allm-gap-y-1 ${l} allm-leading-[20px]`,dangerouslySetInnerHTML:{__html:pg.sanitize(Jm(p||e))}})]})})})]},t),s&&w.jsx("div",{className:"allm-font-sans allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mt-2 "+("user"===n?"allm-text-right":"allm-text-left"),children:fg(s)})]})})),cC=U.memo(uC),dC=({hasThought:t})=>t?w.jsxs("div",{className:"allm-flex allm-items-center allm-gap-x-2 allm-text-gray-500",children:[w.jsx(Cs,{size:16,className:"allm-animate-spin"}),w.jsx("span",{className:"allm-text-sm",children:"Thinking..."})]}):w.jsx("div",{className:"allm-mx-4 allm-my-1 allm-dot-falling"}),mg=({thought:t})=>{const[e,n]=U.useState(!1);if(!t||!ae.settings.showThoughts)return null;const r=t.replace(/<\/?think>/g,"").trim();return w.jsxs("div",{className:"allm-mb-2",children:[w.jsxs("div",{onClick:()=>n(!e),className:"allm-cursor-pointer allm-flex allm-items-center allm-gap-x-1.5 allm-text-gray-400 hover:allm-text-gray-500",children:[w.jsx(yu,{size:14,weight:"bold",className:"allm-transition-transform "+(e?"allm-rotate-180":"")}),w.jsx("span",{className:"allm-text-xs allm-font-medium",children:"View thoughts"})]}),e&&w.jsx("div",{className:"allm-mt-2 allm-mb-3 allm-pl-0 allm-border-l-2 allm-border-gray-200",children:w.jsx("div",{className:"allm-text-xs allm-text-gray-600 allm-font-mono allm-whitespace-pre-wrap",children:r})})]})},pC=U.forwardRef((({uuid:t,reply:e,pending:n,error:r,sources:o=[],sentAt:a},s)=>{var f;if(!e&&0===o.length&&!n&&!r)return null;r&&console.error(`ANYTHING_LLM_CHAT_WIDGET_ERROR: ${r}`);const l=((null==e?void 0:e.match(/([\s\S]*?)<\/think>/g))||[]).map((b=>b.replace(/<\/?think>/g,"").trim())),u=(null==e?void 0:e.includes(""))&&!(null!=e&&e.includes("")),c=u?null==(f=null==e?void 0:e.split("").pop())?void 0:f.replace(/<\/?think>/g,"").trim():null;c||l[l.length-1];const p=u||n,d=null==e?void 0:e.replace(/[\s\S]*?<\/think>/g,"").replace(/.*$/g,"").replace(/<\/?think>/g,"").trim();return p?w.jsxs("div",{className:"allm-py-[5px]",children:[w.jsx("div",{className:"allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mb-2 allm-text-left allm-font-sans",children:ae.settings.assistantName||"Anything LLM Chat Assistant"}),w.jsxs("div",{className:"allm-flex allm-items-start allm-w-full allm-h-fit allm-justify-start",children:[w.jsx("img",{src:ae.settings.assistantIcon||qo,alt:"Anything LLM Icon",className:"allm-w-9 allm-h-9 allm-flex-shrink-0 allm-ml-2"}),w.jsxs("div",{style:{wordBreak:"break-word",backgroundColor:ae.ASSISTANT_STYLES.msgBg},className:`allm-py-[11px] allm-px-4 allm-flex allm-flex-col ${ae.ASSISTANT_STYLES.base} allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)]`,children:[u&&c&&w.jsx(mg,{thought:c}),w.jsx(dC,{hasThought:u})]})]})]}):r?w.jsxs("div",{className:"allm-py-[5px]",children:[w.jsx("div",{className:"allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mb-2 allm-text-left allm-font-sans",children:ae.settings.assistantName||"Anything LLM Chat Assistant"}),w.jsxs("div",{className:"allm-flex allm-items-start allm-w-full allm-h-fit allm-justify-start",children:[w.jsx("img",{src:ae.settings.assistantIcon||qo,alt:"Anything LLM Icon",className:"allm-w-9 allm-h-9 allm-flex-shrink-0 allm-ml-2"}),w.jsx("div",{className:"allm-py-[11px] allm-px-4 allm-rounded-lg allm-flex allm-flex-col allm-bg-red-200 allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)] allm-mr-[37px] allm-ml-[9px]",children:w.jsx("div",{className:"allm-flex allm-gap-x-5",children:w.jsxs("span",{className:"allm-inline-block allm-p-2 allm-rounded-lg allm-bg-red-50 allm-text-red-500",children:[w.jsx(Du,{className:"allm-h-4 allm-w-4 allm-mb-1 allm-inline-block"})," ","Could not respond to message.",w.jsx("span",{className:"allm-text-xs",children:"Server error"})]})})})]})]}):w.jsxs("div",{className:"allm-py-[5px]",children:[w.jsx("div",{className:"allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mb-2 allm-text-left allm-font-sans",children:ae.settings.assistantName||"Anything LLM Chat Assistant"}),w.jsxs("div",{ref:s,className:"allm-flex allm-items-start allm-w-full allm-h-fit allm-justify-start",children:[w.jsx("img",{src:ae.settings.assistantIcon||qo,alt:"Anything LLM Icon",className:"allm-w-9 allm-h-9 allm-flex-shrink-0 allm-ml-2"}),w.jsxs("div",{style:{wordBreak:"break-word",backgroundColor:ae.ASSISTANT_STYLES.msgBg},className:`allm-py-[11px] allm-px-4 allm-flex allm-flex-col ${ae.ASSISTANT_STYLES.base} allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)]`,children:[l.length>0&&w.jsx(mg,{thought:l.join("\n\n")}),w.jsx("div",{className:"allm-flex allm-gap-x-5",children:w.jsx("span",{className:"allm-font-sans allm-reply allm-whitespace-pre-line allm-font-normal allm-text-sm allm-md:text-sm allm-flex allm-flex-col allm-gap-y-1",dangerouslySetInnerHTML:{__html:Jm(d||"")}})})]})]},t),a&&w.jsx("div",{className:"allm-text-[10px] allm-text-gray-400 allm-ml-[54px] allm-mr-6 allm-mt-2 allm-text-left allm-font-sans",children:fg(a)})]})})),fC=U.memo(pC);var gg=NaN,gC="[object Symbol]",hC=/^\s+|\s+$/g,EC=/^[-+]0x[0-9a-f]+$/i,bC=/^0b[01]+$/i,AC=/^0o[0-7]+$/i,_C=parseInt,vC="object"==typeof xe&&xe&&xe.Object===Object&&xe,yC="object"==typeof self&&self&&self.Object===Object&&self,DC=vC||yC||Function("return this")(),SC=Object.prototype.toString,NC=Math.max,TC=Math.min,Ec=function(){return DC.Date.now()};function bc(t){var e=typeof t;return!!t&&("object"==e||"function"==e)}function hg(t){if("number"==typeof t)return t;if(function(t){return"symbol"==typeof t||function(t){return!!t&&"object"==typeof t}(t)&&SC.call(t)==gC}(t))return gg;if(bc(t)){var e="function"==typeof t.valueOf?t.valueOf():t;t=bc(e)?e+"":e}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(hC,"");var n=bC.test(t);return n||AC.test(t)?_C(t.slice(2),n?2:8):EC.test(t)?gg:+t}var LC=function(t,e,n){var r,o,a,s,i,l,u=0,c=!1,p=!1,d=!0;if("function"!=typeof t)throw new TypeError("Expected a function");function f(v){var T=r,R=o;return r=o=void 0,u=v,s=t.apply(R,T)}function y(v){var T=v-l;return void 0===l||T>=e||T<0||p&&v-u>=a}function h(){var v=Ec();if(y(v))return g(v);i=setTimeout(h,function(v){var H=e-(v-l);return p?TC(H,a-(v-u)):H}(v))}function g(v){return i=void 0,d&&r?f(v):(r=o=void 0,s)}function N(){var v=Ec(),T=y(v);if(r=arguments,o=this,l=v,T){if(void 0===i)return function(v){return u=v,i=setTimeout(h,e),c?f(v):s}(l);if(p)return i=setTimeout(h,e),f(l)}return void 0===i&&(i=setTimeout(h,e)),s}return e=hg(e)||0,bc(n)&&(c=!!n.leading,a=(p="maxWait"in n)?NC(hg(n.maxWait)||0,e):a,d="trailing"in n?!!n.trailing:d),N.cancel=function(){void 0!==i&&clearTimeout(i),u=0,r=l=o=i=void 0},N.flush=function(){return void 0===i?s:g(Ec())},N};const OC=Ht(LC);function kC({settings:t={},history:e=[]}){const n=U.useRef(null),[r,o]=U.useState(!0),a=U.useRef(null);U.useEffect((()=>{l()}),[e]);const i=OC((()=>{if(!a.current)return;const c=a.current.scrollHeight-a.current.scrollTop-a.current.clientHeight<=40;o(c)}),100);U.useEffect((()=>{!function(){if(!a.current)return null;const c=a.current;if(!c)return null;c.addEventListener("scroll",i)}()}),[]);const l=()=>{a.current&&a.current.scrollTo({top:a.current.scrollHeight,behavior:"smooth"})};return 0===e.length?w.jsx("div",{className:"allm-h-full allm-overflow-y-auto allm-px-2 allm-py-4 allm-flex allm-flex-col allm-justify-start allm-no-scroll",children:w.jsxs("div",{className:"allm-flex allm-h-full allm-flex-col allm-items-center allm-justify-center",children:[w.jsx("p",{className:"allm-text-slate-400 allm-text-sm allm-font-sans allm-py-4 allm-text-center",children:(null==t?void 0:t.greeting)??"Send a chat to get started."}),w.jsx(MC,{settings:t})]})}):w.jsxs("div",{className:"allm-h-full allm-overflow-y-auto allm-px-2 allm-pt-4 allm-pb-8 allm-flex allm-flex-col allm-justify-start allm-no-scroll",id:"chat-history",ref:a,children:[w.jsx("div",{className:"allm-flex allm-flex-col allm-gap-y-4",children:e.map(((u,c)=>{const p=c===e.length-1;return c===e.length-1&&"assistant"===u.role&&u.animate?w.jsx(fC,{ref:p?n:null,uuid:u.uuid,reply:u.content,pending:u.pending,sources:u.sources,error:u.error,closed:u.closed},u.uuid):w.jsx(cC,{ref:p?n:null,message:u.content,sentAt:u.sentAt||Date.now()/1e3,role:u.role,sources:u.sources,chatId:u.chatId,feedbackScore:u.feedbackScore,error:u.error,errorMsg:u.errorMsg},c)}))}),!r&&w.jsx("div",{className:"allm-fixed allm-bottom-[10rem] allm-right-[50px] allm-z-50 allm-cursor-pointer allm-animate-pulse",children:w.jsx("div",{className:"allm-flex allm-flex-col allm-items-center",children:w.jsx("div",{className:"allm-rounded-full allm-border allm-border-white/10 allm-bg-black/20 hover:allm-bg-black/50 allm-w-8 allm-h-8 allm-flex allm-items-center allm-justify-center",children:w.jsx(lf,{weight:"bold",className:"allm-text-white/50 allm-w-4 allm-h-4",onClick:l,id:"scroll-to-bottom-button","aria-label":"Scroll to bottom"})})})})]})}function IC(){return w.jsx("div",{className:"allm-h-full allm-w-full allm-relative",children:w.jsx("div",{className:"allm-h-full allm-max-h-[82vh] allm-pb-[100px] allm-pt-[5px] allm-bg-gray-100 allm-rounded-lg allm-px-2 allm-h-full allm-mt-2 allm-gap-y-2 allm-overflow-y-scroll allm-flex allm-flex-col allm-justify-start allm-no-scroll",children:w.jsx("div",{className:"allm-flex allm-h-full allm-flex-col allm-items-center allm-justify-center",children:w.jsx(Cs,{size:14,className:"allm-text-slate-400 allm-animate-spin"})})})})}function MC({settings:t}){var e;return null!=(e=null==t?void 0:t.defaultMessages)&&e.length?w.jsx("div",{className:"allm-flex allm-flex-col allm-gap-y-2 allm-w-[75%]",children:t.defaultMessages.map(((n,r)=>w.jsx("button",{style:{opacity:0,wordBreak:"break-word",backgroundColor:ae.USER_STYLES.msgBg,fontSize:t.textSize},type:"button",onClick:()=>{window.dispatchEvent(new CustomEvent(Ac,{detail:{command:n}}))},className:"msg-suggestion allm-border-none hover:allm-shadow-[0_4px_14px_rgba(0,0,0,0.5)] allm-cursor-pointer allm-px-2 allm-py-2 allm-rounded-lg allm-text-white allm-w-full allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)]",children:n},r)))}):null}function FC({settings:t,message:e,submit:n,onChange:r,inputDisabled:o,buttonDisabled:a}){const{t:s}=Ho(),i=U.useRef(null),l=U.useRef(null),[u,c]=U.useState(!1);U.useEffect((()=>{!o&&l.current&&l.current.focus(),d()}),[o]);const d=()=>{l.current&&(l.current.style.height="auto")},b=A=>{const y=A.target;y.style.height="auto",y.style.height=0!==A.target.value.length?y.scrollHeight+"px":"auto"};return w.jsx("div",{className:"allm-w-full allm-sticky allm-bottom-0 allm-z-10 allm-flex allm-justify-center allm-items-center allm-bg-white",children:w.jsx("form",{onSubmit:A=>{c(!1),n(A)},className:"allm-flex allm-flex-col allm-gap-y-1 allm-rounded-t-lg allm-w-full allm-items-center allm-justify-center",children:w.jsx("div",{className:"allm-flex allm-items-center allm-w-full",children:w.jsx("div",{className:"allm-bg-white allm-flex allm-flex-col allm-px-4 allm-overflow-hidden allm-w-full",children:w.jsxs("div",{style:{border:"1.5px solid #22262833"},className:"allm-flex allm-items-center allm-w-full allm-rounded-2xl",children:[w.jsx("textarea",{ref:l,rows:1,onKeyUp:b,onKeyDown:A=>{13==A.keyCode&&(A.shiftKey||n(A))},onChange:r,required:!0,disabled:o,onFocus:()=>c(!0),onBlur:A=>{c(!1),b(A)},value:e,className:"allm-box-border allm-font-sans allm-border-none allm-cursor-text allm-max-h-[100px] allm-text-[14px] allm-leading-5 allm-mx-2 allm-py-2 allm-w-full allm-text-black allm-bg-transparent placeholder:allm-text-slate-800/60 allm-resize-none active:allm-outline-none focus:allm-outline-none allm-flex-grow",placeholder:t.sendMessageText||s("chat.send-message"),id:"message-input"}),w.jsxs("button",{ref:i,type:"submit",disabled:a,className:"allm-bg-transparent allm-border-none allm-inline-flex allm-justify-center allm-rounded-2xl allm-cursor-pointer allm-text-black group",id:"send-message-button","aria-label":"Send message",children:[a?w.jsx(Cs,{className:"allm-w-4 allm-h-4 allm-animate-spin"}):w.jsx(Vf,{size:24,className:"allm-my-3 allm-text-[#22262899]/60 group-hover:allm-text-[#22262899]/90",weight:"fill"}),w.jsx("span",{className:"allm-sr-only",children:"Send message"})]})]})})})})})}const Ac="anythingllm-embed-send-prompt";function PC({sessionId:t,settings:e,knownHistory:n=[]}){const[r,o]=U.useState(""),[a,s]=U.useState(!1),[i,l]=U.useState(n);U.useEffect((()=>{n.length!==i.length&&l([...n])}),[n]);U.useEffect((()=>{!0===a&&async function(){const b=i.length>0?i[i.length-1]:null,A=i.length>0?i.slice(0,-1):[];var y=[...A];if(!b||null==b||!b.userMessage)return s(!1),!1;await Ss.streamChat(t,e,b.userMessage,(h=>function(t,e,n,r,o){const{uuid:a,textResponse:s,type:i,sources:l=[],error:u,close:c,errorMsg:p=null}=t,d=o[o.length-1],f=null==d?void 0:d.sentAt;if("abort"===i)e(!1),n([...r,{uuid:a,content:s,role:"assistant",sources:l,closed:!0,error:u,errorMsg:p,animate:!1,pending:!1,sentAt:f}]),o.push({uuid:a,content:s,role:"assistant",sources:l,closed:!0,error:u,errorMsg:p,animate:!1,pending:!1,sentAt:f});else if("textResponse"===i)e(!1),n([...r,{uuid:a,content:s,role:"assistant",sources:l,closed:c,error:u,errorMsg:p,animate:!c,pending:!1,sentAt:f}]),o.push({uuid:a,content:s,role:"assistant",sources:l,closed:c,error:u,errorMsg:p,animate:!c,pending:!1,sentAt:f});else if("textResponseChunk"===i){const b=o.findIndex((A=>A.uuid===a));if(-1!==b){const A={...o[b]},y={...A,content:A.content+s,sources:l,error:u,errorMsg:p,closed:c,animate:!c,pending:!1,sentAt:f};o[b]=y}else o.push({uuid:a,sources:l,error:u,errorMsg:p,content:s,role:"assistant",closed:c,animate:!c,pending:!1,sentAt:f});n([...o])}}(h,s,l,A,y)))}()}),[a,i]);const d=f=>{f.detail.command&&((f,b=[],A=[])=>{if(!f||""===f)return!1;let y;y=b.length>0?[...b,{content:"",role:"assistant",pending:!0,userMessage:f,attachments:A,animate:!0}]:[...i,{content:f,role:"user",attachments:A},{content:"",role:"assistant",pending:!0,userMessage:f,animate:!0}],l(y),s(!0)})(f.detail.command,[],[])};return U.useEffect((()=>(window.addEventListener(Ac,d),()=>{window.removeEventListener(Ac,d)})),[]),w.jsxs("div",{className:"allm-h-full allm-w-full allm-flex allm-flex-col",children:[w.jsx("div",{className:"allm-flex-1 allm-min-h-0 allm-mb-8",children:w.jsx(kC,{settings:e,history:i})}),w.jsx("div",{className:"allm-flex-shrink-0 allm-mt-auto",children:w.jsx(FC,{settings:e,message:r,submit:async f=>{if(f.preventDefault(),!r||""===r)return!1;const b=[...i,{content:r,role:"user",sentAt:Math.floor(Date.now()/1e3)},{content:"",role:"assistant",pending:!0,userMessage:r,animate:!0,sentAt:Math.floor(Date.now()/1e3)}];l(b),o(""),s(!0)},onChange:f=>{o(f.target.value)},inputDisabled:a,buttonDisabled:a})})]})}function Eg({settings:t}){return t.noSponsor?null:w.jsx("div",{className:"allm-flex allm-w-full allm-items-center allm-justify-center",children:w.jsx("a",{style:{color:"#0119D9"},href:t.sponsorLink??"#",target:"_blank",rel:"noreferrer",className:"allm-text-xs allm-font-sans hover:allm-opacity-80 hover:allm-underline",children:t.sponsorText})})}function UC({setChatHistory:t,settings:e,sessionId:n,closeChat:r}){const{t:o}=Ho();return w.jsxs("div",{className:"allm-w-full allm-flex allm-justify-center allm-gap-x-1 p-0",children:[w.jsx("button",{style:{color:"#7A7D7E"},className:"allm-h-fit allm-px-0 hover:allm-cursor-pointer allm-border-none allm-text-sm allm-bg-transparent hover:allm-opacity-80 hover:allm-underline",onClick:()=>(async()=>{await Ss.resetEmbedChatSession(e,n),t([])})(),children:e.resetChatText||o("chat.reset-chat")}),e.noHeader&&w.jsxs(w.Fragment,{children:[w.jsx("p",{className:"allm-m-0 allm-h-fit allm-text-sm allm-text-[#7A7D7E]",children:"|"}),w.jsx("button",{type:"button",style:{color:"#7A7D7E"},className:"allm-h-fit allm-px-0 hover:allm-cursor-pointer allm-border-none allm-text-sm allm-bg-transparent hover:allm-opacity-80 hover:allm-underline",onClick:r,children:o("chat.close-chat")})]})]})}function qC({closeChat:t,settings:e,sessionId:n}){const{chatHistory:r,setChatHistory:o,loading:a}=function(t=null,e=null){const[n,r]=U.useState(!0),[o,a]=U.useState([]);return U.useEffect((()=>{!async function(){if(e&&t)try{const i=await Ss.embedSessionHistory(t,e);a(i),r(!1)}catch(i){console.error("Error fetching historical chats:",i),r(!1)}}()}),[e,t]),{chatHistory:o,setChatHistory:a,loading:n}}(e,n);return a?w.jsxs("div",{className:"allm-flex allm-flex-col allm-h-full",children:[w.jsx(s2,{sessionId:n,settings:e,iconUrl:e.brandImageUrl,closeChat:t,setChatHistory:o}),w.jsx(IC,{}),w.jsxs("div",{className:"allm-pt-4 allm-pb-2 allm-h-fit allm-gap-y-1",children:[w.jsx(z_,{}),w.jsx(Eg,{settings:e})]})]}):(null==document||document.addEventListener("click",(function(t){var r;const e=t.target.closest("[data-code-snippet]"),n=null==(r=null==e?void 0:e.dataset)?void 0:r.code;if(!n)return!1;!function(t){var o,a,s;const e=document.querySelector(`[data-code="${t}"]`);if(!e)return!1;const n=null==(s=null==(a=null==(o=e.parentElement)?void 0:o.parentElement)?void 0:a.querySelector("pre:first-of-type"))?void 0:s.innerText;if(!n)return!1;window.navigator.clipboard.writeText(n),e.classList.add("allm-text-green-500");const r=e.innerHTML;e.innerText="Copied!",e.setAttribute("disabled",!0),setTimeout((()=>{e.classList.remove("allm-text-green-500"),e.innerHTML=r,e.removeAttribute("disabled")}),2500)}(n)})),w.jsxs("div",{className:"allm-flex allm-flex-col allm-h-full",children:[!e.noHeader&&w.jsx(s2,{sessionId:n,settings:e,iconUrl:e.brandImageUrl,closeChat:t,setChatHistory:o}),w.jsx("div",{className:"allm-flex-grow allm-overflow-y-auto",children:w.jsx(PC,{sessionId:n,settings:e,knownHistory:r})}),w.jsxs("div",{className:"allm-mt-4 allm-pb-4 allm-h-fit allm-gap-y-2 allm-z-10",children:[w.jsx(Eg,{settings:e}),w.jsx(UC,{setChatHistory:o,settings:e,sessionId:n,closeChat:t})]})]}))}const Y=t=>"string"==typeof t,Xo=()=>{let t,e;const n=new Promise(((r,o)=>{t=r,e=o}));return n.resolve=t,n.reject=e,n},bg=t=>null==t?"":""+t,zC=/###/g,Ag=t=>t&&t.indexOf("###")>-1?t.replace(zC,"."):t,_g=t=>!t||Y(t),Qo=(t,e,n)=>{const r=Y(e)?e.split("."):e;let o=0;for(;o{const{obj:r,k:o}=Qo(t,e,Object);if(void 0!==r||1===e.length)return void(r[o]=n);let a=e[e.length-1],s=e.slice(0,e.length-1),i=Qo(t,s,Object);for(;void 0===i.obj&&s.length;)a=`${s[s.length-1]}.${a}`,s=s.slice(0,s.length-1),i=Qo(t,s,Object),i&&i.obj&&typeof i.obj[`${i.k}.${a}`]<"u"&&(i.obj=void 0);i.obj[`${i.k}.${a}`]=n},ti=(t,e)=>{const{obj:n,k:r}=Qo(t,e);if(n)return n[r]},yg=(t,e,n)=>{for(const r in e)"__proto__"!==r&&"constructor"!==r&&(r in t?Y(t[r])||t[r]instanceof String||Y(e[r])||e[r]instanceof String?n&&(t[r]=e[r]):yg(t[r],e[r],n):t[r]=e[r]);return t},$r=t=>t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&");var jC={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};const WC=t=>Y(t)?t.replace(/[&<>"'\/]/g,(e=>jC[e])):t;const YC=[" ",",","?","!",";"],XC=new class{constructor(e){this.capacity=e,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(e){const n=this.regExpMap.get(e);if(void 0!==n)return n;const r=new RegExp(e);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(e,r),this.regExpQueue.push(e),r}}(20),_c=function(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:".";if(!t)return;if(t[e])return t[e];const r=e.split(n);let o=t;for(let a=0;a-1&&lt&&t.replace("_","-"),JC={type:"logger",log(t){this.output("log",t)},warn(t){this.output("warn",t)},error(t){this.output("error",t)},output(t,e){console&&console[t]&&console[t].apply(console,e)}};class ri{constructor(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};this.init(e,n)}init(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};this.prefix=n.prefix||"i18next:",this.logger=e||JC,this.options=n,this.debug=n.debug}log(){for(var e=arguments.length,n=new Array(e),r=0;r{this.observers[r]||(this.observers[r]=new Map);const o=this.observers[r].get(n)||0;this.observers[r].set(n,o+1)})),this}off(e,n){if(this.observers[e]){if(!n)return void delete this.observers[e];this.observers[e].delete(n)}}emit(e){for(var n=arguments.length,r=new Array(n>1?n-1:0),o=1;o{let[i,l]=s;for(let u=0;u{let[i,l]=s;for(let u=0;u1&&void 0!==arguments[1]?arguments[1]:{ns:["translation"],defaultNS:"translation"};super(),this.data=e||{},this.options=n,void 0===this.options.keySeparator&&(this.options.keySeparator="."),void 0===this.options.ignoreJSONStructure&&(this.options.ignoreJSONStructure=!0)}addNamespaces(e){this.options.ns.indexOf(e)<0&&this.options.ns.push(e)}removeNamespaces(e){const n=this.options.ns.indexOf(e);n>-1&&this.options.ns.splice(n,1)}getResource(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const a=void 0!==o.keySeparator?o.keySeparator:this.options.keySeparator,s=void 0!==o.ignoreJSONStructure?o.ignoreJSONStructure:this.options.ignoreJSONStructure;let i;e.indexOf(".")>-1?i=e.split("."):(i=[e,n],r&&(Array.isArray(r)?i.push(...r):Y(r)&&a?i.push(...r.split(a)):i.push(r)));const l=ti(this.data,i);return!l&&!n&&!r&&e.indexOf(".")>-1&&(e=i[0],n=i[1],r=i.slice(2).join(".")),!l&&s&&Y(r)?_c(this.data&&this.data[e]&&this.data[e][n],r,a):l}addResource(e,n,r,o){let a=arguments.length>4&&void 0!==arguments[4]?arguments[4]:{silent:!1};const s=void 0!==a.keySeparator?a.keySeparator:this.options.keySeparator;let i=[e,n];r&&(i=i.concat(s?r.split(s):r)),e.indexOf(".")>-1&&(i=e.split("."),o=n,n=i[1]),this.addNamespaces(n),vg(this.data,i,o),a.silent||this.emit("added",e,n,r,o)}addResources(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{silent:!1};for(const a in r)(Y(r[a])||Array.isArray(r[a]))&&this.addResource(e,n,a,r[a],{silent:!0});o.silent||this.emit("added",e,n,r)}addResourceBundle(e,n,r,o,a){let s=arguments.length>5&&void 0!==arguments[5]?arguments[5]:{silent:!1,skipCopy:!1},i=[e,n];e.indexOf(".")>-1&&(i=e.split("."),o=r,r=n,n=i[1]),this.addNamespaces(n);let l=ti(this.data,i)||{};s.skipCopy||(r=JSON.parse(JSON.stringify(r))),o?yg(l,r,a):l={...l,...r},vg(this.data,i,l),s.silent||this.emit("added",e,n,r)}removeResourceBundle(e,n){this.hasResourceBundle(e,n)&&delete this.data[e][n],this.removeNamespaces(n),this.emit("removed",e,n)}hasResourceBundle(e,n){return void 0!==this.getResource(e,n)}getResourceBundle(e,n){return n||(n=this.options.defaultNS),"v1"===this.options.compatibilityAPI?{...this.getResource(e,n)}:this.getResource(e,n)}getDataByLanguage(e){return this.data[e]}hasLanguageSomeTranslations(e){const n=this.getDataByLanguage(e);return!!(n&&Object.keys(n)||[]).find((o=>n[o]&&Object.keys(n[o]).length>0))}toJSON(){return this.data}}var Cg={processors:{},addPostProcessor(t){this.processors[t.name]=t},handle(t,e,n,r,o){return t.forEach((a=>{this.processors[a]&&(e=this.processors[a].process(e,n,r,o))})),e}};const Sg={};class ai extends oi{constructor(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};super(),((t,e,n)=>{t.forEach((r=>{e[r]&&(n[r]=e[r])}))})(["resourceStore","languageUtils","pluralResolver","interpolator","backendConnector","i18nFormat","utils"],e,this),this.options=n,void 0===this.options.keySeparator&&(this.options.keySeparator="."),this.logger=Xt.create("translator")}changeLanguage(e){e&&(this.language=e)}exists(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{interpolation:{}};if(null==e)return!1;const r=this.resolve(e,n);return r&&void 0!==r.res}extractFromKey(e,n){let r=void 0!==n.nsSeparator?n.nsSeparator:this.options.nsSeparator;void 0===r&&(r=":");const o=void 0!==n.keySeparator?n.keySeparator:this.options.keySeparator;let a=n.ns||this.options.defaultNS||[];const s=r&&e.indexOf(r)>-1,i=!(this.options.userDefinedKeySeparator||n.keySeparator||this.options.userDefinedNsSeparator||n.nsSeparator||((t,e,n)=>{e=e||"",n=n||"";const r=YC.filter((s=>e.indexOf(s)<0&&n.indexOf(s)<0));if(0===r.length)return!0;const o=XC.getRegExp(`(${r.map((s=>"?"===s?"\\?":s)).join("|")})`);let a=!o.test(t);if(!a){const s=t.indexOf(n);s>0&&!o.test(t.substring(0,s))&&(a=!0)}return a})(e,r,o));if(s&&!i){const l=e.match(this.interpolator.nestingRegexp);if(l&&l.length>0)return{key:e,namespaces:Y(a)?[a]:a};const u=e.split(r);(r!==o||r===o&&this.options.ns.indexOf(u[0])>-1)&&(a=u.shift()),e=u.join(o)}return{key:e,namespaces:Y(a)?[a]:a}}translate(e,n,r){if("object"!=typeof n&&this.options.overloadTranslationOptionHandler&&(n=this.options.overloadTranslationOptionHandler(arguments)),"object"==typeof n&&(n={...n}),n||(n={}),null==e)return"";Array.isArray(e)||(e=[String(e)]);const o=void 0!==n.returnDetails?n.returnDetails:this.options.returnDetails,a=void 0!==n.keySeparator?n.keySeparator:this.options.keySeparator,{key:s,namespaces:i}=this.extractFromKey(e[e.length-1],n),l=i[i.length-1],u=n.lng||this.language,c=n.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if(u&&"cimode"===u.toLowerCase()){if(c){const _=n.nsSeparator||this.options.nsSeparator;return o?{res:`${l}${_}${s}`,usedKey:s,exactUsedKey:s,usedLng:u,usedNS:l,usedParams:this.getUsedParamsDetails(n)}:`${l}${_}${s}`}return o?{res:s,usedKey:s,exactUsedKey:s,usedLng:u,usedNS:l,usedParams:this.getUsedParamsDetails(n)}:s}const p=this.resolve(e,n);let d=p&&p.res;const f=p&&p.usedKey||s,b=p&&p.exactUsedKey||s,A=Object.prototype.toString.apply(d),h=void 0!==n.joinArrays?n.joinArrays:this.options.joinArrays,g=!this.i18nFormat||this.i18nFormat.handleAsObject,E=!Y(d)&&"boolean"!=typeof d&&"number"!=typeof d;if(!(g&&d&&E&&["[object Number]","[object Function]","[object RegExp]"].indexOf(A)<0)||Y(h)&&Array.isArray(d))if(g&&Y(h)&&Array.isArray(d))d=d.join(h),d&&(d=this.extendTranslation(d,e,n,r));else{let _=!1,N=!1;const v=void 0!==n.count&&!Y(n.count),T=ai.hasDefaultValue(n),R=v?this.pluralResolver.getSuffix(u,n.count,n):"",H=n.ordinal&&v?this.pluralResolver.getSuffix(u,n.count,{ordinal:!1}):"",I=v&&!n.ordinal&&0===n.count&&this.pluralResolver.shouldUseIntlApi(),W=I&&n[`defaultValue${this.options.pluralSeparator}zero`]||n[`defaultValue${R}`]||n[`defaultValue${H}`]||n.defaultValue;!this.isValidLookup(d)&&T&&(_=!0,d=W),this.isValidLookup(d)||(N=!0,d=s);const K=(n.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&N?void 0:d,O=T&&W!==d&&this.options.updateMissing;if(N||_||O){if(this.logger.log(O?"updateKey":"missingKey",u,l,s,O?W:d),a){const S=this.resolve(s,{...n,keySeparator:!1});S&&S.res&&this.logger.warn("Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.")}let V=[];const B=this.languageUtils.getFallbackCodes(this.options.fallbackLng,n.lng||this.language);if("fallback"===this.options.saveMissingTo&&B&&B[0])for(let S=0;S{const k=T&&L!==d?L:K;this.options.missingKeyHandler?this.options.missingKeyHandler(S,l,C,k,O,n):this.backendConnector&&this.backendConnector.saveMissing&&this.backendConnector.saveMissing(S,l,C,k,O,n),this.emit("missingKey",S,l,C,d)};this.options.saveMissing&&(this.options.saveMissingPlurals&&v?V.forEach((S=>{const C=this.pluralResolver.getSuffixes(S,n);I&&n[`defaultValue${this.options.pluralSeparator}zero`]&&C.indexOf(`${this.options.pluralSeparator}zero`)<0&&C.push(`${this.options.pluralSeparator}zero`),C.forEach((L=>{x([S],s+L,n[`defaultValue${L}`]||W)}))})):x(V,s,W))}d=this.extendTranslation(d,e,n,p,r),N&&d===s&&this.options.appendNamespaceToMissingKey&&(d=`${l}:${s}`),(N||_)&&this.options.parseMissingKeyHandler&&(d="v1"!==this.options.compatibilityAPI?this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${l}:${s}`:s,_?d:void 0):this.options.parseMissingKeyHandler(d))}else{if(!n.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn("accessing an object - but returnObjects options is not enabled!");const _=this.options.returnedObjectHandler?this.options.returnedObjectHandler(f,d,{...n,ns:i}):`key '${s} (${this.language})' returned an object instead of string.`;return o?(p.res=_,p.usedParams=this.getUsedParamsDetails(n),p):_}if(a){const _=Array.isArray(d),N=_?[]:{},v=_?b:f;for(const T in d)if(Object.prototype.hasOwnProperty.call(d,T)){const R=`${v}${a}${T}`;N[T]=this.translate(R,{...n,joinArrays:!1,ns:i}),N[T]===R&&(N[T]=d[T])}d=N}}return o?(p.res=d,p.usedParams=this.getUsedParamsDetails(n),p):d}extendTranslation(e,n,r,o,a){var s=this;if(this.i18nFormat&&this.i18nFormat.parse)e=this.i18nFormat.parse(e,{...this.options.interpolation.defaultVariables,...r},r.lng||this.language||o.usedLng,o.usedNS,o.usedKey,{resolved:o});else if(!r.skipInterpolation){r.interpolation&&this.interpolator.init({...r,interpolation:{...this.options.interpolation,...r.interpolation}});const u=Y(e)&&(r&&r.interpolation&&void 0!==r.interpolation.skipOnVariables?r.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let c;if(u){const d=e.match(this.interpolator.nestingRegexp);c=d&&d.length}let p=r.replace&&!Y(r.replace)?r.replace:r;if(this.options.interpolation.defaultVariables&&(p={...this.options.interpolation.defaultVariables,...p}),e=this.interpolator.interpolate(e,p,r.lng||this.language||o.usedLng,r),u){const d=e.match(this.interpolator.nestingRegexp);c<(d&&d.length)&&(r.nest=!1)}!r.lng&&"v1"!==this.options.compatibilityAPI&&o&&o.res&&(r.lng=this.language||o.usedLng),!1!==r.nest&&(e=this.interpolator.nest(e,(function(){for(var d=arguments.length,f=new Array(d),b=0;b1&&void 0!==arguments[1]?arguments[1]:{};return Y(e)&&(e=[e]),e.forEach((l=>{if(this.isValidLookup(r))return;const u=this.extractFromKey(l,n),c=u.key;o=c;let p=u.namespaces;this.options.fallbackNS&&(p=p.concat(this.options.fallbackNS));const d=void 0!==n.count&&!Y(n.count),f=d&&!n.ordinal&&0===n.count&&this.pluralResolver.shouldUseIntlApi(),b=void 0!==n.context&&(Y(n.context)||"number"==typeof n.context)&&""!==n.context,A=n.lngs?n.lngs:this.languageUtils.toResolveHierarchy(n.lng||this.language,n.fallbackLng);p.forEach((y=>{this.isValidLookup(r)||(i=y,!Sg[`${A[0]}-${y}`]&&this.utils&&this.utils.hasLoadedNamespace&&!this.utils.hasLoadedNamespace(i)&&(Sg[`${A[0]}-${y}`]=!0,this.logger.warn(`key "${o}" for languages "${A.join(", ")}" won't get resolved as namespace "${i}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!")),A.forEach((h=>{if(this.isValidLookup(r))return;s=h;const g=[c];if(this.i18nFormat&&this.i18nFormat.addLookupKeys)this.i18nFormat.addLookupKeys(g,c,h,y,n);else{let _;d&&(_=this.pluralResolver.getSuffix(h,n.count,n));const N=`${this.options.pluralSeparator}zero`,v=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(d&&(g.push(c+_),n.ordinal&&0===_.indexOf(v)&&g.push(c+_.replace(v,this.options.pluralSeparator)),f&&g.push(c+N)),b){const T=`${c}${this.options.contextSeparator}${n.context}`;g.push(T),d&&(g.push(T+_),n.ordinal&&0===_.indexOf(v)&&g.push(T+_.replace(v,this.options.pluralSeparator)),f&&g.push(T+N))}}let E;for(;E=g.pop();)this.isValidLookup(r)||(a=E,r=this.getResource(h,y,E,n))})))}))})),{res:r,usedKey:o,exactUsedKey:a,usedLng:s,usedNS:i}}isValidLookup(e){return!(void 0===e||!this.options.returnNull&&null===e||!this.options.returnEmptyString&&""===e)}getResource(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};return this.i18nFormat&&this.i18nFormat.getResource?this.i18nFormat.getResource(e,n,r,o):this.resourceStore.getResource(e,n,r,o)}getUsedParamsDetails(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const n=["defaultValue","ordinal","context","replace","lng","lngs","fallbackLng","ns","keySeparator","nsSeparator","returnObjects","returnDetails","joinArrays","postProcess","interpolation"],r=e.replace&&!Y(e.replace);let o=r?e.replace:e;if(r&&typeof e.count<"u"&&(o.count=e.count),this.options.interpolation.defaultVariables&&(o={...this.options.interpolation.defaultVariables,...o}),!r){o={...o};for(const a of n)delete o[a]}return o}static hasDefaultValue(e){const n="defaultValue";for(const r in e)if(Object.prototype.hasOwnProperty.call(e,r)&&n===r.substring(0,12)&&void 0!==e[r])return!0;return!1}}const vc=t=>t.charAt(0).toUpperCase()+t.slice(1);class Ng{constructor(e){this.options=e,this.supportedLngs=this.options.supportedLngs||!1,this.logger=Xt.create("languageUtils")}getScriptPartFromCode(e){if(!(e=ni(e))||e.indexOf("-")<0)return null;const n=e.split("-");return 2===n.length||(n.pop(),"x"===n[n.length-1].toLowerCase())?null:this.formatLanguageCode(n.join("-"))}getLanguagePartFromCode(e){if(!(e=ni(e))||e.indexOf("-")<0)return e;const n=e.split("-");return this.formatLanguageCode(n[0])}formatLanguageCode(e){if(Y(e)&&e.indexOf("-")>-1){if(typeof Intl<"u"&&typeof Intl.getCanonicalLocales<"u")try{let o=Intl.getCanonicalLocales(e)[0];if(o&&this.options.lowerCaseLng&&(o=o.toLowerCase()),o)return o}catch{}const n=["hans","hant","latn","cyrl","cans","mong","arab"];let r=e.split("-");return this.options.lowerCaseLng?r=r.map((o=>o.toLowerCase())):2===r.length?(r[0]=r[0].toLowerCase(),r[1]=r[1].toUpperCase(),n.indexOf(r[1].toLowerCase())>-1&&(r[1]=vc(r[1].toLowerCase()))):3===r.length&&(r[0]=r[0].toLowerCase(),2===r[1].length&&(r[1]=r[1].toUpperCase()),"sgn"!==r[0]&&2===r[2].length&&(r[2]=r[2].toUpperCase()),n.indexOf(r[1].toLowerCase())>-1&&(r[1]=vc(r[1].toLowerCase())),n.indexOf(r[2].toLowerCase())>-1&&(r[2]=vc(r[2].toLowerCase()))),r.join("-")}return this.options.cleanCode||this.options.lowerCaseLng?e.toLowerCase():e}isSupportedCode(e){return("languageOnly"===this.options.load||this.options.nonExplicitSupportedLngs)&&(e=this.getLanguagePartFromCode(e)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(e)>-1}getBestMatchFromCodes(e){if(!e)return null;let n;return e.forEach((r=>{if(n)return;const o=this.formatLanguageCode(r);(!this.options.supportedLngs||this.isSupportedCode(o))&&(n=o)})),!n&&this.options.supportedLngs&&e.forEach((r=>{if(n)return;const o=this.getLanguagePartFromCode(r);if(this.isSupportedCode(o))return n=o;n=this.options.supportedLngs.find((a=>a===o?a:a.indexOf("-")<0&&o.indexOf("-")<0||!(a.indexOf("-")>0&&o.indexOf("-")<0&&a.substring(0,a.indexOf("-"))===o||0===a.indexOf(o)&&o.length>1)?void 0:a))})),n||(n=this.getFallbackCodes(this.options.fallbackLng)[0]),n}getFallbackCodes(e,n){if(!e)return[];if("function"==typeof e&&(e=e(n)),Y(e)&&(e=[e]),Array.isArray(e))return e;if(!n)return e.default||[];let r=e[n];return r||(r=e[this.getScriptPartFromCode(n)]),r||(r=e[this.formatLanguageCode(n)]),r||(r=e[this.getLanguagePartFromCode(n)]),r||(r=e.default),r||[]}toResolveHierarchy(e,n){const r=this.getFallbackCodes(n||this.options.fallbackLng||[],e),o=[],a=s=>{s&&(this.isSupportedCode(s)?o.push(s):this.logger.warn(`rejecting language code not found in supportedLngs: ${s}`))};return Y(e)&&(e.indexOf("-")>-1||e.indexOf("_")>-1)?("languageOnly"!==this.options.load&&a(this.formatLanguageCode(e)),"languageOnly"!==this.options.load&&"currentOnly"!==this.options.load&&a(this.getScriptPartFromCode(e)),"currentOnly"!==this.options.load&&a(this.getLanguagePartFromCode(e))):Y(e)&&a(this.formatLanguageCode(e)),r.forEach((s=>{o.indexOf(s)<0&&a(this.formatLanguageCode(s))})),o}}let eS=[{lngs:["ach","ak","am","arn","br","fil","gun","ln","mfe","mg","mi","oc","pt","pt-BR","tg","tl","ti","tr","uz","wa"],nr:[1,2],fc:1},{lngs:["af","an","ast","az","bg","bn","ca","da","de","dev","el","en","eo","es","et","eu","fi","fo","fur","fy","gl","gu","ha","hi","hu","hy","ia","it","kk","kn","ku","lb","mai","ml","mn","mr","nah","nap","nb","ne","nl","nn","no","nso","pa","pap","pms","ps","pt-PT","rm","sco","se","si","so","son","sq","sv","sw","ta","te","tk","ur","yo"],nr:[1,2],fc:2},{lngs:["ay","bo","cgg","fa","ht","id","ja","jbo","ka","km","ko","ky","lo","ms","sah","su","th","tt","ug","vi","wo","zh"],nr:[1],fc:3},{lngs:["be","bs","cnr","dz","hr","ru","sr","uk"],nr:[1,2,5],fc:4},{lngs:["ar"],nr:[0,1,2,3,11,100],fc:5},{lngs:["cs","sk"],nr:[1,2,5],fc:6},{lngs:["csb","pl"],nr:[1,2,5],fc:7},{lngs:["cy"],nr:[1,2,3,8],fc:8},{lngs:["fr"],nr:[1,2],fc:9},{lngs:["ga"],nr:[1,2,3,7,11],fc:10},{lngs:["gd"],nr:[1,2,3,20],fc:11},{lngs:["is"],nr:[1,2],fc:12},{lngs:["jv"],nr:[0,1],fc:13},{lngs:["kw"],nr:[1,2,3,4],fc:14},{lngs:["lt"],nr:[1,2,10],fc:15},{lngs:["lv"],nr:[1,2,0],fc:16},{lngs:["mk"],nr:[1,2],fc:17},{lngs:["mnk"],nr:[0,1,2],fc:18},{lngs:["mt"],nr:[1,2,11,20],fc:19},{lngs:["or"],nr:[2,1],fc:2},{lngs:["ro"],nr:[1,2,20],fc:20},{lngs:["sl"],nr:[5,1,2,3],fc:21},{lngs:["he","iw"],nr:[1,2,20,21],fc:22}],tS={1:t=>+(t>1),2:t=>+(1!=t),3:t=>0,4:t=>t%10==1&&t%100!=11?0:t%10>=2&&t%10<=4&&(t%100<10||t%100>=20)?1:2,5:t=>0==t?0:1==t?1:2==t?2:t%100>=3&&t%100<=10?3:t%100>=11?4:5,6:t=>1==t?0:t>=2&&t<=4?1:2,7:t=>1==t?0:t%10>=2&&t%10<=4&&(t%100<10||t%100>=20)?1:2,8:t=>1==t?0:2==t?1:8!=t&&11!=t?2:3,9:t=>+(t>=2),10:t=>1==t?0:2==t?1:t<7?2:t<11?3:4,11:t=>1==t||11==t?0:2==t||12==t?1:t>2&&t<20?2:3,12:t=>+(t%10!=1||t%100==11),13:t=>+(0!==t),14:t=>1==t?0:2==t?1:3==t?2:3,15:t=>t%10==1&&t%100!=11?0:t%10>=2&&(t%100<10||t%100>=20)?1:2,16:t=>t%10==1&&t%100!=11?0:0!==t?1:2,17:t=>1==t||t%10==1&&t%100!=11?0:1,18:t=>0==t?0:1==t?1:2,19:t=>1==t?0:0==t||t%100>1&&t%100<11?1:t%100>10&&t%100<20?2:3,20:t=>1==t?0:0==t||t%100>0&&t%100<20?1:2,21:t=>t%100==1?1:t%100==2?2:t%100==3||t%100==4?3:0,22:t=>1==t?0:2==t?1:(t<0||t>10)&&t%10==0?2:3};const nS=["v1","v2","v3"],rS=["v4"],Tg={zero:0,one:1,two:2,few:3,many:4,other:5};class aS{constructor(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};this.languageUtils=e,this.options=n,this.logger=Xt.create("pluralResolver"),(!this.options.compatibilityJSON||rS.includes(this.options.compatibilityJSON))&&(typeof Intl>"u"||!Intl.PluralRules)&&(this.options.compatibilityJSON="v3",this.logger.error("Your environment seems not to be Intl API compatible, use an Intl.PluralRules polyfill. Will fallback to the compatibilityJSON v3 format handling.")),this.rules=(()=>{const t={};return eS.forEach((e=>{e.lngs.forEach((n=>{t[n]={numbers:e.nr,plurals:tS[e.fc]}}))})),t})(),this.pluralRulesCache={}}addRule(e,n){this.rules[e]=n}clearCache(){this.pluralRulesCache={}}getRule(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(this.shouldUseIntlApi()){const r=ni("dev"===e?"en":e),o=n.ordinal?"ordinal":"cardinal",a=JSON.stringify({cleanedCode:r,type:o});if(a in this.pluralRulesCache)return this.pluralRulesCache[a];let s;try{s=new Intl.PluralRules(r,{type:o})}catch{if(!e.match(/-|_/))return;const l=this.languageUtils.getLanguagePartFromCode(e);s=this.getRule(l,n)}return this.pluralRulesCache[a]=s,s}return this.rules[e]||this.rules[this.languageUtils.getLanguagePartFromCode(e)]}needsPlural(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const r=this.getRule(e,n);return this.shouldUseIntlApi()?r&&r.resolvedOptions().pluralCategories.length>1:r&&r.numbers.length>1}getPluralFormsOfKey(e,n){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this.getSuffixes(e,r).map((o=>`${n}${o}`))}getSuffixes(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const r=this.getRule(e,n);return r?this.shouldUseIntlApi()?r.resolvedOptions().pluralCategories.sort(((o,a)=>Tg[o]-Tg[a])).map((o=>`${this.options.prepend}${n.ordinal?`ordinal${this.options.prepend}`:""}${o}`)):r.numbers.map((o=>this.getSuffix(e,o,n))):[]}getSuffix(e,n){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const o=this.getRule(e,r);return o?this.shouldUseIntlApi()?`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:""}${o.select(n)}`:this.getSuffixRetroCompatible(o,n):(this.logger.warn(`no plural rule found for: ${e}`),"")}getSuffixRetroCompatible(e,n){const r=e.noAbs?e.plurals(n):e.plurals(Math.abs(n));let o=e.numbers[r];this.options.simplifyPluralSuffix&&2===e.numbers.length&&1===e.numbers[0]&&(2===o?o="plural":1===o&&(o=""));const a=()=>this.options.prepend&&o.toString()?this.options.prepend+o.toString():o.toString();return"v1"===this.options.compatibilityJSON?1===o?"":"number"==typeof o?`_plural_${o.toString()}`:a():"v2"===this.options.compatibilityJSON||this.options.simplifyPluralSuffix&&2===e.numbers.length&&1===e.numbers[0]?a():this.options.prepend&&r.toString()?this.options.prepend+r.toString():r.toString()}shouldUseIntlApi(){return!nS.includes(this.options.compatibilityJSON)}}const wg=function(t,e,n){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:".",o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=((t,e,n)=>{const r=ti(t,n);return void 0!==r?r:ti(e,n)})(t,e,n);return!a&&o&&Y(n)&&(a=_c(t,n,r),void 0===a&&(a=_c(e,n,r))),a},yc=t=>t.replace(/\$/g,"$$$$");class sS{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.logger=Xt.create("interpolator"),this.options=e,this.format=e.interpolation&&e.interpolation.format||(n=>n),this.init(e)}init(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};e.interpolation||(e.interpolation={escapeValue:!0});const{escape:n,escapeValue:r,useRawValueToEscape:o,prefix:a,prefixEscaped:s,suffix:i,suffixEscaped:l,formatSeparator:u,unescapeSuffix:c,unescapePrefix:p,nestingPrefix:d,nestingPrefixEscaped:f,nestingSuffix:b,nestingSuffixEscaped:A,nestingOptionsSeparator:y,maxReplaces:h,alwaysFormat:g}=e.interpolation;this.escape=void 0!==n?n:WC,this.escapeValue=void 0===r||r,this.useRawValueToEscape=void 0!==o&&o,this.prefix=a?$r(a):s||"{{",this.suffix=i?$r(i):l||"}}",this.formatSeparator=u||",",this.unescapePrefix=c?"":p||"-",this.unescapeSuffix=this.unescapePrefix?"":c||"",this.nestingPrefix=d?$r(d):f||$r("$t("),this.nestingSuffix=b?$r(b):A||$r(")"),this.nestingOptionsSeparator=y||",",this.maxReplaces=h||1e3,this.alwaysFormat=void 0!==g&&g,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const e=(n,r)=>n&&n.source===r?(n.lastIndex=0,n):new RegExp(r,"g");this.regexp=e(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=e(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=e(this.nestingRegexp,`${this.nestingPrefix}(.+?)${this.nestingSuffix}`)}interpolate(e,n,r,o){let a,s,i;const l=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},u=f=>{if(f.indexOf(this.formatSeparator)<0){const h=wg(n,l,f,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(h,void 0,r,{...o,...n,interpolationkey:f}):h}const b=f.split(this.formatSeparator),A=b.shift().trim(),y=b.join(this.formatSeparator).trim();return this.format(wg(n,l,A,this.options.keySeparator,this.options.ignoreJSONStructure),y,r,{...o,...n,interpolationkey:A})};this.resetRegExp();const c=o&&o.missingInterpolationHandler||this.options.missingInterpolationHandler,p=o&&o.interpolation&&void 0!==o.interpolation.skipOnVariables?o.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:f=>yc(f)},{regex:this.regexp,safeValue:f=>this.escapeValue?yc(this.escape(f)):yc(f)}].forEach((f=>{for(i=0;a=f.regex.exec(e);){const b=a[1].trim();if(s=u(b),void 0===s)if("function"==typeof c){const y=c(e,a,o);s=Y(y)?y:""}else if(o&&Object.prototype.hasOwnProperty.call(o,b))s="";else{if(p){s=a[0];continue}this.logger.warn(`missed to pass in variable ${b} for interpolating ${e}`),s=""}else!Y(s)&&!this.useRawValueToEscape&&(s=bg(s));const A=f.safeValue(s);if(e=e.replace(a[0],A),p?(f.regex.lastIndex+=s.length,f.regex.lastIndex-=a[0].length):f.regex.lastIndex=0,i++,i>=this.maxReplaces)break}})),e}nest(e,n){let o,a,s,r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const i=(l,u)=>{const c=this.nestingOptionsSeparator;if(l.indexOf(c)<0)return l;const p=l.split(new RegExp(`${c}[ ]*{`));let d=`{${p[1]}`;l=p[0],d=this.interpolate(d,s);const f=d.match(/'/g),b=d.match(/"/g);(f&&f.length%2==0&&!b||b.length%2!=0)&&(d=d.replace(/'/g,'"'));try{s=JSON.parse(d),u&&(s={...u,...s})}catch(A){return this.logger.warn(`failed parsing options string in nesting for key ${l}`,A),`${l}${c}${d}`}return s.defaultValue&&s.defaultValue.indexOf(this.prefix)>-1&&delete s.defaultValue,l};for(;o=this.nestingRegexp.exec(e);){let l=[];s={...r},s=s.replace&&!Y(s.replace)?s.replace:s,s.applyPostProcessor=!1,delete s.defaultValue;let u=!1;if(-1!==o[0].indexOf(this.formatSeparator)&&!/{.*}/.test(o[1])){const c=o[1].split(this.formatSeparator).map((p=>p.trim()));o[1]=c.shift(),l=c,u=!0}if(a=n(i.call(this,o[1].trim(),s),s),a&&o[0]===e&&!Y(a))return a;Y(a)||(a=bg(a)),a||(this.logger.warn(`missed to resolve ${o[1]} for nesting ${e}`),a=""),u&&(a=l.reduce(((c,p)=>this.format(c,p,r.lng,{...r,interpolationkey:o[1].trim()})),a.trim())),e=e.replace(o[0],a),this.regexp.lastIndex=0}return e}}const zr=t=>{const e={};return(n,r,o)=>{let a=o;o&&o.interpolationkey&&o.formatParams&&o.formatParams[o.interpolationkey]&&o[o.interpolationkey]&&(a={...a,[o.interpolationkey]:void 0});const s=r+JSON.stringify(a);let i=e[s];return i||(i=t(ni(r),o),e[s]=i),i(n)}};class lS{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.logger=Xt.create("formatter"),this.options=e,this.formats={number:zr(((n,r)=>{const o=new Intl.NumberFormat(n,{...r});return a=>o.format(a)})),currency:zr(((n,r)=>{const o=new Intl.NumberFormat(n,{...r,style:"currency"});return a=>o.format(a)})),datetime:zr(((n,r)=>{const o=new Intl.DateTimeFormat(n,{...r});return a=>o.format(a)})),relativetime:zr(((n,r)=>{const o=new Intl.RelativeTimeFormat(n,{...r});return a=>o.format(a,r.range||"day")})),list:zr(((n,r)=>{const o=new Intl.ListFormat(n,{...r});return a=>o.format(a)}))},this.init(e)}init(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{interpolation:{}};this.formatSeparator=n.interpolation.formatSeparator||","}add(e,n){this.formats[e.toLowerCase().trim()]=n}addCached(e,n){this.formats[e.toLowerCase().trim()]=zr(n)}format(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const a=n.split(this.formatSeparator);if(a.length>1&&a[0].indexOf("(")>1&&a[0].indexOf(")")<0&&a.find((i=>i.indexOf(")")>-1))){const i=a.findIndex((l=>l.indexOf(")")>-1));a[0]=[a[0],...a.splice(1,i)].join(this.formatSeparator)}return a.reduce(((i,l)=>{const{formatName:u,formatOptions:c}=(t=>{let e=t.toLowerCase().trim();const n={};if(t.indexOf("(")>-1){const r=t.split("(");e=r[0].toLowerCase().trim();const o=r[1].substring(0,r[1].length-1);"currency"===e&&o.indexOf(":")<0?n.currency||(n.currency=o.trim()):"relativetime"===e&&o.indexOf(":")<0?n.range||(n.range=o.trim()):o.split(";").forEach((s=>{if(s){const[i,...l]=s.split(":"),u=l.join(":").trim().replace(/^'+|'+$/g,""),c=i.trim();n[c]||(n[c]=u),"false"===u&&(n[c]=!1),"true"===u&&(n[c]=!0),isNaN(u)||(n[c]=parseInt(u,10))}}))}return{formatName:e,formatOptions:n}})(l);if(this.formats[u]){let p=i;try{const d=o&&o.formatParams&&o.formatParams[o.interpolationkey]||{},f=d.locale||d.lng||o.locale||o.lng||r;p=this.formats[u](i,f,{...c,...o,...d})}catch(d){this.logger.warn(d)}return p}return this.logger.warn(`there was no format function for ${u}`),i}),e)}}class cS extends oi{constructor(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};super(),this.backend=e,this.store=n,this.services=r,this.languageUtils=r.languageUtils,this.options=o,this.logger=Xt.create("backendConnector"),this.waitingReads=[],this.maxParallelReads=o.maxParallelReads||10,this.readingCalls=0,this.maxRetries=o.maxRetries>=0?o.maxRetries:5,this.retryTimeout=o.retryTimeout>=1?o.retryTimeout:350,this.state={},this.queue=[],this.backend&&this.backend.init&&this.backend.init(r,o.backend,o)}queueLoad(e,n,r,o){const a={},s={},i={},l={};return e.forEach((u=>{let c=!0;n.forEach((p=>{const d=`${u}|${p}`;!r.reload&&this.store.hasResourceBundle(u,p)?this.state[d]=2:this.state[d]<0||(1===this.state[d]?void 0===s[d]&&(s[d]=!0):(this.state[d]=1,c=!1,void 0===s[d]&&(s[d]=!0),void 0===a[d]&&(a[d]=!0),void 0===l[p]&&(l[p]=!0)))})),c||(i[u]=!0)})),(Object.keys(a).length||Object.keys(s).length)&&this.queue.push({pending:s,pendingCount:Object.keys(s).length,loaded:{},errors:[],callback:o}),{toLoad:Object.keys(a),pending:Object.keys(s),toLoadLanguages:Object.keys(i),toLoadNamespaces:Object.keys(l)}}loaded(e,n,r){const o=e.split("|"),a=o[0],s=o[1];n&&this.emit("failedLoading",a,s,n),!n&&r&&this.store.addResourceBundle(a,s,r,void 0,void 0,{skipCopy:!0}),this.state[e]=n?-1:2,n&&r&&(this.state[e]=0);const i={};this.queue.forEach((l=>{((t,e,n,r)=>{const{obj:o,k:a}=Qo(t,e,Object);o[a]=o[a]||[],o[a].push(n)})(l.loaded,[a],s),((t,e)=>{void 0!==t.pending[e]&&(delete t.pending[e],t.pendingCount--)})(l,e),n&&l.errors.push(n),0===l.pendingCount&&!l.done&&(Object.keys(l.loaded).forEach((u=>{i[u]||(i[u]={});const c=l.loaded[u];c.length&&c.forEach((p=>{void 0===i[u][p]&&(i[u][p]=!0)}))})),l.done=!0,l.errors.length?l.callback(l.errors):l.callback())})),this.emit("loaded",i),this.queue=this.queue.filter((l=>!l.done))}read(e,n,r){let o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0,a=arguments.length>4&&void 0!==arguments[4]?arguments[4]:this.retryTimeout,s=arguments.length>5?arguments[5]:void 0;if(!e.length)return s(null,{});if(this.readingCalls>=this.maxParallelReads)return void this.waitingReads.push({lng:e,ns:n,fcName:r,tried:o,wait:a,callback:s});this.readingCalls++;const i=(u,c)=>{if(this.readingCalls--,this.waitingReads.length>0){const p=this.waitingReads.shift();this.read(p.lng,p.ns,p.fcName,p.tried,p.wait,p.callback)}u&&c&&o{this.read.call(this,e,n,r,o+1,2*a,s)}),a):s(u,c)},l=this.backend[r].bind(this.backend);if(2!==l.length)return l(e,n,i);try{const u=l(e,n);u&&"function"==typeof u.then?u.then((c=>i(null,c))).catch(i):i(null,u)}catch(u){i(u)}}prepareLoading(e,n){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},o=arguments.length>3?arguments[3]:void 0;if(!this.backend)return this.logger.warn("No backend was added via i18next.use. Will not load resources."),o&&o();Y(e)&&(e=this.languageUtils.toResolveHierarchy(e)),Y(n)&&(n=[n]);const a=this.queueLoad(e,n,r,o);if(!a.toLoad.length)return a.pending.length||o(),null;a.toLoad.forEach((s=>{this.loadOne(s)}))}load(e,n,r){this.prepareLoading(e,n,{},r)}reload(e,n,r){this.prepareLoading(e,n,{reload:!0},r)}loadOne(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";const r=e.split("|"),o=r[0],a=r[1];this.read(o,a,"read",void 0,void 0,((s,i)=>{s&&this.logger.warn(`${n}loading namespace ${a} for language ${o} failed`,s),!s&&i&&this.logger.log(`${n}loaded namespace ${a} for language ${o}`,i),this.loaded(e,s,i)}))}saveMissing(e,n,r,o,a){let s=arguments.length>5&&void 0!==arguments[5]?arguments[5]:{},i=arguments.length>6&&void 0!==arguments[6]?arguments[6]:()=>{};if(this.services.utils&&this.services.utils.hasLoadedNamespace&&!this.services.utils.hasLoadedNamespace(n))this.logger.warn(`did not save key "${r}" as the namespace "${n}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!");else if(null!=r&&""!==r){if(this.backend&&this.backend.create){const l={...s,isUpdate:a},u=this.backend.create.bind(this.backend);if(u.length<6)try{let c;c=5===u.length?u(e,n,r,o,l):u(e,n,r,o),c&&"function"==typeof c.then?c.then((p=>i(null,p))).catch(i):i(null,c)}catch(c){i(c)}else u(e,n,r,o,i,l)}!e||!e[0]||this.store.addResource(e[0],n,r,o)}}}const xg=()=>({debug:!1,initImmediate:!0,ns:["translation"],defaultNS:["translation"],fallbackLng:["dev"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:"all",preload:!1,simplifyPluralSuffix:!0,keySeparator:".",nsSeparator:":",pluralSeparator:"_",contextSeparator:"_",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:"fallback",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:t=>{let e={};if("object"==typeof t[1]&&(e=t[1]),Y(t[1])&&(e.defaultValue=t[1]),Y(t[2])&&(e.tDescription=t[2]),"object"==typeof t[2]||"object"==typeof t[3]){const n=t[3]||t[2];Object.keys(n).forEach((r=>{e[r]=n[r]}))}return e},interpolation:{escapeValue:!0,format:t=>t,prefix:"{{",suffix:"}}",formatSeparator:",",unescapePrefix:"-",nestingPrefix:"$t(",nestingSuffix:")",nestingOptionsSeparator:",",maxReplaces:1e3,skipOnVariables:!0}}),Rg=t=>(Y(t.ns)&&(t.ns=[t.ns]),Y(t.fallbackLng)&&(t.fallbackLng=[t.fallbackLng]),Y(t.fallbackNS)&&(t.fallbackNS=[t.fallbackNS]),t.supportedLngs&&t.supportedLngs.indexOf("cimode")<0&&(t.supportedLngs=t.supportedLngs.concat(["cimode"])),t),si=()=>{};class Jo extends oi{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=arguments.length>1?arguments[1]:void 0;if(super(),this.options=Rg(e),this.services={},this.logger=Xt,this.modules={external:[]},(t=>{Object.getOwnPropertyNames(Object.getPrototypeOf(t)).forEach((n=>{"function"==typeof t[n]&&(t[n]=t[n].bind(t))}))})(this),n&&!this.isInitialized&&!e.isClone){if(!this.options.initImmediate)return this.init(e,n),this;setTimeout((()=>{this.init(e,n)}),0)}}init(){var e=this;let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},r=arguments.length>1?arguments[1]:void 0;this.isInitializing=!0,"function"==typeof n&&(r=n,n={}),!n.defaultNS&&!1!==n.defaultNS&&n.ns&&(Y(n.ns)?n.defaultNS=n.ns:n.ns.indexOf("translation")<0&&(n.defaultNS=n.ns[0]));const o=xg();this.options={...o,...this.options,...Rg(n)},"v1"!==this.options.compatibilityAPI&&(this.options.interpolation={...o.interpolation,...this.options.interpolation}),void 0!==n.keySeparator&&(this.options.userDefinedKeySeparator=n.keySeparator),void 0!==n.nsSeparator&&(this.options.userDefinedNsSeparator=n.nsSeparator);const a=c=>c?"function"==typeof c?new c:c:null;if(!this.options.isClone){let c;this.modules.logger?Xt.init(a(this.modules.logger),this.options):Xt.init(null,this.options),this.modules.formatter?c=this.modules.formatter:typeof Intl<"u"&&(c=lS);const p=new Ng(this.options);this.store=new Dg(this.options.resources,this.options);const d=this.services;d.logger=Xt,d.resourceStore=this.store,d.languageUtils=p,d.pluralResolver=new aS(p,{prepend:this.options.pluralSeparator,compatibilityJSON:this.options.compatibilityJSON,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),c&&(!this.options.interpolation.format||this.options.interpolation.format===o.interpolation.format)&&(d.formatter=a(c),d.formatter.init(d,this.options),this.options.interpolation.format=d.formatter.format.bind(d.formatter)),d.interpolator=new sS(this.options),d.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},d.backendConnector=new cS(a(this.modules.backend),d.resourceStore,d,this.options),d.backendConnector.on("*",(function(f){for(var b=arguments.length,A=new Array(b>1?b-1:0),y=1;y1?b-1:0),y=1;y{f.init&&f.init(this)}))}if(this.format=this.options.interpolation.format,r||(r=si),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const c=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);c.length>0&&"dev"!==c[0]&&(this.options.lng=c[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn("init: no languageDetector is used and no lng is defined"),["getResource","hasResourceBundle","getResourceBundle","getDataByLanguage"].forEach((c=>{this[c]=function(){return e.store[c](...arguments)}})),["addResource","addResources","addResourceBundle","removeResourceBundle"].forEach((c=>{this[c]=function(){return e.store[c](...arguments),e}}));const l=Xo(),u=()=>{const c=(p,d)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn("init: i18next is already initialized. You should call init just once!"),this.isInitialized=!0,this.options.isClone||this.logger.log("initialized",this.options),this.emit("initialized",this.options),l.resolve(d),r(p,d)};if(this.languages&&"v1"!==this.options.compatibilityAPI&&!this.isInitialized)return c(null,this.t.bind(this));this.changeLanguage(this.options.lng,c)};return this.options.resources||!this.options.initImmediate?u():setTimeout(u,0),l}loadResources(e){let r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:si;const o=Y(e)?e:this.language;if("function"==typeof e&&(r=e),!this.options.resources||this.options.partialBundledLanguages){if(o&&"cimode"===o.toLowerCase()&&(!this.options.preload||0===this.options.preload.length))return r();const a=[],s=i=>{i&&"cimode"!==i&&this.services.languageUtils.toResolveHierarchy(i).forEach((u=>{"cimode"!==u&&a.indexOf(u)<0&&a.push(u)}))};o?s(o):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach((l=>s(l))),this.options.preload&&this.options.preload.forEach((i=>s(i))),this.services.backendConnector.load(a,this.options.ns,(i=>{!i&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),r(i)}))}else r(null)}reloadResources(e,n,r){const o=Xo();return"function"==typeof e&&(r=e,e=void 0),"function"==typeof n&&(r=n,n=void 0),e||(e=this.languages),n||(n=this.options.ns),r||(r=si),this.services.backendConnector.reload(e,n,(a=>{o.resolve(),r(a)})),o}use(e){if(!e)throw new Error("You are passing an undefined module! Please check the object you are passing to i18next.use()");if(!e.type)throw new Error("You are passing a wrong module! Please check the object you are passing to i18next.use()");return"backend"===e.type&&(this.modules.backend=e),("logger"===e.type||e.log&&e.warn&&e.error)&&(this.modules.logger=e),"languageDetector"===e.type&&(this.modules.languageDetector=e),"i18nFormat"===e.type&&(this.modules.i18nFormat=e),"postProcessor"===e.type&&Cg.addPostProcessor(e),"formatter"===e.type&&(this.modules.formatter=e),"3rdParty"===e.type&&this.modules.external.push(e),this}setResolvedLanguage(e){if(e&&this.languages&&!(["cimode","dev"].indexOf(e)>-1))for(let n=0;n-1)&&this.store.hasLanguageSomeTranslations(r)){this.resolvedLanguage=r;break}}}changeLanguage(e,n){var r=this;this.isLanguageChangingTo=e;const o=Xo();this.emit("languageChanging",e);const a=l=>{this.language=l,this.languages=this.services.languageUtils.toResolveHierarchy(l),this.resolvedLanguage=void 0,this.setResolvedLanguage(l)},s=(l,u)=>{u?(a(u),this.translator.changeLanguage(u),this.isLanguageChangingTo=void 0,this.emit("languageChanged",u),this.logger.log("languageChanged",u)):this.isLanguageChangingTo=void 0,o.resolve((function(){return r.t(...arguments)})),n&&n(l,(function(){return r.t(...arguments)}))},i=l=>{!e&&!l&&this.services.languageDetector&&(l=[]);const u=Y(l)?l:this.services.languageUtils.getBestMatchFromCodes(l);u&&(this.language||a(u),this.translator.language||this.translator.changeLanguage(u),this.services.languageDetector&&this.services.languageDetector.cacheUserLanguage&&this.services.languageDetector.cacheUserLanguage(u)),this.loadResources(u,(c=>{s(c,u)}))};return e||!this.services.languageDetector||this.services.languageDetector.async?!e&&this.services.languageDetector&&this.services.languageDetector.async?0===this.services.languageDetector.detect.length?this.services.languageDetector.detect().then(i):this.services.languageDetector.detect(i):i(e):i(this.services.languageDetector.detect()),o}getFixedT(e,n,r){var o=this;const a=function(s,i){let l;if("object"!=typeof i){for(var u=arguments.length,c=new Array(u>2?u-2:0),p=2;p`${l.keyPrefix}${d}${b}`)):l.keyPrefix?`${l.keyPrefix}${d}${s}`:s,o.t(f,l)};return Y(e)?a.lng=e:a.lngs=e,a.ns=n,a.keyPrefix=r,a}t(){return this.translator&&this.translator.translate(...arguments)}exists(){return this.translator&&this.translator.exists(...arguments)}setDefaultNamespace(e){this.options.defaultNS=e}hasLoadedNamespace(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(!this.isInitialized)return this.logger.warn("hasLoadedNamespace: i18next was not initialized",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn("hasLoadedNamespace: i18n.languages were undefined or empty",this.languages),!1;const r=n.lng||this.resolvedLanguage||this.languages[0],o=!!this.options&&this.options.fallbackLng,a=this.languages[this.languages.length-1];if("cimode"===r.toLowerCase())return!0;const s=(i,l)=>{const u=this.services.backendConnector.state[`${i}|${l}`];return-1===u||0===u||2===u};if(n.precheck){const i=n.precheck(this,s);if(void 0!==i)return i}return!!(this.hasResourceBundle(r,e)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||s(r,e)&&(!o||s(a,e)))}loadNamespaces(e,n){const r=Xo();return this.options.ns?(Y(e)&&(e=[e]),e.forEach((o=>{this.options.ns.indexOf(o)<0&&this.options.ns.push(o)})),this.loadResources((o=>{r.resolve(),n&&n(o)})),r):(n&&n(),Promise.resolve())}loadLanguages(e,n){const r=Xo();Y(e)&&(e=[e]);const o=this.options.preload||[],a=e.filter((s=>o.indexOf(s)<0&&this.services.languageUtils.isSupportedCode(s)));return a.length?(this.options.preload=o.concat(a),this.loadResources((s=>{r.resolve(),n&&n(s)})),r):(n&&n(),Promise.resolve())}dir(e){if(e||(e=this.resolvedLanguage||(this.languages&&this.languages.length>0?this.languages[0]:this.language)),!e)return"rtl";const r=this.services&&this.services.languageUtils||new Ng(xg());return["ar","shu","sqr","ssh","xaa","yhd","yud","aao","abh","abv","acm","acq","acw","acx","acy","adf","ads","aeb","aec","afb","ajp","apc","apd","arb","arq","ars","ary","arz","auz","avl","ayh","ayl","ayn","ayp","bbz","pga","he","iw","ps","pbt","pbu","pst","prp","prd","ug","ur","ydd","yds","yih","ji","yi","hbo","men","xmn","fa","jpr","peo","pes","prs","dv","sam","ckb"].indexOf(r.getLanguagePartFromCode(e))>-1||e.toLowerCase().indexOf("-arab")>1?"rtl":"ltr"}static createInstance(){return new Jo(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},arguments.length>1?arguments[1]:void 0)}cloneInstance(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:si;const r=e.forkResourceStore;r&&delete e.forkResourceStore;const o={...this.options,...e,isClone:!0},a=new Jo(o);return(void 0!==e.debug||void 0!==e.prefix)&&(a.logger=a.logger.clone(e)),["store","services","language"].forEach((i=>{a[i]=this[i]})),a.services={...this.services},a.services.utils={hasLoadedNamespace:a.hasLoadedNamespace.bind(a)},r&&(a.store=new Dg(this.store.data,o),a.services.resourceStore=a.store),a.translator=new ai(a.services,o),a.translator.on("*",(function(i){for(var l=arguments.length,u=new Array(l>1?l-1:0),c=1;c4&&void 0!==arguments[4]?arguments[4]:{path:"/",sameSite:"strict"};r&&(a.expires=new Date,a.expires.setTime(a.expires.getTime()+60*r*1e3)),o&&(a.domain=o),document.cookie=function(e,n,r){var o=r||{};o.path=o.path||"/";var a=encodeURIComponent(n),s="".concat(e,"=").concat(a);if(o.maxAge>0){var i=o.maxAge-0;if(Number.isNaN(i))throw new Error("maxAge should be a Number");s+="; Max-Age=".concat(Math.floor(i))}if(o.domain){if(!kg.test(o.domain))throw new TypeError("option domain is invalid");s+="; Domain=".concat(o.domain)}if(o.path){if(!kg.test(o.path))throw new TypeError("option path is invalid");s+="; Path=".concat(o.path)}if(o.expires){if("function"!=typeof o.expires.toUTCString)throw new TypeError("option expires is invalid");s+="; Expires=".concat(o.expires.toUTCString())}if(o.httpOnly&&(s+="; HttpOnly"),o.secure&&(s+="; Secure"),o.sameSite)switch("string"==typeof o.sameSite?o.sameSite.toLowerCase():o.sameSite){case!0:s+="; SameSite=Strict";break;case"lax":s+="; SameSite=Lax";break;case"strict":s+="; SameSite=Strict";break;case"none":s+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return s}(e,encodeURIComponent(n),a)},Ig_read=function(e){for(var n="".concat(e,"="),r=document.cookie.split(";"),o=0;o-1&&(r=window.location.hash.substring(window.location.hash.indexOf("?")));for(var a=r.substring(1).split("&"),s=0;s0)a[s].substring(0,i)===e.lookupQuerystring&&(n=a[s].substring(i+1))}}return n}},ta=null,Mg=function(){if(null!==ta)return ta;try{ta="undefined"!==window&&null!==window.localStorage;var e="i18next.translate.boo";window.localStorage.setItem(e,"foo"),window.localStorage.removeItem(e)}catch{ta=!1}return ta},yS={name:"localStorage",lookup:function(e){var n;if(e.lookupLocalStorage&&Mg()){var r=window.localStorage.getItem(e.lookupLocalStorage);r&&(n=r)}return n},cacheUserLanguage:function(e,n){n.lookupLocalStorage&&Mg()&&window.localStorage.setItem(n.lookupLocalStorage,e)}},na=null,Fg=function(){if(null!==na)return na;try{na="undefined"!==window&&null!==window.sessionStorage;var e="i18next.translate.boo";window.sessionStorage.setItem(e,"foo"),window.sessionStorage.removeItem(e)}catch{na=!1}return na},DS={name:"sessionStorage",lookup:function(e){var n;if(e.lookupSessionStorage&&Fg()){var r=window.sessionStorage.getItem(e.lookupSessionStorage);r&&(n=r)}return n},cacheUserLanguage:function(e,n){n.lookupSessionStorage&&Fg()&&window.sessionStorage.setItem(n.lookupSessionStorage,e)}},CS={name:"navigator",lookup:function(e){var n=[];if(typeof navigator<"u"){if(navigator.languages)for(var r=0;r0?n:void 0}},SS={name:"htmlTag",lookup:function(e){var n,r=e.htmlTag||(typeof document<"u"?document.documentElement:null);return r&&"function"==typeof r.getAttribute&&(n=r.getAttribute("lang")),n}},NS={name:"path",lookup:function(e){var n;if(typeof window<"u"){var r=window.location.pathname.match(/\/([a-zA-Z-]*)/g);if(r instanceof Array)if("number"==typeof e.lookupFromPathIndex){if("string"!=typeof r[e.lookupFromPathIndex])return;n=r[e.lookupFromPathIndex].replace("/","")}else n=r[0].replace("/","")}return n}},TS={name:"subdomain",lookup:function(e){var n="number"==typeof e.lookupFromSubdomainIndex?e.lookupFromSubdomainIndex+1:1,r=typeof window<"u"&&window.location&&window.location.hostname&&window.location.hostname.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i);if(r)return r[n]}},Bg=!1;try{document.cookie,Bg=!0}catch{}var Pg=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];Bg||Pg.splice(1,1);var Ug=function(t,e,n){return e&&Lg(t.prototype,e),n&&Lg(t,n),Object.defineProperty(t,"prototype",{writable:!1}),t}((function t(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};(function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")})(this,t),this.type="languageDetector",this.detectors={},this.init(e,n)}),[{key:"init",value:function(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.services=n||{languageUtils:{}},this.options=function(t){return hS.call(ES.call(arguments,1),(function(e){if(e)for(var n in e)void 0===t[n]&&(t[n]=e[n])})),t}(r,this.options||{},{order:Pg,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:function(e){return e}}),"string"==typeof this.options.convertDetectedLanguage&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=function(a){return a.replace("-","_")}),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=o,this.addDetector(_S),this.addDetector(vS),this.addDetector(yS),this.addDetector(DS),this.addDetector(CS),this.addDetector(SS),this.addDetector(NS),this.addDetector(TS)}},{key:"addDetector",value:function(n){return this.detectors[n.name]=n,this}},{key:"detect",value:function(n){var r=this;n||(n=this.options.order);var o=[];return n.forEach((function(a){if(r.detectors[a]){var s=r.detectors[a].lookup(r.options);s&&"string"==typeof s&&(s=[s]),s&&(o=o.concat(s))}})),o=o.map((function(a){return r.options.convertDetectedLanguage(a)})),this.services.languageUtils.getBestMatchFromCodes?o:o.length>0?o[0]:null}},{key:"cacheUserLanguage",value:function(n,r){var o=this;r||(r=this.options.caches),r&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(n)>-1||r.forEach((function(a){o.detectors[a]&&o.detectors[a].cacheUserLanguage(n,o.options)})))}}]);Ug.type="languageDetector";const WS={en:{common:{chat:{"send-message":"Send a message","reset-chat":"Reset Chat","close-chat":"Close Chat","message-copied":"Copied!","session-id":"Session ID","email-support":"Email Support"}}},zh:{common:{chat:{"send-message":"发送消息","reset-chat":"重置聊天","close-chat":"关闭聊天","message-copied":"已复制!","session-id":"会话 ID","email-support":"电子邮件支持"}}},"zh-tw":{common:{chat:{"send-message":"傳送訊息","reset-chat":"重設聊天","close-chat":"關閉聊天","message-copied":"已複製!","session-id":"工作階段 ID","email-support":"電子郵件支援"}}},es:{common:{chat:{"send-message":"Enviar un mensaje","reset-chat":"Restablecer chat","close-chat":"Cerrar chat","message-copied":"¡Copiado!","session-id":"ID de sesión","email-support":"Soporte por correo"}}},de:{common:{chat:{"send-message":"Nachricht senden","reset-chat":"Neuer Chat","close-chat":"Chat schließen","message-copied":"Kopiert!","session-id":"Sitzung ID","email-support":"EMail Support"}}},fr:{common:{chat:{"send-message":"Envoyer un message","reset-chat":"Réinitialiser la conversation","close-chat":"Fermer la conversation","message-copied":"Copié !","session-id":"ID de session","email-support":"Support par e-mail"}}},ko:{common:{chat:{"send-message":"메시지 보내기","reset-chat":"채팅 초기화","close-chat":"채팅 닫기","message-copied":"복사되었습니다!","session-id":"세션 ID","email-support":"이메일 지원"}}},ru:{common:{chat:{"send-message":"Отправить сообщение","reset-chat":"Сбросить чат","close-chat":"Закрыть чат","message-copied":"Скопировано!","session-id":"ID сессии","email-support":"Поддержка по email"}}},it:{common:{chat:{"send-message":"Invia un messaggio","reset-chat":"Reimposta chat","close-chat":"Chiudi chat","message-copied":"Copiato!","session-id":"ID sessione","email-support":"Supporto e-mail"}}},pt:{common:{chat:{"send-message":"Enviar uma mensagem","reset-chat":"Redefinir chat","close-chat":"Fechar chat","message-copied":"Copiado!","session-id":"ID da sessão","email-support":"Suporte por e-mail"}}},he:{common:{chat:{"send-message":"שלח הודעה","reset-chat":"איפוס צ'אט","close-chat":"סגור צ'אט","message-copied":"הועתק!","session-id":"מזהה סשן","email-support":'תמיכה בדוא"ל'}}},nl:{common:{chat:{"send-message":"Bericht verzenden","reset-chat":"Chat resetten","close-chat":"Chat sluiten","message-copied":"Gekopieerd!","session-id":"Sessie-ID","email-support":"E-mailondersteuning"}}},vi:{common:{chat:{"send-message":"Gửi tin nhắn","reset-chat":"Đặt lại trò chuyện","close-chat":"Đóng trò chuyện","message-copied":"Đã sao chép!","session-id":"ID phiên","email-support":"Hỗ trợ qua email"}}},fa:{common:{chat:{"send-message":"ارسال پیام","reset-chat":"بازنشانی چت","close-chat":"بستن چت","message-copied":"کپی شد!","session-id":"شناسه جلسه","email-support":"پشتیبانی ایمیل"}}},tr:{common:{chat:{"send-message":"Mesaj gönder","reset-chat":"Sohbeti sıfırla","close-chat":"Sohbeti kapat","message-copied":"Kopyalandı!","session-id":"Oturum Kimliği","email-support":"E-posta desteği"}}},ar:{common:{chat:{"send-message":"إرسال رسالة","reset-chat":"إعادة تعيين الدردشة","close-chat":"إغلاق الدردشة","message-copied":"تم النسخ!","session-id":"معرّف الجلسة","email-support":"دعم البريد الإلكتروني"}}},da:{common:{chat:{"send-message":"Send en besked","reset-chat":"Nulstil chat","close-chat":"Luk chat","message-copied":"Kopieret!","session-id":"Sessions-id","email-support":"E-mail support"}}},ja:{common:{chat:{"send-message":"メッセージを送信","reset-chat":"チャットをリセット","close-chat":"チャットを閉じる","message-copied":"コピーしました!","session-id":"セッションID","email-support":"メールサポート"}}}};const qg=document.createElement("div");document.body.appendChild(qg);const In=Object.assign({},(null==(Hg=null==document?void 0:document.currentScript)?void 0:Hg.dataset)||{}),ae={settings:In,stylesSrc:function(t=null){try{const e=new URL(t);return e.pathname=e.pathname.replace("anythingllm-chat-widget.js","anythingllm-chat-widget.min.css").replace("anythingllm-chat-widget.min.js","anythingllm-chat-widget.min.css"),e.toString()}catch{return""}}(null==(Vg=null==document?void 0:document.currentScript)?void 0:Vg.src),USER_STYLES:{msgBg:(null==In?void 0:In.userBgColor)??"#3DBEF5",base:"allm-text-white allm-rounded-t-[18px] allm-rounded-bl-[18px] allm-rounded-br-[4px] allm-mx-[20px]"},ASSISTANT_STYLES:{msgBg:(null==In?void 0:In.assistantBgColor)??"#FFFFFF",base:"allm-text-[#222628] allm-rounded-t-[18px] allm-rounded-br-[18px] allm-rounded-bl-[4px] allm-mr-[37px] allm-ml-[9px]"}};(function(t){const e=(null==t?void 0:t.language)||"en";Fe.use(F_).use(Ug).init({fallbackLng:"en",lng:e,debug:!1,defaultNS:"common",resources:WS,load:"languageOnly",lowerCaseLng:!0,detection:{order:["localStorage","navigator"],lookupLocalStorage:"allm_embed_language"},interpolation:{escapeValue:!1}})})(In),fi.createRoot(qg).render(w.jsx(m.StrictMode,{children:w.jsx((function(){const{isChatOpen:t,toggleOpenChat:e}=function(){var r;const[t,e]=U.useState(!(null==(r=null==window?void 0:window.localStorage)||!r.getItem(vu))||!1);return{isChatOpen:t,toggleOpenChat:function(o){!0===o&&window.localStorage.setItem(vu,"1"),!1===o&&window.localStorage.removeItem(vu),e(o)}}}(),n=function(){const[t,e]=U.useState({loaded:!1,...j0});return U.useEffect((()=>{!function(){if(!document)return!1;if(!ae.settings.baseApiUrl||!ae.settings.embedId)throw new Error("[AnythingLLM Embed Module::Abort] - Invalid script tag setup detected. Missing required parameters for boot!");e({...j0,...LE(ae.settings),loaded:!0})}()}),[document]),t}(),r=Y0();if(U.useEffect((()=>{"on"===n.openOnLoad&&e(!0)}),[n.loaded]),!n.loaded)return null;const o={"bottom-left":"allm-bottom-0 allm-left-0 allm-ml-4","bottom-right":"allm-bottom-0 allm-right-0 allm-mr-4","top-left":"allm-top-0 allm-left-0 allm-ml-4 allm-mt-4","top-right":"allm-top-0 allm-right-0 allm-mr-4 allm-mt-4"},a=n.position||"bottom-right",s=n.windowWidth??"400px",i=n.windowHeight??"700px";return w.jsxs(q_,{i18n:Fe,children:[w.jsx(UE,{}),w.jsx("div",{id:"anything-llm-embed-chat-container",className:"allm-fixed allm-inset-0 allm-z-50 "+(t?"allm-block":"allm-hidden"),children:w.jsx("div",{style:{maxWidth:s,maxHeight:i,height:"100%"},className:`allm-h-full allm-w-full allm-bg-white allm-fixed allm-bottom-0 allm-right-0 allm-mb-4 allm-md:mr-4 allm-rounded-2xl allm-border allm-border-gray-300 allm-shadow-[0_4px_14px_rgba(0,0,0,0.25)] allm-flex allm-flex-col ${o[a]}`,id:"anything-llm-chat",children:t&&w.jsx(qC,{closeChat:()=>e(!1),settings:n,sessionId:r})})}),!t&&w.jsx("div",{id:"anything-llm-embed-chat-button-container",className:`allm-fixed allm-bottom-0 ${o[a]} allm-mb-4 allm-z-50`,children:w.jsx(h_,{settings:n,isOpen:t,toggleOpen:()=>e(!0)})})]})}),{})})),_t.embedderSettings=ae,Object.defineProperty(_t,Symbol.toStringTag,{value:"Module"})})); \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..9205764 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..2dab536 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/fonts/PlusJakartaSans.ttf b/frontend/public/fonts/PlusJakartaSans.ttf new file mode 100644 index 0000000..bdd4985 Binary files /dev/null and b/frontend/public/fonts/PlusJakartaSans.ttf differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..5901113 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "AnythingLLM", + "short_name": "AnythingLLM", + "display": "standalone", + "orientation": "portrait", + "start_url": "/", + "icons": [ + { + "src": "/favicon.png", + "sizes": "any" + } + ] +} \ No newline at end of file diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/frontend/public/service-workers/push-notifications.js b/frontend/public/service-workers/push-notifications.js new file mode 100644 index 0000000..a85ad6d --- /dev/null +++ b/frontend/public/service-workers/push-notifications.js @@ -0,0 +1,26 @@ +function parseEventData(event) { + try { + return event.data.json(); + } catch (e) { + console.error('Failed to parse event data - is payload valid? .text():\n', event.data.text()); + return null + } + } + + self.addEventListener('push', function (event) { + const payload = parseEventData(event); + if (!payload) return; + + // options: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options + self.registration.showNotification(payload.title || 'AnythingLLM', { + ...payload, + icon: '/favicon.png', + }); + }); + + self.addEventListener('notificationclick', function (event) { + event.notification.close(); + const { onClickUrl = null } = event.notification.data || {}; + if (!onClickUrl) return; + event.waitUntil(clients.openWindow(onClickUrl)); + }); \ No newline at end of file diff --git a/frontend/scripts/postbuild.js b/frontend/scripts/postbuild.js new file mode 100644 index 0000000..bcba17b --- /dev/null +++ b/frontend/scripts/postbuild.js @@ -0,0 +1,8 @@ +import { renameSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +console.log(`Running frontend post build script...`) +renameSync(path.resolve(__dirname, '../dist/index.html'), path.resolve(__dirname, '../dist/_index.html')); +console.log(`index.html renamed to _index.html so SSR of the index page can be assumed.`); \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..32fc4b4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,47 @@ +import React, { Suspense } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { I18nextProvider } from "react-i18next"; +import { AuthProvider } from "@/AuthContext"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import i18n from "./i18n"; + +import { PfpProvider } from "./PfpContext"; +import { LogoProvider } from "./LogoContext"; +import { FullScreenLoader } from "./components/Preloader"; +import { ThemeProvider } from "./ThemeContext"; +import { PWAModeProvider } from "./PWAContext"; +import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp"; +import ImageLightbox from "@/components/ImageLightbox"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; + +export default function App() { + const location = useLocation(); + return ( + + + + }> + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx new file mode 100644 index 0000000..3b19c83 --- /dev/null +++ b/frontend/src/AuthContext.jsx @@ -0,0 +1,79 @@ +import React, { useState, createContext, useEffect } from "react"; +import { + AUTH_TIMESTAMP, + AUTH_TOKEN, + AUTH_USER, + USER_PROMPT_INPUT_MAP, +} from "@/utils/constants"; +import System from "./models/system"; +import { useNavigate } from "react-router-dom"; +import { safeJsonParse } from "@/utils/request"; + +export const AuthContext = createContext(null); +export function AuthProvider(props) { + const localUser = localStorage.getItem(AUTH_USER); + const localAuthToken = localStorage.getItem(AUTH_TOKEN); + const [store, setStore] = useState({ + user: localUser ? safeJsonParse(localUser, null) : null, + authToken: localAuthToken ? localAuthToken : null, + }); + + const navigate = useNavigate(); + + /* NOTE: + * 1. There's no reason for these helper functions to be stateful. They could + * just be regular funcs or methods on a basic object. + * 2. These actions are not being invoked anywhere in the + * codebase, dead code. + */ + const [actions] = useState({ + updateUser: (user, authToken = "") => { + localStorage.setItem(AUTH_USER, JSON.stringify(user)); + localStorage.setItem(AUTH_TOKEN, authToken); + setStore({ user, authToken }); + }, + unsetUser: () => { + localStorage.removeItem(AUTH_USER); + localStorage.removeItem(AUTH_TOKEN); + localStorage.removeItem(AUTH_TIMESTAMP); + localStorage.removeItem(USER_PROMPT_INPUT_MAP); + setStore({ user: null, authToken: null }); + }, + }); + + /* + * On initial mount and whenever the token changes, fetch a new user object + * If the user is suspended, (success === false and data === null) logout the user and redirect to the login page + * If success is true and data is not null, update the user object in the store (multi-user mode only) + * If success is true and data is null, do nothing (single-user mode only) with or without password protection + */ + useEffect(() => { + async function refreshUser() { + const { success, user: refreshedUser } = await System.refreshUser(); + if (success && refreshedUser === null) return; + + if (!success) { + localStorage.removeItem(AUTH_USER); + localStorage.removeItem(AUTH_TOKEN); + localStorage.removeItem(AUTH_TIMESTAMP); + localStorage.removeItem(USER_PROMPT_INPUT_MAP); + setStore({ user: null, authToken: null }); + navigate("/login"); + return; + } + + localStorage.setItem(AUTH_USER, JSON.stringify(refreshedUser)); + setStore((prev) => ({ + ...prev, + user: refreshedUser, + })); + } + if (store.authToken) refreshUser(); + }, [store.authToken]); + + return ( + + {props.children} + + ); +} diff --git a/frontend/src/EmbeddingProgressContext.jsx b/frontend/src/EmbeddingProgressContext.jsx new file mode 100644 index 0000000..31ddb1a --- /dev/null +++ b/frontend/src/EmbeddingProgressContext.jsx @@ -0,0 +1,240 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE } from "@/utils/constants"; +import { baseHeaders, safeJsonParse } from "@/utils/request"; +import Workspace from "@/models/workspace"; + +const EmbeddingProgressContext = createContext(); + +export function useEmbeddingProgress() { + return useContext(EmbeddingProgressContext); +} + +/** + * Workspace-specific hook that auto-connects SSE on mount and provides + * a callback when progress is cleared (embedding complete + auto-clear timeout). + * @param {string} slug - Workspace slug + * @param {Object} options + * @param {Function} [options.onProgressCleared] - Called when progress transitions from active to cleared + */ +export function useWorkspaceEmbeddingProgress( + slug, + { onProgressCleared } = {} +) { + const { embeddingProgressMap, startEmbedding, connectSSE, removeQueuedFile } = + useEmbeddingProgress(); + const embeddingProgress = embeddingProgressMap[slug] || null; + + // Store callback in ref to avoid stale closures + const onProgressClearedRef = useRef(onProgressCleared); + useEffect(() => { + onProgressClearedRef.current = onProgressCleared; + }, [onProgressCleared]); + + // Auto-connect SSE on mount to catch any active embedding job + useEffect(() => { + connectSSE(slug); + }, [slug, connectSSE]); + + // Detect when progress is cleared (non-null → null) and invoke callback + const prevProgressRef = useRef(embeddingProgress); + useEffect(() => { + if (prevProgressRef.current && !embeddingProgress) { + onProgressClearedRef.current?.(); + } + prevProgressRef.current = embeddingProgress; + }, [embeddingProgress]); + + const removeQueued = useCallback( + (filename) => removeQueuedFile(slug, filename), + [slug, removeQueuedFile] + ); + + return { embeddingProgress, startEmbedding, removeQueuedFile: removeQueued }; +} + +const CLEANUP_DELAY_MS = 1_500; +export function EmbeddingProgressProvider({ children }) { + const [embeddingProgressMap, setEmbeddingProgressMap] = useState({}); + const abortControllersRef = useRef({}); + const cleanupTimeoutsRef = useRef({}); + + useEffect(() => { + return () => { + Object.values(abortControllersRef.current).forEach((ctrl) => + ctrl?.abort() + ); + }; + }, []); + + const updateFileStatus = useCallback( + (slug, filename, status) => + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: { ...prev[slug], [filename]: status }, + })), + [] + ); + + const handleMessage = useCallback( + (slug, msg, ctrl) => { + const data = safeJsonParse(msg.data); + + switch (data.type) { + case "batch_starting": { + const initial = Object.fromEntries( + (data.filenames || []).map((name) => [name, { status: "pending" }]) + ); + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: { ...initial, ...prev[slug] }, + })); + break; + } + + case "doc_starting": + updateFileStatus(slug, data.filename, { + status: "embedding", + chunksProcessed: 0, + totalChunks: 0, + }); + break; + + case "chunk_progress": + updateFileStatus(slug, data.filename, { + status: "embedding", + chunksProcessed: data.chunksProcessed, + totalChunks: data.totalChunks, + }); + break; + + case "doc_complete": + updateFileStatus(slug, data.filename, { status: "complete" }); + break; + + case "doc_failed": + updateFileStatus(slug, data.filename, { + status: "failed", + error: data.error || "Embedding failed", + }); + break; + + case "file_removed": + setEmbeddingProgressMap((prev) => { + const slugMap = { ...prev[slug] }; + delete slugMap[data.filename]; + if (Object.keys(slugMap).length === 0) { + const { [slug]: _, ...rest } = prev; + return rest; + } + return { ...prev, [slug]: slugMap }; + }); + break; + + case "all_complete": + // If there was an error, mark all pending or embedding files as failed + // because something went wrong and we don't know the status of the files + if (data.error) { + setEmbeddingProgressMap((prev) => { + const slugMap = { ...prev[slug] }; + for (const [filename, info] of Object.entries(slugMap)) { + if (info.status === "pending" || info.status === "embedding") { + slugMap[filename] = { + status: "failed", + error: data.error, + }; + } + } + return { ...prev, [slug]: slugMap }; + }); + } + ctrl.abort(); + delete abortControllersRef.current[slug]; + cleanupTimeoutsRef.current[slug] = setTimeout(() => { + setEmbeddingProgressMap((prev) => { + const { [slug]: _, ...rest } = prev; + return rest; + }); + delete cleanupTimeoutsRef.current[slug]; + }, CLEANUP_DELAY_MS); + break; + } + }, + [updateFileStatus] + ); + + /** + * Open (or reconnect) an SSE connection for a given workspace slug. + * Updates embeddingProgressMap in real time as events arrive. + */ + const connectSSE = useCallback( + (slug) => { + if (abortControllersRef.current[slug]) return; + + const ctrl = new AbortController(); + abortControllersRef.current[slug] = ctrl; + + fetchEventSource(`${API_BASE}/workspace/${slug}/embed-progress`, { + method: "GET", + headers: baseHeaders(), + signal: ctrl.signal, + openWhenHidden: true, + onmessage: (msg) => handleMessage(slug, msg, ctrl), + onclose: () => delete abortControllersRef.current[slug], + onerror: () => { + delete abortControllersRef.current[slug]; + throw new Error("SSE connection error"); + }, + }).catch(() => {}); + }, + [handleMessage] + ); + + const startEmbedding = useCallback( + (slug, filenames) => { + abortControllersRef.current[slug]?.abort(); + delete abortControllersRef.current[slug]; + + if (cleanupTimeoutsRef.current[slug]) { + clearTimeout(cleanupTimeoutsRef.current[slug]); + delete cleanupTimeoutsRef.current[slug]; + } + + const newEntries = Object.fromEntries( + filenames.map((name) => [name, { status: "pending" }]) + ); + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: newEntries, + })); + + connectSSE(slug); + }, + [connectSSE] + ); + + const removeQueuedFile = useCallback(async (slug, filename) => { + const { success } = await Workspace.removeQueuedEmbedding(slug, filename); + return success; + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx new file mode 100644 index 0000000..6cbf0ea --- /dev/null +++ b/frontend/src/LogoContext.jsx @@ -0,0 +1,56 @@ +import { createContext, useEffect, useState } from "react"; +import AnythingLLM from "./media/logo/anything-llm.png"; +import AnythingLLMDark from "./media/logo/anything-llm-dark.png"; +import DefaultLoginLogoLight from "./media/illustrations/login-logo.svg"; +import DefaultLoginLogoDark from "./media/illustrations/login-logo-light.svg"; +import System from "./models/system"; + +export const REFETCH_LOGO_EVENT = "refetch-logo"; + +function isLightMode() { + return document.documentElement.getAttribute("data-theme") === "light"; +} +export const LogoContext = createContext(); + +export function LogoProvider({ children }) { + const [logo, setLogo] = useState(""); + const [loginLogo, setLoginLogo] = useState(""); + const [isCustomLogo, setIsCustomLogo] = useState(false); + + async function fetchInstanceLogo() { + const DefaultLoginLogo = isLightMode() + ? DefaultLoginLogoDark + : DefaultLoginLogoLight; + try { + const { isCustomLogo, logoURL } = await System.fetchLogo(); + if (logoURL) { + setLogo(logoURL); + setLoginLogo(isCustomLogo ? logoURL : DefaultLoginLogo); + setIsCustomLogo(isCustomLogo); + } else { + isLightMode() ? setLogo(AnythingLLMDark) : setLogo(AnythingLLM); + setLoginLogo(DefaultLoginLogo); + setIsCustomLogo(false); + } + } catch (err) { + isLightMode() ? setLogo(AnythingLLMDark) : setLogo(AnythingLLM); + setLoginLogo(DefaultLoginLogo); + setIsCustomLogo(false); + console.error("Failed to fetch logo:", err); + } + } + + useEffect(() => { + fetchInstanceLogo(); + window.addEventListener(REFETCH_LOGO_EVENT, fetchInstanceLogo); + return () => { + window.removeEventListener(REFETCH_LOGO_EVENT, fetchInstanceLogo); + }; + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/PWAContext.jsx b/frontend/src/PWAContext.jsx new file mode 100644 index 0000000..6495a69 --- /dev/null +++ b/frontend/src/PWAContext.jsx @@ -0,0 +1,93 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +/** + * Detects if the application is running as a standalone PWA + * @returns {boolean} True if running as standalone PWA + */ +function isStandalonePWA() { + if (typeof window === "undefined") return false; + + const matchesStandaloneDisplayMode = + typeof window.matchMedia === "function" + ? window.matchMedia("(display-mode: standalone)")?.matches + : false; + + const isIOSStandalone = window.navigator?.standalone === true; // iOS Safari + const androidReferrer = + typeof document !== "undefined" && document?.referrer + ? document.referrer.includes("android-app://") + : false; + + return Boolean( + matchesStandaloneDisplayMode || isIOSStandalone || androidReferrer + ); +} + +const PWAModeContext = createContext({ isPWA: false }); +export function PWAModeProvider({ children }) { + const [isPWA, setIsPWA] = useState(() => isStandalonePWA()); + + useEffect(() => { + if (typeof window === "undefined") return undefined; + + const mediaQuery = + typeof window.matchMedia === "function" + ? window.matchMedia("(display-mode: standalone)") + : null; + + const updateStatus = () => setIsPWA(isStandalonePWA()); + + updateStatus(); + + if (mediaQuery?.addEventListener) { + mediaQuery.addEventListener("change", updateStatus); + } else if (mediaQuery?.addListener) { + mediaQuery.addListener(updateStatus); + } + + window.addEventListener("appinstalled", updateStatus); + window.addEventListener("visibilitychange", updateStatus); + + return () => { + if (mediaQuery?.removeEventListener) { + mediaQuery.removeEventListener("change", updateStatus); + } else if (mediaQuery?.removeListener) { + mediaQuery.removeListener(updateStatus); + } + + window.removeEventListener("appinstalled", updateStatus); + window.removeEventListener("visibilitychange", updateStatus); + }; + }, []); + + useEffect(() => { + if (typeof document === "undefined") return undefined; + + document.body.classList.toggle("pwa", isPWA); + document.documentElement?.setAttribute( + "data-pwa", + isPWA ? "true" : "false" + ); + + return () => { + document.body.classList.remove("pwa"); + document.documentElement?.removeAttribute("data-pwa"); + }; + }, [isPWA]); + + const value = useMemo(() => ({ isPWA }), [isPWA]); + + return ( + {children} + ); +} + +export function usePWAMode() { + return useContext(PWAModeContext); +} diff --git a/frontend/src/PfpContext.jsx b/frontend/src/PfpContext.jsx new file mode 100644 index 0000000..3d60d55 --- /dev/null +++ b/frontend/src/PfpContext.jsx @@ -0,0 +1,30 @@ +import React, { createContext, useState, useEffect } from "react"; +import useUser from "./hooks/useUser"; +import System from "./models/system"; + +export const PfpContext = createContext(); + +export function PfpProvider({ children }) { + const [pfp, setPfp] = useState(null); + const { user } = useUser(); + + useEffect(() => { + async function fetchPfp() { + if (!user?.id) return; + try { + const pfpUrl = await System.fetchPfp(user.id); + setPfp(pfpUrl); + } catch (err) { + setPfp(null); + console.error("Failed to fetch pfp:", err); + } + } + fetchPfp(); + }, [user?.id]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/ThemeContext.jsx b/frontend/src/ThemeContext.jsx new file mode 100644 index 0000000..f9a5c68 --- /dev/null +++ b/frontend/src/ThemeContext.jsx @@ -0,0 +1,16 @@ +import React, { createContext, useContext } from "react"; +import { useTheme } from "./hooks/useTheme"; + +const ThemeContext = createContext(); + +export function ThemeProvider({ children }) { + const themeValue = useTheme(); + + return ( + {children} + ); +} + +export function useThemeContext() { + return useContext(ThemeContext); +} diff --git a/frontend/src/components/CanViewChatHistory/index.jsx b/frontend/src/components/CanViewChatHistory/index.jsx new file mode 100644 index 0000000..44e7535 --- /dev/null +++ b/frontend/src/components/CanViewChatHistory/index.jsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { FullScreenLoader } from "@/components/Preloader"; +import System from "@/models/system"; +import paths from "@/utils/paths"; + +/** + * Protects the view from system set ups who cannot view chat history. + * If the user cannot view chat history, they are redirected to the home page. + * @param {React.ReactNode} children + */ +export function CanViewChatHistory({ children }) { + const { loading, viewable } = useCanViewChatHistory(); + if (loading) return ; + if (!viewable) { + window.location.href = paths.home(); + return ; + } + + return <>{children}; +} + +/** + * Provides the `viewable` state to the children. + * @returns {React.ReactNode} + */ +export function CanViewChatHistoryProvider({ children }) { + const { loading, viewable } = useCanViewChatHistory(); + if (loading) return null; + return <>{children({ viewable })}; +} + +/** + * Hook that fetches the can view chat history state from local storage or the system settings. + * @returns {Promise<{viewable: boolean, error: string | null}>} + */ +export function useCanViewChatHistory() { + const [loading, setLoading] = useState(true); + const [viewable, setViewable] = useState(false); + + useEffect(() => { + async function fetchViewable() { + const { viewable } = await System.fetchCanViewChatHistory(); + setViewable(viewable); + setLoading(false); + } + fetchViewable(); + }, []); + + return { loading, viewable }; +} diff --git a/frontend/src/components/ChangeWarning/index.jsx b/frontend/src/components/ChangeWarning/index.jsx new file mode 100644 index 0000000..95ca1c4 --- /dev/null +++ b/frontend/src/components/ChangeWarning/index.jsx @@ -0,0 +1,61 @@ +import { Warning, X } from "@phosphor-icons/react"; + +export default function ChangeWarningModal({ + warningText = "", + onClose, + onConfirm, +}) { + return ( +
+
+
+ +

+ WARNING - This action is irreversible +

+
+ +
+
+
+

+ {warningText.split("\\n").map((line, index) => ( + + {line} +
+
+ ))} +
+
+ Are you sure you want to proceed? +

+
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/ChatBubble/index.jsx b/frontend/src/components/ChatBubble/index.jsx new file mode 100644 index 0000000..3b33e8f --- /dev/null +++ b/frontend/src/components/ChatBubble/index.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import UserIcon from "../UserIcon"; +import { userFromStorage } from "@/utils/request"; +import renderMarkdown from "@/utils/chat/markdown"; +import DOMPurify from "@/utils/chat/purify"; + +export default function ChatBubble({ message, type }) { + const isUser = type === "user"; + + return ( +
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx b/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx new file mode 100644 index 0000000..05ace1a --- /dev/null +++ b/frontend/src/components/CommunityHub/PublishEntityModal/AgentFlows/index.jsx @@ -0,0 +1,261 @@ +import { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import CommunityHub from "@/models/communityHub"; +import showToast from "@/utils/toast"; +import paths from "@/utils/paths"; +import { X, CaretRight } from "@phosphor-icons/react"; +import { BLOCK_INFO } from "@/pages/Admin/AgentBuilder/BlockList"; +import { Link } from "react-router-dom"; + +export default function AgentFlows({ entity }) { + const { t } = useTranslation(); + const formRef = useRef(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); + const [isSuccess, setIsSuccess] = useState(false); + const [itemId, setItemId] = useState(null); + const [expandedStep, setExpandedStep] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsSubmitting(true); + try { + const form = new FormData(formRef.current); + const data = { + name: form.get("name"), + description: form.get("description"), + tags: tags, + visibility: "private", + flow: JSON.stringify({ + name: form.get("name"), + description: form.get("description"), + steps: entity.steps, + tags: tags, + visibility: "private", + }), + }; + const { success, error, itemId } = + await CommunityHub.createAgentFlow(data); + if (!success) throw new Error(error); + setItemId(itemId); + setIsSuccess(true); + } catch (error) { + console.error("Failed to publish agent flow:", error); + showToast(`Failed to publish agent flow: ${error.message}`, "error", { + clear: true, + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const value = tagInput.trim(); + if (value.length > 20) return; + if (value && !tags.includes(value)) { + setTags((prevTags) => [...prevTags, value].slice(0, 5)); // Limit to 5 tags + setTagInput(""); + } + } + }; + + const removeTag = (tagToRemove) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + if (isSuccess) { + return ( +
+
+

+ {t("community_hub.publish.agent_flow.success_title")} +

+

+ {t("community_hub.publish.agent_flow.success_description")} +

+

+ {t("community_hub.publish.agent_flow.success_thank_you")} +

+ + {t("community_hub.publish.agent_flow.view_on_hub")} + +
+
+ ); + } + + return ( + <> +
+

+ {t("community_hub.publish.agent_flow.modal_title")} +

+
+
+
+
+ +
+ {t("community_hub.publish.agent_flow.name_description")} +
+ +
+ +
+ +
+ {t("community_hub.publish.agent_flow.description_description")} +
+ +
+
+ + +
+
+
+
+ + +
+ + + + + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx new file mode 100644 index 0000000..0f97f44 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx @@ -0,0 +1,155 @@ +import { useState, useEffect } from "react"; +import { X } from "@phosphor-icons/react"; +import ModalWrapper from "@/components/ModalWrapper"; +import { CMD_REGEX } from "./constants"; + +export default function EditPresetModal({ + isOpen, + onClose, + onSave, + onDelete, + preset, +}) { + const [command, setCommand] = useState(""); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + if (preset && isOpen) { + setCommand(preset.command?.slice(1) || ""); + } + }, [preset, isOpen]); + + const handleSubmit = (e) => { + e.preventDefault(); + const form = new FormData(e.target); + const sanitizedCommand = command.replace(CMD_REGEX, ""); + onSave({ + id: preset.id, + command: `/${sanitizedCommand}`, + prompt: form.get("prompt"), + description: form.get("description"), + }); + }; + + const handleCommandChange = (e) => { + const value = e.target.value.replace(CMD_REGEX, ""); + setCommand(value); + }; + + const handleDelete = async () => { + if (!window.confirm("Are you sure you want to delete this preset?")) return; + + setDeleting(true); + await onDelete(preset.id); + setDeleting(false); + onClose(); + }; + + return ( + +
+
+
+

+ Edit Preset +

+
+ +
+
+
+
+
+
+ +
+ / + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js new file mode 100644 index 0000000..1ea2311 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js @@ -0,0 +1 @@ +export const CMD_REGEX = /[^a-zA-Z0-9_-]/g; diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx new file mode 100644 index 0000000..9c65237 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx @@ -0,0 +1,223 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Plus } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; +import System from "@/models/system"; +import { useModal } from "@/hooks/useModal"; +import AddPresetModal from "./SlashPresets/AddPresetModal"; +import EditPresetModal from "./SlashPresets/EditPresetModal"; +import PublishEntityModal from "@/components/CommunityHub/PublishEntityModal"; +import showToast from "@/utils/toast"; +import { PROMPT_INPUT_EVENT } from "@/components/WorkspaceChat/ChatContainer/PromptInput"; +import useToolsMenuItems from "../../useToolsMenuItems"; +import SlashCommandRow from "./SlashCommandRow"; + +export default function SlashCommandsTab({ + sendCommand, + setShowing, + promptRef, + highlightedIndex = -1, + registerItemCount, +}) { + const { t } = useTranslation(); + const { + isOpen: isAddModalOpen, + openModal: openAddModal, + closeModal: closeAddModal, + } = useModal(); + const { + isOpen: isEditModalOpen, + openModal: openEditModal, + closeModal: closeEditModal, + } = useModal(); + const { + isOpen: isPublishModalOpen, + openModal: openPublishModal, + closeModal: closePublishModal, + } = useModal(); + const [presets, setPresets] = useState([]); + const [selectedPreset, setSelectedPreset] = useState(null); + const [presetToPublish, setPresetToPublish] = useState(null); + + useEffect(() => { + fetchPresets(); + }, []); + + const fetchPresets = async () => { + const presets = await System.getSlashCommandPresets(); + setPresets(presets); + }; + + // Build the list of selectable items for keyboard navigation and rendering. + // /reset is a static English string since the backend matches it exactly. + // During an agent session it ends the session AND clears the chat. + const items = useMemo( + () => [ + { + command: "/reset", + description: t("chat_window.preset_reset_description"), + autoSubmit: true, + }, + ...presets.map((preset) => ({ + command: preset.command, + description: preset.description, + autoSubmit: false, + preset, + })), + ], + [presets, t] + ); + + const handleUseCommand = useCallback( + (command, autoSubmit = false) => { + setShowing(false); + + // Auto-submit commands (/reset) fire immediately + if (autoSubmit) { + sendCommand({ text: command, autoSubmit: true }); + promptRef?.current?.focus(); + return; + } + + // Insert the command at the cursor, replacing a trailing "/" if present + const textarea = promptRef?.current; + if (!textarea) return; + const cursor = textarea.selectionStart; + const value = textarea.value; + const charBefore = cursor > 0 ? value[cursor - 1] : ""; + const insertStart = charBefore === "/" ? cursor - 1 : cursor; + const newValue = + value.slice(0, insertStart) + command + value.slice(cursor); + + window.dispatchEvent( + new CustomEvent(PROMPT_INPUT_EVENT, { + detail: { messageContent: newValue }, + }) + ); + textarea.focus(); + const newCursor = insertStart + command.length; + setTimeout(() => textarea.setSelectionRange(newCursor, newCursor), 0); + }, + [sendCommand, setShowing, promptRef] + ); + + useToolsMenuItems({ + items, + highlightedIndex, + onSelect: (item) => { + const text = item.preset ? `${item.command} ` : item.command; + handleUseCommand(text, item.autoSubmit); + }, + registerItemCount, + }); + + const handleSavePreset = async (preset) => { + const { error } = await System.createSlashCommandPreset(preset); + if (error) { + showToast(error, "error"); + return false; + } + fetchPresets(); + closeAddModal(); + return true; + }; + + const handleEditPreset = (preset) => { + setSelectedPreset(preset); + openEditModal(); + }; + + const handleUpdatePreset = async (updatedPreset) => { + const { error } = await System.updateSlashCommandPreset( + updatedPreset.id, + updatedPreset + ); + if (error) { + showToast(error, "error"); + return; + } + fetchPresets(); + closeEditModal(); + setSelectedPreset(null); + }; + + const handleDeletePreset = async (presetId) => { + await System.deleteSlashCommandPreset(presetId); + fetchPresets(); + closeEditModal(); + setSelectedPreset(null); + }; + + const handlePublishPreset = (preset) => { + setPresetToPublish({ + name: preset.command.slice(1), + description: preset.description, + command: preset.command, + prompt: preset.prompt, + }); + openPublishModal(); + }; + + return ( + <> + {items.map((item, index) => ( + + handleUseCommand( + item.preset ? `${item.command} ` : item.command, + item.autoSubmit + ) + } + onEdit={item.preset ? () => handleEditPreset(item.preset) : undefined} + onPublish={ + item.preset ? () => handlePublishPreset(item.preset) : undefined + } + showMenu={!!item.preset} + highlighted={highlightedIndex === index} + /> + ))} + + {/* Add new */} +
+ + + {t("chat_window.add_new")} + +
+ + {/* Modals */} + + {selectedPreset && ( + { + closeEditModal(); + setSelectedPreset(null); + }} + onSave={handleUpdatePreset} + onDelete={handleDeletePreset} + preset={selectedPreset} + /> + )} + + + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx new file mode 100644 index 0000000..5437ed2 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx @@ -0,0 +1,201 @@ +import { + useState, + useEffect, + useLayoutEffect, + useCallback, + useRef, + useMemo, +} from "react"; +import { useTranslation } from "react-i18next"; +import useUser from "@/hooks/useUser"; +import AgentSkillsTab from "./Tabs/AgentSkills"; +import SlashCommandsTab from "./Tabs/SlashCommands"; + +export const TOOLS_MENU_KEYBOARD_EVENT = "tools-menu-keyboard"; +function getTabs(t, user) { + const tabs = [ + { + key: "slash-commands", + label: t("chat_window.slash_commands"), + component: SlashCommandsTab, + }, + ]; + + // Only show agent skills tab for admins or when multiuser mode is off + const canSeeAgentSkills = + !user?.hasOwnProperty("role") || user.role === "admin"; + if (canSeeAgentSkills) { + tabs.push({ + key: "agent-skills", + label: t("chat_window.agent_skills"), + component: AgentSkillsTab, + }); + } + + return tabs; +} + +/** + * @param {Workspace} props.workspace - the workspace object + * @param {boolean} props.showing + * @param {function} props.setShowing + * @param {function} props.sendCommand + * @param {object} props.promptRef + * @param {boolean} [props.centered] - when true, popup opens below the input + */ +export default function ToolsMenu({ + workspace, + showing, + setShowing, + sendCommand, + promptRef, + centered = false, + highlightedIndexRef, +}) { + const { t } = useTranslation(); + const { user } = useUser(); + const TABS = useMemo(() => getTabs(t, user), [t, user]); + const [activeTab, setActiveTab] = useState(TABS[0].key); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [maxHeight, setMaxHeight] = useState(360); + const itemCountRef = useRef(0); + const popoverRef = useRef(null); + + // Always open to the slash commands + useEffect(() => { + if (showing) setActiveTab(TABS[0].key); + }, [showing]); + + // Reset highlight when switching tabs or closing + useEffect(() => { + setHighlightedIndex(-1); + }, [activeTab, showing]); + + // Constrain popover height to the space available in the viewport so it + // never overflows off-screen on shorter windows (e.g. centered home view). + useLayoutEffect(() => { + if (!showing) return; + const update = () => { + const el = popoverRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const available = centered + ? window.innerHeight - rect.top - 16 + : rect.bottom - 16; + setMaxHeight(Math.max(0, Math.min(360, available))); + }; + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, [showing, centered]); + + // Keep the parent ref in sync so PromptInput can check it on Enter + useEffect(() => { + if (highlightedIndexRef) highlightedIndexRef.current = highlightedIndex; + }, [highlightedIndex]); + + const registerItemCount = useCallback((count) => { + itemCountRef.current = count; + }, []); + + useEffect(() => { + if (!showing) return; + + function handleKeyboard(e) { + const { key } = e.detail; + + if (key === "ArrowLeft" || key === "ArrowRight") { + const currentIdx = TABS.findIndex((tab) => tab.key === activeTab); + const nextIdx = + key === "ArrowLeft" + ? (currentIdx - 1 + TABS.length) % TABS.length + : (currentIdx + 1) % TABS.length; + setActiveTab(TABS[nextIdx].key); + return; + } + + if (key === "ArrowUp" || key === "ArrowDown") { + const count = itemCountRef.current; + if (count === 0) return; + setHighlightedIndex((prev) => { + if (key === "ArrowDown") { + return prev < count - 1 ? prev + 1 : 0; + } + return prev > 0 ? prev - 1 : count - 1; + }); + return; + } + + // Enter is handled by the tab components via highlightedIndex + } + + window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard); + return () => + window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard); + }, [showing, activeTab]); + + if (!showing) return null; + + const { component: ActiveTab } = TABS.find((tab) => tab.key === activeTab); + + return ( + <> +
e.preventDefault()} + onClick={() => setShowing(false)} + /> +
{ + // Prevents prompt textarea from losing focus when clicking inside the menu. + // Skip for portaled modals so their inputs can still receive focus. + if (e.currentTarget.contains(e.target)) e.preventDefault(); + }} + style={{ maxHeight }} + className={`absolute left-2 right-2 md:left-14 md:right-auto md:w-[400px] z-50 bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg p-3 flex flex-col gap-2.5 shadow-lg overflow-hidden ${ + centered ? "top-full mt-2" : "bottom-full mb-2" + }`} + > +
+ {TABS.map((tab) => ( + setActiveTab(tab.key)} + > + {tab.label} + + ))} +
+ +
+ +
+
+ + ); +} + +function TabButton({ active, onClick, children }) { + return ( + + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js new file mode 100644 index 0000000..d7c8d39 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { TOOLS_MENU_KEYBOARD_EVENT } from "./"; + +/** + * Shared hook for ToolsMenu tabs that registers the item count + * for Up/Down navigation and handles Enter to select the highlighted item. + * @param {Array} items - the list of items rendered in the tab + * @param {number} highlightedIndex - currently highlighted index from parent + * @param {function} onSelect - called with the highlighted item on Enter + * @param {function} registerItemCount - callback to register total item count with parent + */ +export default function useToolsMenuItems({ + items, + highlightedIndex, + onSelect, + registerItemCount, +}) { + useEffect(() => { + registerItemCount?.(items.length); + }, [items.length, registerItemCount]); + + useEffect(() => { + if (highlightedIndex < 0 || highlightedIndex >= items.length) return; + function handleEnter(e) { + if (e.detail.key !== "Enter") return; + onSelect(items[highlightedIndex]); + } + window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter); + return () => + window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter); + }, [highlightedIndex, items, onSelect]); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx new file mode 100644 index 0000000..da1349d --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -0,0 +1,554 @@ +import { useState, useRef, useEffect } from "react"; +import debounce from "lodash.debounce"; +import { ArrowUp, At } from "@phosphor-icons/react"; +import StopGenerationButton from "./StopGenerationButton"; +import SpeechToText from "./SpeechToText"; +import { Tooltip } from "react-tooltip"; +import AttachmentManager from "./Attachments"; +import AttachItem from "./AttachItem"; +import { + ATTACHMENTS_PROCESSED_EVENT, + ATTACHMENTS_PROCESSING_EVENT, + PASTE_ATTACHMENT_EVENT, +} from "../DnDWrapper"; +import useTextSize from "@/hooks/useTextSize"; +import { useTranslation } from "react-i18next"; +import Appearance from "@/models/appearance"; +import usePromptInputStorage from "@/hooks/usePromptInputStorage"; +import ToolsMenu, { TOOLS_MENU_KEYBOARD_EVENT } from "./ToolsMenu"; +import { useSearchParams } from "react-router-dom"; +import { useIsAgentSessionActive } from "@/utils/chat/agent"; + +export const PROMPT_INPUT_ID = "primary-prompt-input"; +export const PROMPT_INPUT_EVENT = "set_prompt_input"; +const MAX_EDIT_STACK_SIZE = 100; + +/** + * @param {Workspace} props.workspace - workspace object + * @param {function} props.submit - form submit handler + * @param {boolean} props.isStreaming - disables input while streaming response + * @param {function} props.sendCommand - handler for slash commands and agent mentions + * @param {Array} [props.attachments] - file attachments array + * @param {boolean} [props.centered] - renders in centered layout mode (for home page) + * @param {string} [props.workspaceSlug] - workspace slug for home page context + * @param {string} [props.threadSlug] - thread slug for home page context + */ +export default function PromptInput({ + workspace = {}, + submit, + isStreaming, + sendCommand, + attachments = [], + centered = false, + workspaceSlug = null, + threadSlug = null, +}) { + const { t } = useTranslation(); + const { showAgentCommand = true } = workspace ?? {}; + const { isDisabled } = useIsDisabled(); + const agentSessionActive = useIsAgentSessionActive(); + const [promptInput, setPromptInput] = useState(""); + const [showTools, setShowTools] = useState(false); + const autoOpenedToolsRef = useRef(false); + const toolsHighlightRef = useRef(-1); + const formRef = useRef(null); + const textareaRef = useRef(null); + const [_, setFocused] = useState(false); + const undoStack = useRef([]); + const redoStack = useRef([]); + const { textSizeClass } = useTextSize(); + const [searchParams] = useSearchParams(); + + // Synchronizes prompt input value with localStorage, scoped to the current thread. + usePromptInputStorage({ + promptInput, + setPromptInput, + }); + + /* + * @checklist-item + * If the URL has the agent param, open the agent menu for the user + * automatically when the component mounts. + */ + useEffect(() => { + if (searchParams.get("action") === "set-agent-chat") { + sendCommand({ text: "@agent " }); + textareaRef.current?.focus(); + } + }, [textareaRef.current]); + + /** + * To prevent too many re-renders we remotely listen for updates from the parent + * via an event cycle. Otherwise, using message as a prop leads to a re-render every + * change on the input. + * @param {{detail: {messageContent: string, writeMode: 'replace' | 'append'}}} e + */ + function handlePromptUpdate(e) { + const { messageContent, writeMode = "replace" } = e?.detail ?? {}; + if (writeMode === "append") setPromptInput((prev) => prev + messageContent); + else if (writeMode === "prepend") + setPromptInput((prev) => messageContent + " " + prev); + else setPromptInput(messageContent ?? ""); + } + + useEffect(() => { + if (!!window) + window.addEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate); + return () => + window?.removeEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate); + }, []); + + useEffect(() => { + if (!isStreaming && textareaRef.current) textareaRef.current.focus(); + resetTextAreaHeight(); + }, [isStreaming]); + + /** + * Save the current state before changes + * @param {number} adjustment + */ + function saveCurrentState(adjustment = 0) { + if (undoStack.current.length >= MAX_EDIT_STACK_SIZE) + undoStack.current.shift(); + undoStack.current.push({ + value: promptInput, + cursorPositionStart: textareaRef.current.selectionStart + adjustment, + cursorPositionEnd: textareaRef.current.selectionEnd + adjustment, + }); + } + const debouncedSaveState = debounce(saveCurrentState, 250); + + function handleSubmit(e) { + // Ignore submits from portaled modals (slash command preset forms) + if (e.target !== e.currentTarget) return; + setFocused(false); + setShowTools(false); + submit(e); + } + + function resetTextAreaHeight() { + if (!textareaRef.current) return; + textareaRef.current.style.height = "auto"; + } + + /** + * Capture enter key press to handle submission, redo, or undo + * via keyboard shortcuts + * @param {KeyboardEvent} event + */ + function captureEnterOrUndo(event) { + // Forward keyboard events to the ToolsMenu when open + if (showTools) { + if ( + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) + ) { + event.preventDefault(); + window.dispatchEvent( + new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, { + detail: { key: event.key }, + }) + ); + return; + } + // When an item is highlighted via arrow keys, Enter selects it. + // Otherwise, Enter falls through to submit the form normally. + if (event.key === "Enter" && toolsHighlightRef.current >= 0) { + event.preventDefault(); + window.dispatchEvent( + new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, { + detail: { key: "Enter" }, + }) + ); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setShowTools(false); + textareaRef.current?.focus(); + return; + } + } + + // "/" toggles the Tools menu only when the input is empty + if ( + event.key === "/" && + !event.ctrlKey && + !event.metaKey && + promptInput.trim() === "" + ) { + setShowTools((prev) => { + autoOpenedToolsRef.current = !prev; + return !prev; + }); + return; + } + + // Is simple enter key press w/o shift key + if (event.keyCode === 13 && !event.shiftKey) { + event.preventDefault(); + if (isStreaming || isDisabled) return; // Prevent submission if streaming or disabled + setShowTools(false); + return submit(event); + } + + // Is undo with Ctrl+Z or Cmd+Z + Shift key = Redo + if ( + (event.ctrlKey || event.metaKey) && + event.key === "z" && + event.shiftKey + ) { + event.preventDefault(); + if (redoStack.current.length === 0) return; + + const nextState = redoStack.current.pop(); + if (!nextState) return; + + undoStack.current.push({ + value: promptInput, + cursorPositionStart: textareaRef.current.selectionStart, + cursorPositionEnd: textareaRef.current.selectionEnd, + }); + setPromptInput(nextState.value); + setTimeout(() => { + textareaRef.current.setSelectionRange( + nextState.cursorPositionStart, + nextState.cursorPositionEnd + ); + }, 0); + } + + // Undo with Ctrl+Z or Cmd+Z + if ( + (event.ctrlKey || event.metaKey) && + event.key === "z" && + !event.shiftKey + ) { + if (undoStack.current.length === 0) return; + const lastState = undoStack.current.pop(); + if (!lastState) return; + + redoStack.current.push({ + value: promptInput, + cursorPositionStart: textareaRef.current.selectionStart, + cursorPositionEnd: textareaRef.current.selectionEnd, + }); + setPromptInput(lastState.value); + setTimeout(() => { + textareaRef.current.setSelectionRange( + lastState.cursorPositionStart, + lastState.cursorPositionEnd + ); + }, 0); + } + } + + function adjustTextArea(event) { + const element = event.target; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + } + + function handlePasteEvent(e) { + e.preventDefault(); + if (e.clipboardData.items.length === 0) return false; + + // paste any clipboard items that are images. + for (const item of e.clipboardData.items) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + window.dispatchEvent( + new CustomEvent(PASTE_ATTACHMENT_EVENT, { + detail: { files: [file] }, + }) + ); + continue; + } + + // handle files specifically that are not images as uploads + if (item.kind === "file") { + const file = item.getAsFile(); + window.dispatchEvent( + new CustomEvent(PASTE_ATTACHMENT_EVENT, { + detail: { files: [file] }, + }) + ); + continue; + } + } + + const pasteText = e.clipboardData.getData("text/plain"); + if (pasteText) { + const textarea = textareaRef.current; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newPromptInput = + promptInput.substring(0, start) + + pasteText + + promptInput.substring(end); + setPromptInput(newPromptInput); + + // Set the cursor position after the pasted text + // we need to use setTimeout to prevent the cursor from being set to the end of the text + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = + start + pasteText.length; + adjustTextArea({ target: textarea }); + }, 0); + } + return; + } + + function handleChange(e) { + debouncedSaveState(-1); + adjustTextArea(e); + const value = e.target.value; + setPromptInput(value); + + // Auto-dismiss the tools menu when the "/" that opened it is modified + if (autoOpenedToolsRef.current && showTools && value !== "/") { + setShowTools(false); + autoOpenedToolsRef.current = false; + } + } + + return ( +
+
+
+
+ +
+ +
+ + +
+ + +
+ + + +
+ +
+
+
+
+ + +
+
+
No subagent runs yet.
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
+ + + + diff --git a/open-computer/services/public/vnc_lite.html b/open-computer/services/public/vnc_lite.html new file mode 100644 index 0000000..95e7132 --- /dev/null +++ b/open-computer/services/public/vnc_lite.html @@ -0,0 +1,49 @@ + + + + + Desktop + + + + +
+ + diff --git a/open-computer/services/start-service.sh b/open-computer/services/start-service.sh new file mode 100755 index 0000000..87030f5 --- /dev/null +++ b/open-computer/services/start-service.sh @@ -0,0 +1,6 @@ +#!/bin/bash +if [ -f /opt/open-computer/interface-service/index.js ]; then + exec nodemon --watch /opt/open-computer --ext js,json,ts --signal SIGTERM --legacy-watch --polling-interval 2000 /opt/open-computer/interface-service/index.js +else + exec node /opt/open-computer/interface-service.cjs +fi diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e54bbc --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "anything-llm", + "version": "1.15.0", + "description": "The best solution for turning private documents into a chat bot using off-the-shelf tools and commercially viable AI technologies.", + "main": "index.js", + "type": "module", + "author": "Timothy Carambat (Mintplex Labs)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/mintplex-labs/anything-llm.git" + }, + "bugs": { + "url": "https://github.com/mintplex-labs/anything-llm/issues" + }, + "homepage": "https://github.com/mintplex-labs/anything-llm#readme", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "jest", + "lint": "cd server && yarn lint && cd ../frontend && yarn lint && cd ../collector && yarn lint", + "lint:ci": "cd server && yarn lint:check && cd ../frontend && yarn lint:check && cd ../collector && yarn lint:check", + "setup": "cd server && yarn && cd ../collector && yarn && cd ../frontend && yarn && cd .. && yarn setup:envs && yarn prisma:setup && echo \"Please run yarn dev:server, yarn dev:collector, and yarn dev:frontend in separate terminal tabs.\"", + "setup:envs": "cp -n ./frontend/.env.example ./frontend/.env; cp -n ./server/.env.example ./server/.env.development; cp -n ./collector/.env.example ./collector/.env; cp -n ./docker/.env.example ./docker/.env; echo \"All ENV files copied!\n\"", + "dev:server": "cd server && yarn dev", + "dev:collector": "cd collector && yarn dev", + "dev:frontend": "cd frontend && yarn dev", + "dev": "npx concurrently \"yarn dev:server\" \"yarn dev:frontend\" \"yarn dev:collector\"", + "prisma:generate": "cd server && npx prisma generate", + "prisma:migrate": "cd server && npx prisma migrate dev --name init", + "prisma:seed": "cd server && npx prisma db seed", + "prisma:setup": "yarn prisma:generate && yarn prisma:migrate && yarn prisma:seed", + "prisma:reset": "truncate -s 0 server/storage/anythingllm.db && yarn prisma:migrate", + "prod:server": "cd server && yarn start", + "prod:frontend": "cd frontend && yarn build", + "generate:cloudformation": "node cloud-deployments/aws/cloudformation/generate.mjs", + "generate::gcp_deployment": "node cloud-deployments/gcp/deployment/generate.mjs", + "translations:verify": "cd frontend/src/locales && node verifyTranslations.mjs", + "translations:normalize": "cd frontend/src/locales && node normalizeEn.mjs && cd ../../.. && cd frontend && yarn lint && cd .. && yarn translations:verify", + "translations:prune": "cd frontend/src/locales && node findUnusedTranslations.mjs --delete && cd ../../../ && yarn translations:normalize", + "translations:create": "cd extras/translator && node index.mjs --all && cd ../../frontend && yarn lint && cd ../.." + }, + "private": false, + "devDependencies": { + "concurrently": "^9.1.2", + "jest": "^29.7.0" + } +} diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..128b9ad --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,41 @@ + +### Pull Request Type + + + +- [ ] ✨ feat (New feature) +- [ ] 🐛 fix (Bug fix) +- [ ] ♻️ refactor (Code refactoring without changing behavior) +- [ ] 💄 style (UI style changes) +- [ ] 🔨 chore (Build, CI, maintenance) +- [ ] 📝 docs (Documentation updates) + +### Relevant Issues + + + +resolves # + +### Description + + + + +### Visuals (if applicable) + + + + +### Additional Information + + + + +### Developer Validations + + + +- [ ] I ran `yarn lint` from the root of the repo & committed changes +- [ ] Relevant documentation has been updated (if applicable) +- [ ] I have tested my code functionality +- [ ] Docker build succeeds locally diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..c68033a --- /dev/null +++ b/server/.env.example @@ -0,0 +1,537 @@ +SERVER_PORT=3001 +JWT_SECRET="my-random-string-for-seeding" # Please generate random string at least 12 chars long. +# JWT_EXPIRY="30d" # (optional) https://docs.anythingllm.com/configuration#custom-ttl-for-sessions +SIG_KEY='passphrase' # Please generate random string at least 32 chars long. +SIG_SALT='salt' # Please generate random string at least 32 chars long. + +########################################### +######## LLM API SElECTION ################ +########################################### +# LLM_PROVIDER='openai' +# OPEN_AI_KEY= +# OPEN_MODEL_PREF='gpt-4.1-nano' + +# LLM_PROVIDER='gemini' +# GEMINI_API_KEY= +# GEMINI_LLM_MODEL_PREF='gemini-2.0-flash-lite' + +# LLM_PROVIDER='azure' +# AZURE_OPENAI_ENDPOINT= +# AZURE_OPENAI_KEY= +# AZURE_OPENAI_MODEL_PREF='my-gpt35-deployment' # This is the "deployment" on Azure you want to use. Not the base model. +# EMBEDDING_MODEL_PREF='embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002 + +# LLM_PROVIDER='anthropic' +# ANTHROPIC_API_KEY=sk-ant-xxxx +# ANTHROPIC_MODEL_PREF='claude-sonnet-4-6' +# ANTHROPIC_CACHE_CONTROL="5m" # Enable prompt caching (5m=5min cache, 1h=1hour cache). Reduces costs and improves speed by caching system prompts. + +# LLM_PROVIDER='lmstudio' +# LMSTUDIO_BASE_PATH='http://your-server:1234/v1' +# LMSTUDIO_MODEL_PREF='Loaded from Chat UI' # this is a bug in LMStudio 0.2.17 +# LMSTUDIO_MODEL_TOKEN_LIMIT=4096 +# LMSTUDIO_AUTH_TOKEN='your-lmstudio-auth-token-here' + +# LLM_PROVIDER='localai' +# LOCAL_AI_BASE_PATH='http://localhost:8080/v1' +# LOCAL_AI_MODEL_PREF='luna-ai-llama2' +# LOCAL_AI_MODEL_TOKEN_LIMIT=4096 +# LOCAL_AI_API_KEY="sk-123abc" + +# LLM_PROVIDER='ollama' +# OLLAMA_BASE_PATH='http://host.docker.internal:11434' +# OLLAMA_MODEL_PREF='llama2' +# OLLAMA_MODEL_TOKEN_LIMIT=4096 +# OLLAMA_AUTH_TOKEN='your-ollama-auth-token-here (optional, only for ollama running behind auth - Bearer token)' +# OLLAMA_RESPONSE_TIMEOUT=7200000 (optional, max timeout in milliseconds for ollama response to conclude. Default is 5min before aborting) + +# LLM_PROVIDER='togetherai' +# TOGETHER_AI_API_KEY='my-together-ai-key' +# TOGETHER_AI_MODEL_PREF='mistralai/Mixtral-8x7B-Instruct-v0.1' + +# LLM_PROVIDER='fireworksai' +# FIREWORKS_AI_LLM_API_KEY='my-fireworks-ai-key' +# FIREWORKS_AI_LLM_MODEL_PREF='accounts/fireworks/models/llama-v3p1-8b-instruct' + +# LLM_PROVIDER='perplexity' +# PERPLEXITY_API_KEY='my-perplexity-key' +# PERPLEXITY_MODEL_PREF='codellama-34b-instruct' + +# LLM_PROVIDER='deepseek' +# DEEPSEEK_API_KEY=YOUR_API_KEY +# DEEPSEEK_MODEL_PREF='deepseek-chat' + +# LLM_PROVIDER='openrouter' +# OPENROUTER_API_KEY='my-openrouter-key' +# OPENROUTER_MODEL_PREF='openrouter/auto' + +# LLM_PROVIDER='mistral' +# MISTRAL_API_KEY='example-mistral-ai-api-key' +# MISTRAL_MODEL_PREF='mistral-tiny' + +# LLM_PROVIDER='groq' +# GROQ_API_KEY=gsk_abcxyz +# GROQ_MODEL_PREF=llama3-8b-8192 + +# LLM_PROVIDER='koboldcpp' +# KOBOLD_CPP_BASE_PATH='http://127.0.0.1:5000/v1' +# KOBOLD_CPP_MODEL_PREF='koboldcpp/codellama-7b-instruct.Q4_K_S' +# KOBOLD_CPP_MODEL_TOKEN_LIMIT=4096 +# KOBOLD_CPP_MAX_TOKENS=2048 + +# LLM_PROVIDER='textgenwebui' +# TEXT_GEN_WEB_UI_BASE_PATH='http://127.0.0.1:5000/v1' +# TEXT_GEN_WEB_UI_TOKEN_LIMIT=4096 +# TEXT_GEN_WEB_UI_API_KEY='sk-123abc' + +# LLM_PROVIDER='generic-openai' +# GENERIC_OPEN_AI_BASE_PATH='http://proxy.url.openai.com/v1' +# GENERIC_OPEN_AI_MODEL_PREF='gpt-4.1-nano' +# GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=4096 +# GENERIC_OPEN_AI_API_KEY=sk-123abc +# GENERIC_OPEN_AI_CUSTOM_HEADERS="X-Custom-Auth:my-secret-key,X-Custom-Header:my-value" (useful if using a proxy that requires authentication or other headers) + +# LLM_PROVIDER='litellm' +# LITE_LLM_MODEL_PREF='gpt-4.1-nano' +# LITE_LLM_MODEL_TOKEN_LIMIT=4096 +# LITE_LLM_BASE_PATH='http://127.0.0.1:4000' +# LITE_LLM_API_KEY='sk-123abc' + +# LLM_PROVIDER='novita' +# NOVITA_LLM_API_KEY='your-novita-api-key-here' check on https://novita.ai/settings#key-management +# NOVITA_LLM_MODEL_PREF='deepseek/deepseek-r1' + +# LLM_PROVIDER='cohere' +# COHERE_API_KEY= +# COHERE_MODEL_PREF='command-r' + +# LLM_PROVIDER='cometapi' +# COMETAPI_LLM_API_KEY='your-cometapi-key-here' # Get one at https://api.cometapi.com/console/token +# COMETAPI_LLM_MODEL_PREF='gpt-5-mini' +# COMETAPI_LLM_TIMEOUT_MS=500 # Optional; stream idle timeout in ms (min 500ms) + +# LLM_PROVIDER='bedrock' +# AWS_BEDROCK_LLM_REGION=us-west-2 +# AWS_BEDROCK_API_KEY="xxxxxxx" +# AWS_BEDROCK_LLM_MODEL_PREFERENCE=global.anthropic.claude-haiku-4-5-20251001-v1:0 +# AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT='128000' +# AWS_BEDROCK_STREAMING_DISABLED=1 + +# LLM_PROVIDER='apipie' +# APIPIE_LLM_API_KEY='sk-123abc' +# APIPIE_LLM_MODEL_PREF='openrouter/llama-3.1-8b-instruct' + +# LLM_PROVIDER='xai' +# XAI_LLM_API_KEY='xai-your-api-key-here' +# XAI_LLM_MODEL_PREF='grok-beta' + +# LLM_PROVIDER='zai' +# ZAI_API_KEY="your-zai-api-key-here" +# ZAI_MODEL_PREF="glm-4.5" + +# LLM_PROVIDER='nvidia-nim' +# NVIDIA_NIM_LLM_BASE_PATH='http://127.0.0.1:8000' +# NVIDIA_NIM_LLM_MODEL_PREF='meta/llama-3.2-3b-instruct' + +# LLM_PROVIDER='ppio' +# PPIO_API_KEY='your-ppio-api-key-here' +# PPIO_MODEL_PREF='deepseek/deepseek-v3/community' + +# LLM_PROVIDER='moonshotai' +# MOONSHOT_AI_API_KEY='your-moonshot-api-key-here' +# MOONSHOT_AI_MODEL_PREF='moonshot-v1-32k' + +# LLM_PROVIDER='foundry' +# FOUNDRY_BASE_PATH='http://127.0.0.1:55776' +# FOUNDRY_MODEL_PREF='phi-3.5-mini' +# FOUNDRY_MODEL_TOKEN_LIMIT=4096 + +# LLM_PROVIDER='giteeai' +# GITEE_AI_API_KEY= +# GITEE_AI_MODEL_PREF= +# GITEE_AI_MODEL_TOKEN_LIMIT= + +# LLM_PROVIDER='docker-model-runner' +# DOCKER_MODEL_RUNNER_BASE_PATH='http://127.0.0.1:12434' +# DOCKER_MODEL_RUNNER_LLM_MODEL_PREF='phi-3.5-mini' +# DOCKER_MODEL_RUNNER_LLM_MODEL_TOKEN_LIMIT=4096 + +# LLM_PROVIDER='privatemode' +# PRIVATEMODE_LLM_BASE_PATH='http://127.0.0.1:8080' +# PRIVATEMODE_LLM_MODEL_PREF='gemma-3-27b' + +# LLM_PROVIDER='sambanova' +# SAMBANOVA_LLM_API_KEY='xxx-xxx-xxx' +# SAMBANOVA_LLM_MODEL_PREF='gpt-oss-120b' + +# LLM_PROVIDER='lemonade' +# LEMONADE_LLM_BASE_PATH='http://127.0.0.1:8000' +# LEMONADE_LLM_MODEL_PREF='Llama-3.2-1B-Instruct-GGUF' +# LEMONADE_LLM_MODEL_TOKEN_LIMIT=8192 +# LEMONADE_LLM_API_KEY= + +# LLM_PROVIDER='minimax' +# MINIMAX_API_KEY='sk-cp-...' +# MINIMAX_MODEL_PREF='MiniMax-M2.7' + +# LLM_PROVIDER='cerebras' +# CEREBRAS_API_KEY='your-cerebras-api-key-here' +# CEREBRAS_MODEL_PREF='gpt-oss-120b' + +# LLM_PROVIDER='anythingllm-router' +# MODEL_ROUTER_ID=1 + +########################################### +######## Embedding API SElECTION ########## +########################################### +# This will be the assumed default embedding seleciton and model +# EMBEDDING_ENGINE='native' +# EMBEDDING_MODEL_PREF='Xenova/all-MiniLM-L6-v2' + +# Only used if you are using an LLM that does not natively support embedding (openai or Azure) +# EMBEDDING_ENGINE='openai' +# OPEN_AI_KEY=sk-xxxx +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' + +# EMBEDDING_ENGINE='azure' +# AZURE_OPENAI_ENDPOINT= +# AZURE_OPENAI_KEY= +# EMBEDDING_MODEL_PREF='my-embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002 + +# EMBEDDING_ENGINE='localai' +# EMBEDDING_BASE_PATH='http://localhost:8080/v1' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=1000 # The max chunk size in chars a string to embed can be + +# EMBEDDING_ENGINE='ollama' +# EMBEDDING_BASE_PATH='http://127.0.0.1:11434' +# EMBEDDING_MODEL_PREF='nomic-embed-text:latest' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +# EMBEDDING_ENGINE='lmstudio' +# EMBEDDING_BASE_PATH='https://localhost:1234/v1' +# EMBEDDING_MODEL_PREF='nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.Q4_0.gguf' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +# EMBEDDING_ENGINE='cohere' +# COHERE_API_KEY= +# EMBEDDING_MODEL_PREF='embed-english-v3.0' + +# EMBEDDING_ENGINE='voyageai' +# VOYAGEAI_API_KEY= +# EMBEDDING_MODEL_PREF='voyage-large-2-instruct' + +# EMBEDDING_ENGINE='litellm' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 +# LITE_LLM_BASE_PATH='http://127.0.0.1:4000' +# LITE_LLM_API_KEY='sk-123abc' + +# EMBEDDING_ENGINE='generic-openai' +# EMBEDDING_MODEL_PREF='text-embedding-ada-002' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 +# EMBEDDING_BASE_PATH='http://127.0.0.1:4000' +# GENERIC_OPEN_AI_EMBEDDING_API_KEY='sk-123abc' +# GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=500 +# GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=1000 +# GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX= +# GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX= + +# EMBEDDING_ENGINE='gemini' +# GEMINI_EMBEDDING_API_KEY= +# EMBEDDING_MODEL_PREF='text-embedding-004' + +# EMBEDDING_ENGINE='openrouter' +# EMBEDDING_MODEL_PREF='baai/bge-m3' +# OPENROUTER_API_KEY='' + +# EMBEDDING_ENGINE='lemonade' +# EMBEDDING_BASE_PATH='http://127.0.0.1:8000' +# EMBEDDING_MODEL_PREF='Qwen3-embedder' +# EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192 + +########################################### +######## Vector Database Selection ######## +########################################### +# Enable all below if you are using vector database: Chroma. +# VECTOR_DB="chroma" +# CHROMA_ENDPOINT='http://localhost:8000' +# CHROMA_API_HEADER="X-Api-Key" +# CHROMA_API_KEY="sk-123abc" + +# Enable all below if you are using vector database: Chroma Cloud. +# VECTOR_DB="chromacloud" +# CHROMACLOUD_API_KEY="ck-your-api-key" +# CHROMACLOUD_TENANT= +# CHROMACLOUD_DATABASE= + +# Enable all below if you are using vector database: Pinecone. +# VECTOR_DB="pinecone" +# PINECONE_API_KEY= +# PINECONE_INDEX= + +# Enable all below if you are using vector database: Astra DB. +# VECTOR_DB="astra" +# ASTRA_DB_APPLICATION_TOKEN= +# ASTRA_DB_ENDPOINT= + +# Enable all below if you are using vector database: LanceDB. +VECTOR_DB="lancedb" + +# Enable all below if you are using vector database: PG Vector. +# VECTOR_DB="pgvector" +# PGVECTOR_CONNECTION_STRING="postgresql://dbuser:dbuserpass@localhost:5432/yourdb" +# PGVECTOR_TABLE_NAME="anythingllm_vectors" # optional, but can be defined + +# Enable all below if you are using vector database: Weaviate. +# VECTOR_DB="weaviate" +# WEAVIATE_ENDPOINT="http://localhost:8080" +# WEAVIATE_API_KEY= + +# Enable all below if you are using vector database: Qdrant. +# VECTOR_DB="qdrant" +# QDRANT_ENDPOINT="http://localhost:6333" +# QDRANT_API_KEY= + +# Enable all below if you are using vector database: Milvus. +# VECTOR_DB="milvus" +# MILVUS_ADDRESS="http://localhost:19530" +# MILVUS_USERNAME= +# MILVUS_PASSWORD= + +# Enable all below if you are using vector database: Zilliz Cloud. +# VECTOR_DB="zilliz" +# ZILLIZ_ENDPOINT="https://sample.api.gcp-us-west1.zillizcloud.com" +# ZILLIZ_API_TOKEN=api-token-here + +########################################### +######## Audio Model Selection ############ +########################################### +# (default) use built-in whisper-small model. +WHISPER_PROVIDER="local" + +# use openai hosted whisper model. +# WHISPER_PROVIDER="openai" +# OPEN_AI_KEY=sk-xxxxxxxx + +# use any OpenAI-compatible transcription endpoint. +# WHISPER_PROVIDER="generic-openai" +# WHISPER_GENERIC_OPEN_AI_BASE_URL="http://localhost:8000/v1" +# WHISPER_GENERIC_OPEN_AI_API_KEY=sk-xxxxxxxx +# WHISPER_GENERIC_OPEN_AI_MODEL="whisper-small" + +########################################### +######## TTS/STT Model Selection ########## +########################################### +TTS_PROVIDER="native" + +# TTS_PROVIDER="openai" +# TTS_OPEN_AI_KEY=sk-example +# TTS_OPEN_AI_VOICE_MODEL=nova + +# TTS_PROVIDER="elevenlabs" +# TTS_ELEVEN_LABS_KEY= +# TTS_ELEVEN_LABS_VOICE_MODEL=21m00Tcm4TlvDq8ikWAM # Rachel + +# TTS_PROVIDER="generic-openai" +# TTS_OPEN_AI_COMPATIBLE_KEY=sk-example +# TTS_OPEN_AI_COMPATIBLE_MODEL=tts-1 +# TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL=nova +# TTS_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1" + +# Self-hosted Kokoro via kokoro-fastapi (OpenAI-compatible). +# TTS_PROVIDER="kokoro" +# TTS_KOKORO_ENDPOINT="http://localhost:8880/v1" +# TTS_KOKORO_KEY= # optional, only if you front your server with auth +# TTS_KOKORO_VOICE_MODEL=af_bella + +STT_PROVIDER="native" + +# Server-side STT via OpenAI. Reuses OPEN_AI_KEY set above. +# STT_PROVIDER="openai" +# STT_OPEN_AI_MODEL=whisper-1 + +# Server-side STT via Lemonade. Reuses LEMONADE_LLM_API_KEY set above. +# STT_PROVIDER="lemonade" +# STT_LEMONADE_BASE_PATH='http://127.0.0.1:8000' +# STT_LEMONADE_MODEL_PREF=whisper-1 + +# STT_PROVIDER="deepgram" +# STT_DEEPGRAM_API_KEY= +# STT_DEEPGRAM_MODEL=nova-3 + +# STT_PROVIDER="generic-openai" +# STT_OPEN_AI_COMPATIBLE_KEY=sk-example +# STT_OPEN_AI_COMPATIBLE_MODEL=whisper-1 +# STT_OPEN_AI_COMPATIBLE_ENDPOINT="https://api.openai.com/v1" + +# STT_PROVIDER="groq" +# STT_GROQ_API_KEY=xxxxx +# STT_GROQ_MODEL=whisper-large-v3-turbo + +# CLOUD DEPLOYMENT VARIRABLES ONLY +# AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting. +# STORAGE_DIR= # absolute filesystem path with no trailing slash + +########################################### +######## PASSWORD COMPLEXITY ############## +########################################### +# Enforce a password schema for your organization users. +# Documentation on how to use https://github.com/kamronbatman/joi-password-complexity +#PASSWORDMINCHAR=8 +#PASSWORDMAXCHAR=250 +#PASSWORDLOWERCASE=1 +#PASSWORDUPPERCASE=1 +#PASSWORDNUMERIC=1 +#PASSWORDSYMBOL=1 +#PASSWORDREQUIREMENTS=4 + +########################################### +######## ENABLE HTTPS SERVER ############## +########################################### +# By enabling this and providing the path/filename for the key and cert, +# the server will use HTTPS instead of HTTP. +#ENABLE_HTTPS="true" +#HTTPS_CERT_PATH="sslcert/cert.pem" +#HTTPS_KEY_PATH="sslcert/key.pem" + +########################################### +######## AGENT SERVICE KEYS ############### +########################################### + +#------ SEARCH ENGINES ------- +#============================= +#------ Google Search -------- https://programmablesearchengine.google.com/controlpanel/create +# AGENT_GSE_KEY= +# AGENT_GSE_CTX= + +#------ SerpApi ----------- https://serpapi.com/ +# AGENT_SERPAPI_API_KEY= +# AGENT_SERPAPI_ENGINE=google + +#------ SearchApi.io ----------- https://www.searchapi.io/ +# AGENT_SEARCHAPI_API_KEY= +# AGENT_SEARCHAPI_ENGINE=google + +#------ Serper.dev ----------- https://serper.dev/ +# AGENT_SERPER_DEV_KEY= + +#------ Bing Search ----------- https://portal.azure.com/ +# AGENT_BING_SEARCH_API_KEY= + +#------ Baidu Search ----------- https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5 +# AGENT_BAIDU_SEARCH_API_KEY= + +#------ Serply.io ----------- https://serply.io/ +# AGENT_SERPLY_API_KEY= + +#------ SearXNG ----------- https://github.com/searxng/searxng +# AGENT_SEARXNG_API_URL= + +#------ Tavily ----------- https://www.tavily.com/ +# AGENT_TAVILY_API_KEY= + +#------ Exa Search ----------- https://www.exa.ai/ +# AGENT_EXA_API_KEY= + +#------ Perplexity Search ----------- [https://console.perplexity.ai](https://console.perplexity.ai) +# AGENT_PERPLEXITY_API_KEY= + +#------ fastCRW Search ----------- https://fastcrw.com/ +# AGENT_CRW_API_KEY= +# AGENT_CRW_API_URL= # Optional. Defaults to https://fastcrw.com/api. Set for self-hosted fastCRW. + +########################################### +######## Other Configurations ############ +########################################### + +# Disable viewing chat history from the UI and frontend APIs. +# See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. +# DISABLE_VIEW_CHAT_HISTORY=1 + +# Disable workspace deletion from the UI and APIs when this ENV is present with any value. +# WORKSPACE_DELETION_PROTECTION=1 + +# Enable simple SSO passthrough to pre-authenticate users from a third party service. +# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. +# SIMPLE_SSO_ENABLED=1 +# SIMPLE_SSO_NO_LOGIN=1 +# SIMPLE_SSO_NO_LOGIN_REDIRECT=https://your-custom-login-url.com (optional) + +# Allow scraping of any IP address in collector - must be string "true" to be enabled +# See https://docs.anythingllm.com/configuration#local-ip-address-scraping for more information. +# COLLECTOR_ALLOW_ANY_IP="true" + +# Port the collector listens on. Must match the collector process when running services separately. +# COLLECTOR_PORT=8888 + +# Specify the target languages for when using OCR to parse images and PDFs. +# This is a comma separated list of language codes as a string. Unsupported languages will be ignored. +# Default is English. See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html for a list of valid language codes. +# TARGET_OCR_LANG=eng,deu,ita,spa,fra,por,rus,nld,tur,hun,pol,ita,spa,fra,por,rus,nld,tur,hun,pol + +# Runtime flags for built-in pupeeteer Chromium instance +# This is only required on Linux machines running AnythingLLM via Docker +# and do not want to use the --cap-add=SYS_ADMIN docker argument +# ANYTHINGLLM_CHROMIUM_ARGS="--no-sandbox,--disable-setuid-sandbox" + +# This enables HTTP request/response logging in development. Set value to a truthy string to enable, leave empty value or comment out to disable. +# ENABLE_HTTP_LOGGER="" +# This enables timestamps for the HTTP Logger. Set value to a truthy string to enable, leave empty value or comment out to disable. +# ENABLE_HTTP_LOGGER_TIMESTAMPS="" + +# Disable Swagger API documentation endpoint. +# Set to "true" to disable the /api/docs endpoint (recommended for production deployments). +# DISABLE_SWAGGER_DOCS="true" + +# Disable MCP cooldown timer for agent calls +# this can lead to infinite recursive calls of the same function +# for some model/provider combinations +# MCP_NO_COOLDOWN="true + +# Force specific providers to use UnTooled (prompt-based) tool calling. +# Native tool calling is enabled by default for all providers that support it. +# Add a provider to this flag only if its native tool calling misbehaves and you want to fall back. +# PROVIDER_DISABLE_NATIVE_TOOL_CALLING="generic-openai,etc..." + +# (optional) Maximum number of tools an agent can chain for a single response. +# This prevents some lower-end models from infinite recursive tool calls. +# AGENT_MAX_TOOL_CALLS=10 + +# Agent tool reranking reduces token usage by selecting only the most relevant tools +# for each query. Uses the native embedding reranker to score tools against the user's prompt. +# This is enabled by default and can reduce token costs by 80% when you have +# many tools/MCP servers enabled. +# Set to "false" to disable. +# AGENT_SKILL_RERANKER_ENABLED="false" +# AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15) + +# (optional) Memory extraction background job settings. +# MEMORY_EXTRACTION_INTERVAL="15m" # How often the extraction job runs (default: 15m) +# MEMORY_IDLE_THRESHOLD_MS=1200000 # Min ms since last chat before extraction runs (default: 1200000 / 20min). Set to 0 to disable the idle check. + +# (optional) Maximum number of scheduled jobs that can run concurrently. +# Default is 1. Increase if using a cloud LLM provider with high rate limits. +# SCHEDULED_JOB_MAX_CONCURRENT=1 + +# (optional) Maximum time in milliseconds a scheduled job can run before being terminated. +# Default is 5 minutes (300000ms). +# SCHEDULED_JOB_TIMEOUT_MS=300000 + +# (optional) Comma-separated list of skills that are auto-approved. +# This will allow the skill to be invoked without user interaction. +# AGENT_AUTO_APPROVED_SKILLS=create-pdf-file,create-word-file + +# (optional) How long (in milliseconds) a watched document waits before it is +# re-synced by the document sync background worker. +# Default is 7 days (604800000ms). A minimum of 1 hour (3600000ms) is enforced. +# DOCUMENT_SYNC_STALE_AFTER_MS=604800000 + +########################################### +######## Embed Widget Security ############ +########################################### +# (Optional, hardening) When set to "true", public chat embed widgets that have +# NO allowed-domains allowlist configured will reject all requests instead of +# answering from any origin. Embeds that have an allowlist set are unaffected. +# Leaving this unset preserves the existing behavior. +# EMBED_REQUIRE_ALLOWLIST="true" diff --git a/server/.flowconfig b/server/.flowconfig new file mode 100644 index 0000000..866bdc0 --- /dev/null +++ b/server/.flowconfig @@ -0,0 +1,30 @@ +# How to config: https://flow.org/en/docs/config/ +[version] + +[options] +all=false +emoji=false +include_warnings=false +lazy_mode=false + +[include] + +[ignore] +.*/node_modules/resolve/test/.* + +[untyped] +# /src/models/.* + +[declarations] + +[libs] + +[lints] +all=warn + +[strict] +nonstrict-import +unclear-type +unsafe-getters-setters +untyped-import +untyped-type-import diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..9f9ef7a --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,36 @@ +.env.production +.env.development +.env.test +server/.env +storage/assets/* +!storage/assets/anything-llm.png +!storage/assets/anything-llm-invert.png +storage/documents/* +storage/comkey/* +storage/tmp/* +storage/vector-cache/*.json +storage/exports +storage/imports +storage/anythingllm-fs/* +storage/generated-files/* +storage/plugins/agent-skills/* +storage/plugins/agent-flows/* +storage/plugins/office-extensions/* +storage/plugins/anythingllm_mcp_servers.json +!storage/documents/DOCUMENTS.md +storage/push-notifications/* +storage/direct-uploads +logs/server.log +*.db +*.db-journal +storage/lancedb +public/ +__tests__/**/models + +# For legacy copies of repo +documents +vector-cache +yarn-error.log + +# Local SSL Certs for HTTPS +sslcert diff --git a/server/.nvmrc b/server/.nvmrc new file mode 100644 index 0000000..b714151 --- /dev/null +++ b/server/.nvmrc @@ -0,0 +1 @@ +v18.18.0 \ No newline at end of file diff --git a/server/__tests__/utils/MCP/hypervisor.test.js b/server/__tests__/utils/MCP/hypervisor.test.js new file mode 100644 index 0000000..7b5a8d4 --- /dev/null +++ b/server/__tests__/utils/MCP/hypervisor.test.js @@ -0,0 +1,156 @@ +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const http = require("http"); +const { + Server: MCPTestServer, +} = require("@modelcontextprotocol/sdk/server/index.js"); +const { + SSEServerTransport, +} = require("@modelcontextprotocol/sdk/server/sse.js"); +const { + ListToolsRequestSchema, +} = require("@modelcontextprotocol/sdk/types.js"); + +// pkce-challenge (used by the MCP SDK's OAuth support, which these tests +// never exercise) is ESM-only under the hood and cannot be loaded by jest +// without --experimental-vm-modules. +jest.mock("pkce-challenge", () => ({ + default: async () => ({ code_verifier: "", code_challenge: "" }), +})); + +const MCPHypervisor = require("../../../utils/MCP/hypervisor"); + +/** + * Minimal in-process SSE MCP server so the hypervisor has something real + * to connect to. GET opens the event stream, POST /messages carries the + * JSON-RPC messages. + */ +function startSSETestServer() { + const transports = {}; + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (req.method === "GET") { + const transport = new SSEServerTransport("/messages", res); + transports[transport.sessionId] = transport; + const mcpServer = new MCPTestServer( + { name: "test-sse-server", version: "1.0.0" }, + { capabilities: { tools: {} } } + ); + mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [], + })); + await mcpServer.connect(transport); + return; + } + + if (req.method === "POST" && url.pathname === "/messages") { + const transport = transports[url.searchParams.get("sessionId")]; + if (!transport) { + res.writeHead(400).end(); + return; + } + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => + transport.handlePostMessage(req, res, JSON.parse(body)) + ); + return; + } + + res.writeHead(404).end(); + }); + + return new Promise((resolve) => + httpServer.listen(0, () => resolve(httpServer)) + ); +} + +describe("MCPHypervisor server definition parsing & validation", () => { + let storageDir; + let hypervisor; + + beforeEach(() => { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-hypervisor-")); + process.env.STORAGE_DIR = storageDir; + MCPHypervisor._instance = undefined; + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + if (hypervisor) hypervisor.pruneMCPServers(); + MCPHypervisor._instance = undefined; + hypervisor = undefined; + delete process.env.STORAGE_DIR; + fs.rmSync(storageDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + function bootWithConfig(servers) { + fs.mkdirSync(path.join(storageDir, "plugins"), { recursive: true }); + fs.writeFileSync( + path.join(storageDir, "plugins", "anythingllm_mcp_servers.json"), + JSON.stringify({ mcpServers: servers }, null, 2) + ); + hypervisor = new MCPHypervisor(); + return hypervisor.bootMCPServers(); + } + + it("assumes SSE for a url-only server definition (issue #5917)", async () => { + const sseServer = await startSSETestServer(); + const port = sseServer.address().port; + + try { + const results = await bootWithConfig({ + "url-only": { url: `http://localhost:${port}` }, + }); + expect(results["url-only"].status).toBe("success"); + } finally { + if (typeof sseServer.closeAllConnections === "function") + sseServer.closeAllConnections(); + sseServer.close(); + } + }); + + it("rejects an explicit unknown type value", async () => { + const results = await bootWithConfig({ + "bogus-type": { type: "websocket", url: "http://localhost:1/mcp" }, + }); + expect(results["bogus-type"].status).toBe("failed"); + expect(results["bogus-type"].message).toMatch( + /type must have sse or streamable value/ + ); + }); + + it("rejects an http type definition without a url", async () => { + const results = await bootWithConfig({ + "missing-url": { type: "sse" }, + }); + expect(results["missing-url"].status).toBe("failed"); + expect(results["missing-url"].message).toMatch(/missing required "url"/); + }); + + it("rejects an http type definition with an invalid url", async () => { + const results = await bootWithConfig({ + "bad-url": { type: "streamable", url: "not a url" }, + }); + expect(results["bad-url"].status).toBe("failed"); + expect(results["bad-url"].message).toMatch(/invalid URL/); + }); + + it("rejects a definition with neither command nor url", async () => { + const results = await bootWithConfig({ + "empty-def": {}, + }); + expect(results["empty-def"].status).toBe("failed"); + expect(results["empty-def"].message).toMatch(/command or url is required/); + }); + + it("rejects a stdio definition with non-array args", async () => { + const results = await bootWithConfig({ + "bad-args": { command: "node", args: "not-an-array" }, + }); + expect(results["bad-args"].status).toBe("failed"); + expect(results["bad-args"].message).toMatch(/args must be an array/); + }); +}); diff --git a/server/__tests__/utils/MCP/index.test.js b/server/__tests__/utils/MCP/index.test.js new file mode 100644 index 0000000..8a6812e --- /dev/null +++ b/server/__tests__/utils/MCP/index.test.js @@ -0,0 +1,194 @@ +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const http = require("http"); +const { + Server: MCPTestServer, +} = require("@modelcontextprotocol/sdk/server/index.js"); +const { + SSEServerTransport, +} = require("@modelcontextprotocol/sdk/server/sse.js"); +const { + ListToolsRequestSchema, +} = require("@modelcontextprotocol/sdk/types.js"); + +// pkce-challenge (used by the MCP SDK's OAuth support, which these tests +// never exercise) is ESM-only under the hood and cannot be loaded by jest +// without --experimental-vm-modules. +jest.mock("pkce-challenge", () => ({ + default: async () => ({ code_verifier: "", code_challenge: "" }), +})); + +const MCPCompatibilityLayer = require("../../../utils/MCP"); +const MCPHypervisor = require("../../../utils/MCP/hypervisor"); + +const GOOD_TOOL = { + name: "echo", + description: "Echoes input back", + inputSchema: { type: "object", properties: { text: { type: "string" } } }, +}; + +// A tool whose outputSchema contains a $ref Ajv cannot resolve. The MCP +// SDK eagerly compiles outputSchema validators inside client.listTools(), +// so listing this tool throws - the exact failure from issue #5917 that +// used to hide the entire server list. +const BAD_REF_TOOL = { + name: "bad-ref", + description: "Advertises an unresolvable outputSchema $ref", + inputSchema: { type: "object", properties: {} }, + outputSchema: { + type: "object", + $ref: "#/definitions/does-not-exist", + }, +}; + +/** + * Minimal in-process SSE MCP server advertising the given tools. GET opens + * the event stream, POST /messages carries the JSON-RPC messages. + */ +function startSSETestServer(tools) { + const transports = {}; + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (req.method === "GET") { + const transport = new SSEServerTransport("/messages", res); + transports[transport.sessionId] = transport; + const mcpServer = new MCPTestServer( + { name: "test-sse-server", version: "1.0.0" }, + { capabilities: { tools: {} } } + ); + mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools, + })); + await mcpServer.connect(transport); + return; + } + + if (req.method === "POST" && url.pathname === "/messages") { + const transport = transports[url.searchParams.get("sessionId")]; + if (!transport) { + res.writeHead(400).end(); + return; + } + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => + transport.handlePostMessage(req, res, JSON.parse(body)) + ); + return; + } + + res.writeHead(404).end(); + }); + + return new Promise((resolve) => + httpServer.listen(0, () => resolve(httpServer)) + ); +} + +function stopSSETestServer(httpServer) { + if (typeof httpServer.closeAllConnections === "function") + httpServer.closeAllConnections(); + httpServer.close(); +} + +describe("MCPCompatibilityLayer.servers", () => { + let storageDir; + let mcpLayer; + + beforeEach(() => { + storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-compat-")); + process.env.STORAGE_DIR = storageDir; + MCPCompatibilityLayer._instance = undefined; + MCPHypervisor._instance = undefined; + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + if (mcpLayer) mcpLayer.pruneMCPServers(); + MCPCompatibilityLayer._instance = undefined; + MCPHypervisor._instance = undefined; + mcpLayer = undefined; + delete process.env.STORAGE_DIR; + fs.rmSync(storageDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + function writeMCPConfig(servers) { + fs.mkdirSync(path.join(storageDir, "plugins"), { recursive: true }); + fs.writeFileSync( + path.join(storageDir, "plugins", "anythingllm_mcp_servers.json"), + JSON.stringify({ mcpServers: servers }, null, 2) + ); + } + + it("keeps a server visible with its error when listTools throws on an unresolvable outputSchema $ref (issue #5917)", async () => { + const goodServer = await startSSETestServer([GOOD_TOOL]); + const badServer = await startSSETestServer([BAD_REF_TOOL]); + + try { + writeMCPConfig({ + "good-server": { + url: `http://localhost:${goodServer.address().port}`, + }, + "bad-ref-server": { + url: `http://localhost:${badServer.address().port}`, + }, + }); + mcpLayer = new MCPCompatibilityLayer(); + const servers = await mcpLayer.servers(); + + expect(servers).toHaveLength(2); + + const good = servers.find((s) => s.name === "good-server"); + expect(good.running).toBe(true); + expect(good.tools.map((t) => t.name)).toEqual(["echo"]); + expect(good.error).toBeNull(); + + const bad = servers.find((s) => s.name === "bad-ref-server"); + expect(bad.running).toBe(false); + expect(bad.tools).toEqual([]); + expect(bad.error).toMatch(/resolve reference/); + } finally { + stopSSETestServer(goodServer); + stopSSETestServer(badServer); + } + }); + + it("keeps a server visible with its error when ping fails after boot", async () => { + writeMCPConfig({}); + mcpLayer = new MCPCompatibilityLayer(); + + // Simulate a server that connected at boot but has since died - its + // ping() rejects. bootMCPServers() skips booting since mcps is populated. + mcpLayer.mcps = { + "dead-server": { + ping: () => Promise.reject(new Error("fetch failed")), + transport: { close: () => {} }, + close: () => {}, + }, + "alive-server": { + ping: () => Promise.resolve(true), + listTools: () => Promise.resolve({ tools: [GOOD_TOOL] }), + transport: { close: () => {} }, + close: () => {}, + }, + }; + mcpLayer.mcpLoadingResults = { + "dead-server": { status: "success", message: "connected" }, + "alive-server": { status: "success", message: "connected" }, + }; + + const servers = await mcpLayer.servers(); + + expect(servers).toHaveLength(2); + + const dead = servers.find((s) => s.name === "dead-server"); + expect(dead.running).toBe(false); + expect(dead.error).toBe("fetch failed"); + + const alive = servers.find((s) => s.name === "alive-server"); + expect(alive.running).toBe(true); + expect(alive.tools.map((t) => t.name)).toEqual(["echo"]); + }); +}); diff --git a/server/__tests__/utils/SQLConnectors/connectionParser.test.js b/server/__tests__/utils/SQLConnectors/connectionParser.test.js new file mode 100644 index 0000000..b984391 --- /dev/null +++ b/server/__tests__/utils/SQLConnectors/connectionParser.test.js @@ -0,0 +1,178 @@ +/* eslint-env jest */ +const { ConnectionStringParser } = require("../../../utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils"); + +describe("ConnectionStringParser", () => { + describe("Basic Parsing", () => { + test("should parse a basic connection string without options", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@localhost:1433/mydb"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "mydb", + options: undefined + }); + }); + + test("should parse a connection string with options", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@localhost:1433/mydb?encrypt=true&trustServerCertificate=true"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "mydb", + options: { + encrypt: "true", + trustServerCertificate: "true" + } + }); + }); + + test("should handle empty passwords", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user@localhost:1433/mydb"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: undefined, + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "mydb", + options: undefined + }); + }); + }); + + describe("Error Handling", () => { + test("should throw error for invalid scheme", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + expect(() => parser.parse("mysql://user:pass@localhost:3306/mydb")) + .toThrow("URI must start with 'mssql://'"); + }); + + test("should throw error for missing scheme", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + expect(() => parser.parse("user:pass@localhost:1433/mydb")) + .toThrow("No scheme found in URI"); + }); + }); + + describe("Special Characters", () => { + test("should handle special characters in username and password", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user%40domain:p%40ssw%3Ard@localhost:1433/mydb"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user@domain", + password: "p@ssw:rd", + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "mydb", + options: undefined + }); + }); + + test("should handle special characters in database name", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@localhost:1433/my%20db"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "my db", + options: undefined + }); + }); + }); + + describe("Multiple Hosts", () => { + test("should parse multiple hosts", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@host1:1433,host2:1434/mydb"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [ + { host: "host1", port: 1433 }, + { host: "host2", port: 1434 } + ], + endpoint: "mydb", + options: undefined + }); + }); + + test("should handle hosts without ports", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@host1,host2/mydb"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [ + { host: "host1" }, + { host: "host2" } + ], + endpoint: "mydb", + options: undefined + }); + }); + }); + + describe("Provider-Specific Tests", () => { + test("should parse MySQL connection string", () => { + const parser = new ConnectionStringParser({ scheme: "mysql" }); + const result = parser.parse("mysql://user:pass@localhost:3306/mydb?ssl=true"); + + expect(result).toEqual({ + scheme: "mysql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 3306 }], + endpoint: "mydb", + options: { ssl: "true" } + }); + }); + + test("should parse PostgreSQL connection string", () => { + const parser = new ConnectionStringParser({ scheme: "postgresql" }); + const result = parser.parse("postgresql://user:pass@localhost:5432/mydb?sslmode=require"); + + expect(result).toEqual({ + scheme: "postgresql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 5432 }], + endpoint: "mydb", + options: { sslmode: "require" } + }); + }); + + test("should parse MSSQL connection string with encryption options", () => { + const parser = new ConnectionStringParser({ scheme: "mssql" }); + const result = parser.parse("mssql://user:pass@localhost:1433/mydb?encrypt=true&trustServerCertificate=true"); + + expect(result).toEqual({ + scheme: "mssql", + username: "user", + password: "pass", + hosts: [{ host: "localhost", port: 1433 }], + endpoint: "mydb", + options: { + encrypt: "true", + trustServerCertificate: "true" + } + }); + }); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/TextSplitter/index.test.js b/server/__tests__/utils/TextSplitter/index.test.js new file mode 100644 index 0000000..8ca0160 --- /dev/null +++ b/server/__tests__/utils/TextSplitter/index.test.js @@ -0,0 +1,104 @@ +const { TextSplitter } = require("../../../utils/TextSplitter"); +const _ = require("lodash"); + +describe("TextSplitter", () => { + test("should split long text into n sized chunks", async () => { + const text = "This is a test text to be split into chunks".repeat(2); + const textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + }); + const chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + }); + + test("applies chunk overlap of 20 characters on invalid chunkOverlap", async () => { + const text = "This is a test text to be split into chunks".repeat(2); + const textSplitter = new TextSplitter({ + chunkSize: 30, + }); + const chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(6); + }); + + test("does not allow chunkOverlap to be greater than chunkSize", async () => { + expect(() => { + new TextSplitter({ + chunkSize: 20, + chunkOverlap: 21, + }); + }).toThrow(); + }); + + test("applies specific metadata to stringifyHeader to each chunk", async () => { + const metadata = { + id: "123e4567-e89b-12d3-a456-426614174000", + url: "https://example.com", + title: "Example", + docAuthor: "John Doe", + published: "2021-01-01", + chunkSource: "link://https://example.com", + description: "This is a test text to be split into chunks", + }; + const chunkHeaderMeta = TextSplitter.buildHeaderMeta(metadata); + expect(chunkHeaderMeta).toEqual({ + sourceDocument: metadata.title, + source: metadata.url, + published: metadata.published, + }); + }); + + test("applies a valid chunkPrefix to each chunk", async () => { + const text = "This is a test text to be split into chunks".repeat(2); + let textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + chunkPrefix: "testing: ", + }); + let chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + expect(chunks.every(chunk => chunk.startsWith("testing: "))).toBe(true); + + textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + chunkPrefix: "testing2: ", + }); + chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + expect(chunks.every(chunk => chunk.startsWith("testing2: "))).toBe(true); + + textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + chunkPrefix: undefined, + }); + chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + expect(chunks.every(chunk => !chunk.startsWith(": "))).toBe(true); + + textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + chunkPrefix: "", + }); + chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + expect(chunks.every(chunk => !chunk.startsWith(": "))).toBe(true); + + // Applied chunkPrefix with chunkHeaderMeta + textSplitter = new TextSplitter({ + chunkSize: 20, + chunkOverlap: 0, + chunkHeaderMeta: TextSplitter.buildHeaderMeta({ + title: "Example", + url: "https://example.com", + published: "2021-01-01", + }), + chunkPrefix: "testing3: ", + }); + chunks = await textSplitter.splitText(text); + expect(chunks.length).toEqual(5); + expect(chunks.every(chunk => chunk.startsWith("testing3: "))).toBe(true); + }); +}); diff --git a/server/__tests__/utils/agentFlows/executor.test.js b/server/__tests__/utils/agentFlows/executor.test.js new file mode 100644 index 0000000..e62c8b4 --- /dev/null +++ b/server/__tests__/utils/agentFlows/executor.test.js @@ -0,0 +1,93 @@ +const { FlowExecutor } = require("../../../utils/agentFlows/executor"); + +describe("FlowExecutor: getValueFromPath", () => { + const executor = new FlowExecutor(); + + it("can handle invalid objects", () => { + expect(executor.getValueFromPath(null, "a.b.c")).toBe(""); + expect(executor.getValueFromPath(undefined, "a.b.c")).toBe(""); + expect(executor.getValueFromPath(1, "a.b.c")).toBe(""); + expect(executor.getValueFromPath("string", "a.b.c")).toBe(""); + expect(executor.getValueFromPath(true, "a.b.c")).toBe(""); + }); + + it("can handle invalid paths", () => { + const obj = { a: { b: { c: "answer" } } }; + expect(executor.getValueFromPath(obj, -1)).toBe(""); + expect(executor.getValueFromPath(obj, undefined)).toBe(""); + expect(executor.getValueFromPath(obj, [1, 2, 3])).toBe(""); + expect(executor.getValueFromPath(obj, () => { })).toBe(""); + }); + + it("should be able to resolve a value from a dot path at various levels", () => { + let obj = { + a: { + prop: "top-prop", + b: { + c: "answer", + num: 100, + arr: [1, 2, 3], + subarr: [ + { id: 1, name: "answer2" }, + { id: 2, name: "answer3" }, + { id: 3, name: "answer4" }, + ] + } + } + }; + expect(executor.getValueFromPath(obj, "a.prop")).toBe("top-prop"); + expect(executor.getValueFromPath(obj, "a.b.c")).toBe("answer"); + expect(executor.getValueFromPath(obj, "a.b.num")).toBe(100); + expect(executor.getValueFromPath(obj, "a.b.arr[0]")).toBe(1); + expect(executor.getValueFromPath(obj, "a.b.arr[1]")).toBe(2); + expect(executor.getValueFromPath(obj, "a.b.arr[2]")).toBe(3); + expect(executor.getValueFromPath(obj, "a.b.subarr[0].id")).toBe(1); + expect(executor.getValueFromPath(obj, "a.b.subarr[0].name")).toBe("answer2"); + expect(executor.getValueFromPath(obj, "a.b.subarr[1].id")).toBe(2); + expect(executor.getValueFromPath(obj, "a.b.subarr[2].name")).toBe("answer4"); + expect(executor.getValueFromPath(obj, "a.b.subarr[2].id")).toBe(3); + }); + + it("should return empty string if the path is invalid", () => { + const result = executor.getValueFromPath({}, "a.b.c"); + expect(result).toBe(""); + }); + + it("should return empty string if the object is invalid", () => { + const result = executor.getValueFromPath(null, "a.b.c"); + expect(result).toBe(""); + }); + + it("can return a stringified item if the path target is not an object or array", () => { + const obj = { a: { b: { c: "answer", numbers: [1, 2, 3] } } }; + expect(executor.getValueFromPath(obj, "a.b")).toEqual(JSON.stringify(obj.a.b)); + expect(executor.getValueFromPath(obj, "a.b.numbers")).toEqual(JSON.stringify(obj.a.b.numbers)); + expect(executor.getValueFromPath(obj, "a.b.c")).toBe("answer"); + }); + + it("can return a stringified object if the path target is an array", () => { + const obj = { a: { b: [1, 2, 3] } }; + expect(executor.getValueFromPath(obj, "a.b")).toEqual(JSON.stringify(obj.a.b)); + expect(executor.getValueFromPath(obj, "a.b[0]")).toBe(1); + expect(executor.getValueFromPath(obj, "a.b[1]")).toBe(2); + expect(executor.getValueFromPath(obj, "a.b[2]")).toBe(3); + }); + + it("can find a value by string key traversal", () => { + const obj = { + a: { + items: [ + { + 'my-long-key': [ + { id: 1, name: "answer1" }, + { id: 2, name: "answer2" }, + { id: 3, name: "answer3" }, + ] + }, + ], + } + }; + expect(executor.getValueFromPath(obj, "a.items[0]['my-long-key'][1].id")).toBe(2); + expect(executor.getValueFromPath(obj, "a.items[0]['my-long-key'][1].name")).toBe("answer2"); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/agents/aibitat/emitter.test.js b/server/__tests__/utils/agents/aibitat/emitter.test.js new file mode 100644 index 0000000..66a5b69 --- /dev/null +++ b/server/__tests__/utils/agents/aibitat/emitter.test.js @@ -0,0 +1,150 @@ +process.env.STORAGE_DIR = __dirname; +process.env.NODE_ENV = "test"; + +const { EventEmitter } = require("events"); +const AIbitat = require("../../../../utils/agents/aibitat/index.js"); + +/** + * Tests for the EventEmitter memory leak fix (issue #3168). + * + * The AIbitat emitter used to be a plain `new EventEmitter()` with the + * default maxListeners of 10. When bulk-scraping many URLs, the web-scraping + * and summarize plugins each attach an "abort" listener per invocation via + * onAbort(), easily exceeding the limit and triggering a + * MaxListenersExceededWarning. + * + * The fix: + * 1. AIbitat.emitter now calls setMaxListeners(0) to disable the warning. + * 2. web-scraping.js and summarize.js use named listeners with + * .finally(cleanup) so they are removed after summarization completes. + */ + +function createAibitat() { + return new AIbitat({ + provider: "openai", + handlerProps: { log: () => {} }, + }); +} + +describe("AIbitat emitter – setMaxListeners", () => { + it("should have the default maxListeners (10) after construction", () => { + const instance = createAibitat(); + expect(instance.emitter.getMaxListeners()).toBe(10); + }); + + it("should not emit MaxListenersExceededWarning with many onAbort listeners", () => { + const instance = createAibitat(); + const warnings = []; + const originalEmit = process.emit; + process.emit = function (event, ...args) { + if ( + event === "warning" && + args[0]?.name === "MaxListenersExceededWarning" + ) { + warnings.push(args[0]); + } + return originalEmit.apply(process, [event, ...args]); + }; + + for (let i = 0; i < 50; i++) { + instance.onAbort(() => {}); + } + + process.emit = originalEmit; + expect(warnings).toHaveLength(0); + expect(instance.emitter.listenerCount("abort")).toBe(50); + }); + + it("a default EventEmitter WOULD trigger a warning with 11 listeners", () => { + const plain = new EventEmitter(); + expect(plain.getMaxListeners()).toBe(10); + + const warnings = []; + const originalEmit = process.emit; + process.emit = function (event, ...args) { + if ( + event === "warning" && + args[0]?.name === "MaxListenersExceededWarning" + ) { + warnings.push(args[0]); + } + return originalEmit.apply(process, [event, ...args]); + }; + + for (let i = 0; i < 11; i++) { + plain.on("abort", () => {}); + } + + process.emit = originalEmit; + expect(plain.listenerCount("abort")).toBe(11); + }); +}); + +describe("AIbitat abort listener cleanup pattern", () => { + it("should remove the abort listener after summarization resolves", async () => { + const instance = createAibitat(); + + const controller = new AbortController(); + const abortListener = () => controller.abort(); + instance.emitter.on("abort", abortListener); + const cleanup = () => + instance.emitter.removeListener("abort", abortListener); + + expect(instance.emitter.listenerCount("abort")).toBe(1); + + await Promise.resolve("summary result").finally(cleanup); + + expect(instance.emitter.listenerCount("abort")).toBe(0); + }); + + it("should remove the abort listener even if summarization rejects", async () => { + const instance = createAibitat(); + + const controller = new AbortController(); + const abortListener = () => controller.abort(); + instance.emitter.on("abort", abortListener); + const cleanup = () => + instance.emitter.removeListener("abort", abortListener); + + expect(instance.emitter.listenerCount("abort")).toBe(1); + + await Promise.reject(new Error("summarization failed")) + .finally(cleanup) + .catch(() => {}); + + expect(instance.emitter.listenerCount("abort")).toBe(0); + }); + + it("should not accumulate listeners across 20 sequential scrape calls", async () => { + const instance = createAibitat(); + + for (let i = 0; i < 20; i++) { + const controller = new AbortController(); + const abortListener = () => controller.abort(); + instance.emitter.on("abort", abortListener); + const cleanup = () => + instance.emitter.removeListener("abort", abortListener); + await Promise.resolve("scrape result").finally(cleanup); + } + + expect(instance.emitter.listenerCount("abort")).toBe(0); + }); + + it("abort() should invoke the listener before cleanup removes it", async () => { + const instance = createAibitat(); + let aborted = false; + + const abortListener = () => { + aborted = true; + }; + instance.emitter.on("abort", abortListener); + const cleanup = () => + instance.emitter.removeListener("abort", abortListener); + + instance.abort(); + expect(aborted).toBe(true); + + await Promise.resolve().finally(cleanup); + expect(instance.emitter.listenerCount("abort")).toBe(0); + }); +}); diff --git a/server/__tests__/utils/agents/aibitat/plugins/create-files/lib.test.js b/server/__tests__/utils/agents/aibitat/plugins/create-files/lib.test.js new file mode 100644 index 0000000..bee630d --- /dev/null +++ b/server/__tests__/utils/agents/aibitat/plugins/create-files/lib.test.js @@ -0,0 +1,86 @@ +/* eslint-env jest */ +const createFilesLib = require("../../../../../../utils/agents/aibitat/plugins/create-files/lib.js"); + +describe("CreateFilesManager.stripInvalidXmlChars", () => { + test("removes the form feed produced by a LaTeX backslash sequence", () => { + // `\frac` arrives as a JSON "\f" escape that decodes to U+000C (form feed), + // which is illegal in XML 1.0 and corrupts OOXML documents. + const content = "En la fracción $\x0Crac{3}{5}$"; + const cleaned = createFilesLib.stripInvalidXmlChars(content); + expect(cleaned).toBe("En la fracción $rac{3}{5}$"); + expect(cleaned).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F]/); + }); + + test("strips every disallowed C0 control character", () => { + const dirty = "a\x00b\x08c\x0Bd\x0Ce\x1Ff"; + expect(createFilesLib.stripInvalidXmlChars(dirty)).toBe("abcdef"); + }); + + test("preserves tab, line feed, and carriage return (the legal C0 chars)", () => { + const content = "line1\tcol2\nline2\r\nline3"; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("leaves clean strings unchanged", () => { + const content = "# Title\n\nA normal paragraph with **bold** text."; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("preserves typical markdown document content", () => { + const content = [ + "# Quarterly Report\n", + "## Summary\n", + "Revenue grew **15%** year-over-year.\n", + "- Item 1: $1,200\n- Item 2: $3,400\n", + "| Column A | Column B |\n|----------|----------|\n| value | value |", + ].join("\n"); + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("preserves unicode, accented characters, and emoji", () => { + const content = "Ñoño résumé naïve — «quotes» 日本語 🎉👍"; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("preserves HTML tags that appear in rich content", () => { + const content = + '

Title

\n

Hello & goodbye

'; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("preserves code blocks and special syntax", () => { + const content = + "```javascript\nconst x = () => { return 42; };\n```\n\n$E = mc^2$"; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("preserves backslash sequences that are NOT control characters", () => { + const content = + "Use \\textbf{bold} and \\newline and C:\\Users\\file.txt"; + expect(createFilesLib.stripInvalidXmlChars(content)).toBe(content); + }); + + test("recursively cleans arrays and nested objects", () => { + const sheets = [ + { + name: "Sheet\x0C1", + csvData: "a,b\n1\x00,2", + options: { headerStyle: true, autoFit: 1 }, + }, + ]; + expect(createFilesLib.stripInvalidXmlChars(sheets)).toEqual([ + { + name: "Sheet1", + csvData: "a,b\n1,2", + options: { headerStyle: true, autoFit: 1 }, + }, + ]); + }); + + test("returns non-string scalars untouched", () => { + expect(createFilesLib.stripInvalidXmlChars(null)).toBeNull(); + expect(createFilesLib.stripInvalidXmlChars(undefined)).toBeUndefined(); + expect(createFilesLib.stripInvalidXmlChars(42)).toBe(42); + expect(createFilesLib.stripInvalidXmlChars(true)).toBe(true); + }); +}); diff --git a/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js b/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js new file mode 100644 index 0000000..a3364f9 --- /dev/null +++ b/server/__tests__/utils/agents/aibitat/providers/helpers/untooled.test.js @@ -0,0 +1,87 @@ +const UnTooled = require("../../../../../../utils/agents/aibitat/providers/helpers/untooled"); + +describe("UnTooled: validFuncCall", () => { + const untooled = new UnTooled(); + const validFunc = { + "name": "brave-search-brave_web_search", + "description": "Example function", + "parameters": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (max 400 chars, 50 words)" + }, + "count": { + "type": "number", + "description": "Number of results (1-20, default 10)", + "default": 10 + }, + "offset": { + "type": "number", + "description": "Pagination offset (max 9, default 0)", + "default": 0 + } + }, + "required": [ + "query" + ] + } + }; + + it("Be truthy if the function call is valid and has all required arguments", () => { + const result = untooled.validFuncCall( + { + name: validFunc.name, + arguments: { query: "test" }, + }, [validFunc]); + expect(result.valid).toBe(true); + expect(result.reason).toBe(null); + }); + + it("Be falsey if the function call has no name or arguments", () => { + const result = untooled.validFuncCall( + { arguments: {} }, [validFunc]); + expect(result.valid).toBe(false); + expect(result.reason).toBe("Missing name or arguments in function call."); + + const result2 = untooled.validFuncCall( + { name: validFunc.name }, [validFunc]); + expect(result2.valid).toBe(false); + expect(result2.reason).toBe("Missing name or arguments in function call."); + }); + + it("Be falsey if the function call references an unknown function definition", () => { + const result = untooled.validFuncCall( + { + name: "unknown-function", + arguments: {}, + }, [validFunc]); + expect(result.valid).toBe(false); + expect(result.reason).toBe("Function name does not exist."); + }); + + it("Be falsey if the function call is valid but missing any required arguments", () => { + const result = untooled.validFuncCall( + { + name: validFunc.name, + arguments: {}, + }, [validFunc]); + expect(result.valid).toBe(false); + expect(result.reason).toBe("Missing required argument: query"); + }); + + it("Be falsey if the function call is valid but has an unknown argument defined (required or not)", () => { + const result = untooled.validFuncCall( + { + name: validFunc.name, + arguments: { + query: "test", + unknown: "unknown", + }, + }, [validFunc]); + expect(result.valid).toBe(false); + expect(result.reason).toBe("Unknown argument: unknown provided but not in schema."); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/agents/defaults.test.js b/server/__tests__/utils/agents/defaults.test.js new file mode 100644 index 0000000..b3992a9 --- /dev/null +++ b/server/__tests__/utils/agents/defaults.test.js @@ -0,0 +1,131 @@ +// Set required env vars before requiring modules +process.env.STORAGE_DIR = __dirname; +process.env.NODE_ENV = "test"; + +const { SystemPromptVariables } = require("../../../models/systemPromptVariables"); +const Provider = require("../../../utils/agents/aibitat/providers/ai-provider"); + +jest.mock("../../../models/systemPromptVariables"); +jest.mock("../../../models/systemSettings"); +jest.mock("../../../utils/agents/imported", () => ({ + activeImportedPlugins: jest.fn().mockReturnValue([]), +})); +jest.mock("../../../utils/agentFlows", () => ({ + AgentFlows: { + activeFlowPlugins: jest.fn().mockReturnValue([]), + }, +})); +jest.mock("../../../utils/MCP", () => { + return jest.fn().mockImplementation(() => ({ + activeMCPServers: jest.fn().mockResolvedValue([]), + })); +}); + +const { WORKSPACE_AGENT } = require("../../../utils/agents/defaults"); + +describe("WORKSPACE_AGENT.getDefinition", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock SystemSettings to return empty arrays for agent skills + const { SystemSettings } = require("../../../models/systemSettings"); + SystemSettings.getValueOrFallback = jest.fn().mockResolvedValue("[]"); + }); + + it("should use provider default system prompt when workspace has no openAiPrompt", async () => { + const workspace = { + id: 1, + name: "Test Workspace", + openAiPrompt: null, + }; + const user = { id: 1 }; + const provider = "openai"; + const expectedPrompt = await Provider.systemPrompt({ provider, workspace, user }); + const definition = await WORKSPACE_AGENT.getDefinition( + provider, + workspace, + user + ); + expect(definition.role).toBe(expectedPrompt); + expect(SystemPromptVariables.expandSystemPromptVariables).not.toHaveBeenCalled(); + }); + + it("should use workspace system prompt with variable expansion when openAiPrompt exists", async () => { + const workspace = { + id: 1, + name: "Test Workspace", + openAiPrompt: "You are a helpful assistant for {workspace.name}. The current user is {user.name}.", + }; + const user = { id: 1 }; + const provider = "openai"; + + const expandedPrompt = "You are a helpful assistant for Test Workspace. The current user is John Doe."; + SystemPromptVariables.expandSystemPromptVariables.mockResolvedValue(expandedPrompt); + + const definition = await WORKSPACE_AGENT.getDefinition( + provider, + workspace, + user + ); + + expect(SystemPromptVariables.expandSystemPromptVariables).toHaveBeenCalledWith( + workspace.openAiPrompt, + user.id, + workspace.id + ); + expect(definition.role).toBe(expandedPrompt); + }); + + it("should handle workspace system prompt without user context", async () => { + const workspace = { + id: 1, + name: "Test Workspace", + openAiPrompt: "You are a helpful assistant. Today is {date}.", + }; + const user = null; + const provider = "lmstudio"; + const expandedPrompt = "You are a helpful assistant. Today is January 1, 2024."; + SystemPromptVariables.expandSystemPromptVariables.mockResolvedValue(expandedPrompt); + + const definition = await WORKSPACE_AGENT.getDefinition( + provider, + workspace, + user + ); + + expect(SystemPromptVariables.expandSystemPromptVariables).toHaveBeenCalledWith( + workspace.openAiPrompt, + null, + workspace.id + ); + expect(definition.role).toBe(expandedPrompt); + }); + + it("should return functions array in definition", async () => { + const workspace = { id: 1, openAiPrompt: null }; + const provider = "openai"; + + const definition = await WORKSPACE_AGENT.getDefinition( + provider, + workspace, + null + ); + + expect(definition).toHaveProperty("functions"); + expect(Array.isArray(definition.functions)).toBe(true); + }); + + it("should use LMStudio specific prompt when workspace has no openAiPrompt", async () => { + const workspace = { id: 1, openAiPrompt: null }; + const user = null; + const provider = "lmstudio"; + const definition = await WORKSPACE_AGENT.getDefinition( + provider, + workspace, + null + ); + + expect(definition.role).toBe(await Provider.systemPrompt({ provider, workspace, user })); + expect(definition.role).toContain("helpful ai assistant"); + }); +}); + diff --git a/server/__tests__/utils/agents/imported.test.js b/server/__tests__/utils/agents/imported.test.js new file mode 100644 index 0000000..f4749f2 --- /dev/null +++ b/server/__tests__/utils/agents/imported.test.js @@ -0,0 +1,130 @@ +// Set required env vars before requiring modules. +// imported.js resolves its plugins path from STORAGE_DIR at require time. +process.env.STORAGE_DIR = __dirname; +process.env.NODE_ENV = "test"; + +const ImportedPlugin = require("../../../utils/agents/imported"); + +/** + * Build an ImportedPlugin instance WITHOUT touching disk. The real constructor + * require()s a handler.js file, but plugin() only reads this.handler, + * this.name, and this.config - so we bypass the constructor and set those + * three directly. + */ +function makePlugin({ + hubId = "acme/my-skill", + name = "My Skill", + handlerRuntime = { handler: async () => "ok" }, +} = {}) { + const instance = Object.create(ImportedPlugin.prototype); + instance.config = { + hubId, + name, + description: "Test skill", + entrypoint: { params: {} }, + }; + instance.name = hubId; + instance.handler = { runtime: handlerRuntime }; + return instance; +} + +/** + * Run the plugin's setup() against a fake aibitat and return the function + * config that was registered (its properties become the handler's `this`). + */ +function registerAndGetFn(plugin, aibitatOverrides = {}) { + let registered = null; + const fakeAibitat = { + function(config) { + registered = config; + return this; + }, + handlerProps: { log: jest.fn() }, + introspect: jest.fn(), + ...aibitatOverrides, + }; + + const built = plugin.plugin(); + built.setup(fakeAibitat); + return { fn: registered, aibitat: fakeAibitat }; +} + +describe("ImportedPlugin custom skill - requestToolApproval helper", () => { + it("exposes requestToolApproval on the registered function config", () => { + const { fn } = registerAndGetFn(makePlugin()); + expect(typeof fn.requestToolApproval).toBe("function"); + }); + + it("forces skillName to the skill's display name and passes payload/description through", async () => { + const spy = jest.fn().mockResolvedValue({ approved: true, message: "ok" }); + const { fn } = registerAndGetFn(makePlugin({ hubId: "acme/cleaner", name: "Acme Cleaner" }), { + requestToolApproval: spy, + }); + + const result = await fn.requestToolApproval({ + skillName: "gmail-send-email", // spoof attempt - must be ignored + payload: { recordId: 42 }, + description: "Delete record 42?", + }); + + expect(spy).toHaveBeenCalledWith({ + skillName: "Acme Cleaner", + payload: { recordId: 42 }, + description: "Delete record 42?", + }); + expect(result).toEqual({ approved: true, message: "ok" }); + }); + + it("defaults payload to {} and description to null when called with no args", async () => { + const spy = jest.fn().mockResolvedValue({ approved: true, message: "ok" }); + const { fn } = registerAndGetFn(makePlugin({ hubId: "acme/thing", name: "Acme Thing" }), { + requestToolApproval: spy, + }); + + await fn.requestToolApproval(); + + expect(spy).toHaveBeenCalledWith({ + skillName: "Acme Thing", + payload: {}, + description: null, + }); + }); + + it("resolves approved (does not throw) when aibitat has no approval channel", async () => { + // fakeAibitat here has no requestToolApproval (non-interactive context). + const { fn } = registerAndGetFn(makePlugin()); + + const result = await fn.requestToolApproval({ description: "anything" }); + + expect(result).toEqual({ + approved: true, + message: "Approval not required in this context.", + }); + }); + + it("cannot be overridden by the skill's own handler.js exports", async () => { + // A handler that tries to shadow the real helper to bypass approval. + const malicious = jest + .fn() + .mockResolvedValue({ approved: true, message: "spoofed" }); + const realChannel = jest + .fn() + .mockResolvedValue({ approved: false, message: "real" }); + + const { fn } = registerAndGetFn( + makePlugin({ + handlerRuntime: { + handler: async () => "ok", + requestToolApproval: malicious, + }, + }), + { requestToolApproval: realChannel } + ); + + const result = await fn.requestToolApproval({ description: "x" }); + + expect(malicious).not.toHaveBeenCalled(); + expect(realChannel).toHaveBeenCalled(); + expect(result).toEqual({ approved: false, message: "real" }); + }); +}); diff --git a/server/__tests__/utils/chats/openaiCompatible.test.js b/server/__tests__/utils/chats/openaiCompatible.test.js new file mode 100644 index 0000000..1977cc9 --- /dev/null +++ b/server/__tests__/utils/chats/openaiCompatible.test.js @@ -0,0 +1,254 @@ +/* eslint-env jest, node */ +const { OpenAICompatibleChat } = require('../../../utils/chats/openaiCompatible'); +const { WorkspaceChats } = require('../../../models/workspaceChats'); +const { getVectorDbClass, getLLMProvider, resolveProviderConnector } = require('../../../utils/helpers'); +const { extractTextContent, extractAttachments } = require('../../../endpoints/api/openai/helpers'); + +// Mock dependencies +jest.mock('../../../models/workspaceChats'); +jest.mock('../../../utils/helpers'); +jest.mock('../../../utils/DocumentManager', () => ({ + DocumentManager: class { + constructor() { + this.pinnedDocs = jest.fn().mockResolvedValue([]); + } + } +})); + +describe('OpenAICompatibleChat', () => { + let mockWorkspace; + let mockVectorDb; + let mockLLMConnector; + let mockResponse; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Setup mock workspace + mockWorkspace = { + id: 1, + slug: 'test-workspace', + chatMode: 'chat', + chatProvider: 'openai', + chatModel: 'gpt-4', + }; + + // Setup mock VectorDb + mockVectorDb = { + hasNamespace: jest.fn().mockResolvedValue(true), + namespaceCount: jest.fn().mockResolvedValue(1), + performSimilaritySearch: jest.fn().mockResolvedValue({ + contextTexts: [], + sources: [], + message: null, + }), + }; + getVectorDbClass.mockReturnValue(mockVectorDb); + + // Setup mock LLM connector + mockLLMConnector = { + promptWindowLimit: jest.fn().mockReturnValue(4000), + compressMessages: jest.fn().mockResolvedValue([]), + getChatCompletion: jest.fn().mockResolvedValue({ + textResponse: 'Mock response', + metrics: {}, + }), + streamingEnabled: jest.fn().mockReturnValue(true), + streamGetChatCompletion: jest.fn().mockResolvedValue({ + metrics: {}, + }), + handleStream: jest.fn().mockResolvedValue('Mock streamed response'), + defaultTemp: 0.7, + }; + getLLMProvider.mockReturnValue(mockLLMConnector); + resolveProviderConnector.mockResolvedValue({ + connector: mockLLMConnector, + routingMetadata: null, + prefetchedContext: null, + }); + + // Setup WorkspaceChats mock + WorkspaceChats.new.mockResolvedValue({ chat: { id: 'mock-chat-id' } }); + + // Setup mock response object for streaming + mockResponse = { + write: jest.fn(), + }; + }); + + describe('chatSync', () => { + test('should handle OpenAI vision multimodal messages', async () => { + const multiModalPrompt = [ + { + type: 'text', + text: 'What do you see in this image?' + }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,abc123', + detail: 'low' + } + } + ]; + + const prompt = extractTextContent(multiModalPrompt); + const attachments = extractAttachments(multiModalPrompt); + const result = await OpenAICompatibleChat.chatSync({ + workspace: mockWorkspace, + prompt, + attachments, + systemPrompt: 'You are a helpful assistant', + history: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' } + ], + temperature: 0.7 + }); + + // Verify chat was saved with correct format + expect(WorkspaceChats.new).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockWorkspace.id, + prompt: multiModalPrompt[0].text, + response: expect.objectContaining({ + text: 'Mock response', + attachments: [{ + name: 'uploaded_image_0', + mime: 'image/png', + contentString: multiModalPrompt[1].image_url.url + }] + }) + }) + ); + + // Verify response format + expect(result).toEqual( + expect.objectContaining({ + object: 'chat.completion', + choices: expect.arrayContaining([ + expect.objectContaining({ + message: expect.objectContaining({ + role: 'assistant', + content: 'Mock response', + }), + }), + ]), + }) + ); + }); + + test('should handle regular text messages in OpenAI format', async () => { + const promptString = 'Hello world'; + const result = await OpenAICompatibleChat.chatSync({ + workspace: mockWorkspace, + prompt: promptString, + systemPrompt: 'You are a helpful assistant', + history: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' } + ], + temperature: 0.7 + }); + + // Verify chat was saved without attachments + expect(WorkspaceChats.new).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockWorkspace.id, + prompt: promptString, + response: expect.objectContaining({ + text: 'Mock response', + attachments: [] + }) + }) + ); + + expect(result).toBeTruthy(); + }); + }); + + describe('streamChat', () => { + test('should handle OpenAI vision multimodal messages in streaming mode', async () => { + const multiModalPrompt = [ + { + type: 'text', + text: 'What do you see in this image?' + }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,abc123', + detail: 'low' + } + } + ]; + + const prompt = extractTextContent(multiModalPrompt); + const attachments = extractAttachments(multiModalPrompt); + await OpenAICompatibleChat.streamChat({ + workspace: mockWorkspace, + response: mockResponse, + prompt, + attachments, + systemPrompt: 'You are a helpful assistant', + history: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' } + ], + temperature: 0.7 + }); + + // Verify streaming was handled + expect(mockLLMConnector.streamGetChatCompletion).toHaveBeenCalled(); + expect(mockLLMConnector.handleStream).toHaveBeenCalled(); + + // Verify chat was saved with attachments + expect(WorkspaceChats.new).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockWorkspace.id, + prompt: multiModalPrompt[0].text, + response: expect.objectContaining({ + text: 'Mock streamed response', + attachments: [{ + name: 'uploaded_image_0', + mime: 'image/png', + contentString: multiModalPrompt[1].image_url.url + }] + }) + }) + ); + }); + + test('should handle regular text messages in streaming mode', async () => { + const promptString = 'Hello world'; + await OpenAICompatibleChat.streamChat({ + workspace: mockWorkspace, + response: mockResponse, + prompt: promptString, + systemPrompt: 'You are a helpful assistant', + history: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' } + ], + temperature: 0.7 + }); + + // Verify streaming was handled + expect(mockLLMConnector.streamGetChatCompletion).toHaveBeenCalled(); + expect(mockLLMConnector.handleStream).toHaveBeenCalled(); + + // Verify chat was saved without attachments + expect(WorkspaceChats.new).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockWorkspace.id, + prompt: promptString, + response: expect.objectContaining({ + text: 'Mock streamed response', + attachments: [] + }) + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/chats/openaiHelpers.test.js b/server/__tests__/utils/chats/openaiHelpers.test.js new file mode 100644 index 0000000..5eba2e1 --- /dev/null +++ b/server/__tests__/utils/chats/openaiHelpers.test.js @@ -0,0 +1,128 @@ +/* eslint-env jest, node */ +const { extractTextContent, extractAttachments } = require('../../../endpoints/api/openai/helpers'); + +describe('OpenAI Helper Functions', () => { + describe('extractTextContent', () => { + test('should return string content as-is when not an array', () => { + const content = 'Hello world'; + expect(extractTextContent(content)).toBe('Hello world'); + }); + + test('should extract text from multi-modal content array', () => { + const content = [ + { + type: 'text', + text: 'What do you see in this image?' + }, + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,abc123', + detail: 'low' + } + }, + { + type: 'text', + text: 'And what about this part?' + } + ]; + expect(extractTextContent(content)).toBe('What do you see in this image?\nAnd what about this part?'); + }); + + test('should handle empty array', () => { + expect(extractTextContent([])).toBe(''); + }); + + test('should handle array with no text content', () => { + const content = [ + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,abc123', + detail: 'low' + } + } + ]; + expect(extractTextContent(content)).toBe(''); + }); + }); + + describe('extractAttachments', () => { + test('should return empty array for string content', () => { + const content = 'Hello world'; + expect(extractAttachments(content)).toEqual([]); + }); + + test('should extract image attachments with correct mime types', () => { + const content = [ + { + type: 'image_url', + image_url: { + url: 'data:image/png;base64,abc123', + detail: 'low' + } + }, + { + type: 'text', + text: 'Between images' + }, + { + type: 'image_url', + image_url: { + url: 'data:image/jpeg;base64,def456', + detail: 'high' + } + } + ]; + expect(extractAttachments(content)).toEqual([ + { + name: 'uploaded_image_0', + mime: 'image/png', + contentString: 'data:image/png;base64,abc123' + }, + { + name: 'uploaded_image_1', + mime: 'image/jpeg', + contentString: 'data:image/jpeg;base64,def456' + } + ]); + }); + + test('should handle invalid data URLs with PNG fallback', () => { + const content = [ + { + type: 'image_url', + image_url: { + url: 'invalid-data-url', + detail: 'low' + } + } + ]; + expect(extractAttachments(content)).toEqual([ + { + name: 'uploaded_image_0', + mime: 'image/png', + contentString: 'invalid-data-url' + } + ]); + }); + + test('should handle empty array', () => { + expect(extractAttachments([])).toEqual([]); + }); + + test('should handle array with no image content', () => { + const content = [ + { + type: 'text', + text: 'Just some text' + }, + { + type: 'text', + text: 'More text' + } + ]; + expect(extractAttachments(content)).toEqual([]); + }); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/files/isWithin.test.js b/server/__tests__/utils/files/isWithin.test.js new file mode 100644 index 0000000..f2ebb3b --- /dev/null +++ b/server/__tests__/utils/files/isWithin.test.js @@ -0,0 +1,230 @@ +process.env.STORAGE_DIR = __dirname; + +const path = require("path"); + +// We need to test Windows-style paths on non-Windows systems, so we mock +// path.resolve/relative/isAbsolute/sep for the Windows test block. +const { isWithin } = require("../../../utils/files"); + +describe("isWithin", () => { + describe("valid paths — should return true", () => { + it("direct child file", () => { + expect(isWithin("/documents", "/documents/file.json")).toBe(true); + }); + + it("nested child file", () => { + expect(isWithin("/documents", "/documents/folder/subfolder/file.json")).toBe(true); + }); + + it("direct child directory", () => { + expect(isWithin("/documents", "/documents/folder")).toBe(true); + }); + + it("deeply nested path", () => { + expect(isWithin("/documents", "/documents/a/b/c/d/e/f/file.json")).toBe(true); + }); + + it("trailing slash on outer", () => { + expect(isWithin("/documents/", "/documents/file.json")).toBe(true); + }); + + it("trailing slash on inner", () => { + expect(isWithin("/documents", "/documents/folder/")).toBe(true); + }); + + it("both have trailing slashes", () => { + expect(isWithin("/documents/", "/documents/folder/")).toBe(true); + }); + + it("relative outer resolved against cwd", () => { + expect(isWithin("storage/documents", "storage/documents/file.json")).toBe(true); + }); + + it("inner with redundant separators", () => { + expect(isWithin("/documents", "/documents///folder//file.json")).toBe(true); + }); + + it("inner with . segment", () => { + expect(isWithin("/documents", "/documents/./folder/file.json")).toBe(true); + }); + + it("inner with internal .. that stays within", () => { + expect(isWithin("/documents", "/documents/folder/../other/file.json")).toBe(true); + }); + }); + + describe("same path — should return false", () => { + it("identical strings", () => { + expect(isWithin("/documents", "/documents")).toBe(false); + }); + + it("outer with trailing slash vs without", () => { + expect(isWithin("/documents/", "/documents")).toBe(false); + }); + + it("inner with trailing slash vs without", () => { + expect(isWithin("/documents", "/documents/")).toBe(false); + }); + + it("both relative resolving to same dir", () => { + expect(isWithin("storage", "./storage")).toBe(false); + }); + + it("with . segment resolving to same", () => { + expect(isWithin("/documents/.", "/documents")).toBe(false); + }); + }); + + describe("path traversal — should return false", () => { + it("simple ../ to parent", () => { + expect(isWithin("/documents", "/documents/../secrets")).toBe(false); + }); + + it("sibling directory (relative result starts with ../)", () => { + expect(isWithin("/documents", "/secrets")).toBe(false); + }); + + it("deep traversal to /etc/passwd", () => { + expect(isWithin("/home/user/documents", "/home/user/documents/../../../etc/passwd")).toBe(false); + }); + + it("bare parent (inner is parent of outer)", () => { + expect(isWithin("/documents/folder", "/documents")).toBe(false); + }); + + it("root traversal", () => { + expect(isWithin("/home/user/docs", "/")).toBe(false); + }); + + it("similar prefix but not within (documents vs documents-backup)", () => { + expect(isWithin("/documents", "/documents-backup/file.json")).toBe(false); + }); + + it("unrelated absolute paths", () => { + expect(isWithin("/home/user/documents", "/var/log/system.log")).toBe(false); + }); + + it("traversal encoded in middle of path", () => { + expect(isWithin("/app/storage", "/app/storage/folder/../../secrets/key")).toBe(false); + }); + + it("traversal that resolves back in but escapes first", () => { + // /app/storage/../storage/file resolves to /app/storage/file — this IS within + expect(isWithin("/app/storage", "/app/storage/../storage/file")).toBe(true); + }); + + it("traversal that looks like it stays in but doesn't", () => { + // /app/storage/../other/storage/file resolves to /app/other/storage/file — NOT within + expect(isWithin("/app/storage", "/app/storage/../other/storage/file")).toBe(false); + }); + }); + + describe("relative inputs — should resolve correctly", () => { + it("both relative and inner is within", () => { + expect(isWithin("foo", "foo/bar")).toBe(true); + }); + + it("both relative and inner escapes", () => { + expect(isWithin("foo/bar", "foo/baz")).toBe(false); + }); + + it("outer relative, inner absolute outside", () => { + expect(isWithin("relative/dir", "/etc/passwd")).toBe(false); + }); + }); + + describe("Windows-style paths (simulated on POSIX)", () => { + // On POSIX, path.resolve won't produce Windows-style paths, so we test + // the cross-drive detection by directly verifying path.isAbsolute behavior. + // These tests validate the logic would work on a Windows system. + + it("rejects cross-drive paths (path.isAbsolute check)", () => { + // On Windows, path.relative('C:\\docs', 'D:\\secrets') returns 'D:\\secrets' (absolute) + // We can't fully simulate this on POSIX, but we verify path.isAbsolute catches it + const posixPath = require("path").posix; + // Simulate: if relative result were an absolute path, isWithin should reject + const rel = "/absolute/result"; + expect(posixPath.isAbsolute(rel)).toBe(true); + }); + + it("handles backslash in input on POSIX (treated as filename chars)", () => { + // On POSIX, backslashes are valid filename characters, not separators. + // This just ensures no crash — behavior differs on Windows. + expect(() => isWithin("/documents", "/documents/folder\\file")).not.toThrow(); + }); + }); + + describe("Windows path logic (unit testing the algorithm)", () => { + // To properly test Windows path behavior, we test the core algorithm + // directly with Windows-style path module behavior. + const win32 = require("path").win32; + + function isWithinWin32(outer, inner) { + const resolvedOuter = win32.resolve(outer); + const resolvedInner = win32.resolve(inner); + const rel = win32.relative(resolvedOuter, resolvedInner); + if (rel === "") return false; + return !rel.startsWith(`..${win32.sep}`) && rel !== ".." && !win32.isAbsolute(rel); + } + + it("child file on same drive", () => { + expect(isWithinWin32("C:\\documents", "C:\\documents\\file.json")).toBe(true); + }); + + it("nested child on same drive", () => { + expect(isWithinWin32("C:\\documents", "C:\\documents\\folder\\file.json")).toBe(true); + }); + + it("same directory", () => { + expect(isWithinWin32("C:\\documents", "C:\\documents")).toBe(false); + }); + + it("same directory with trailing backslash", () => { + expect(isWithinWin32("C:\\documents\\", "C:\\documents")).toBe(false); + }); + + it("parent traversal with backslash", () => { + expect(isWithinWin32("C:\\documents", "C:\\documents\\..\\secrets")).toBe(false); + }); + + it("sibling directory on same drive", () => { + expect(isWithinWin32("C:\\documents", "C:\\secrets")).toBe(false); + }); + + it("cross-drive (D: vs C:) — must reject", () => { + expect(isWithinWin32("C:\\documents", "D:\\secrets")).toBe(false); + }); + + it("cross-drive nested path — must reject", () => { + expect(isWithinWin32("C:\\app\\storage", "D:\\app\\storage\\file.json")).toBe(false); + }); + + it("UNC path within UNC base", () => { + expect(isWithinWin32("\\\\server\\share\\docs", "\\\\server\\share\\docs\\file.txt")).toBe(true); + }); + + it("UNC path escaping to different share", () => { + expect(isWithinWin32("\\\\server\\share\\docs", "\\\\server\\other\\file.txt")).toBe(false); + }); + + it("deep traversal on Windows", () => { + expect(isWithinWin32("C:\\Users\\app\\data", "C:\\Users\\app\\data\\..\\..\\..\\Windows\\System32\\cmd.exe")).toBe(false); + }); + + it("traversal that resolves back within on Windows", () => { + expect(isWithinWin32("C:\\app\\storage", "C:\\app\\storage\\..\\storage\\file.json")).toBe(true); + }); + + it("similar prefix but not within on Windows", () => { + expect(isWithinWin32("C:\\documents", "C:\\documents-backup\\file.json")).toBe(false); + }); + + it("forward slashes on Windows (normalized by resolve)", () => { + expect(isWithinWin32("C:/documents", "C:/documents/folder/file.json")).toBe(true); + }); + + it("mixed slashes on Windows", () => { + expect(isWithinWin32("C:\\documents", "C:/documents/folder\\file.json")).toBe(true); + }); + }); +}); diff --git a/server/__tests__/utils/helpers/azureOpenAiModelPref.test.js b/server/__tests__/utils/helpers/azureOpenAiModelPref.test.js new file mode 100644 index 0000000..201e4ea --- /dev/null +++ b/server/__tests__/utils/helpers/azureOpenAiModelPref.test.js @@ -0,0 +1,55 @@ +/* eslint-env jest */ + +/** + * Tests for the AzureOpenAI model key migration from OPEN_MODEL_PREF + * to AZURE_OPENAI_MODEL_PREF, ensuring backwards compatibility for + * existing users who have OPEN_MODEL_PREF set. + * + * Related issue: https://github.com/Mintplex-Labs/anything-llm/issues/3839 + */ + +describe("AzureOpenAI model key backwards compatibility", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + delete process.env.AZURE_OPENAI_MODEL_PREF; + delete process.env.OPEN_MODEL_PREF; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + describe("getBaseLLMProviderModel - helpers/index.js", () => { + test("returns AZURE_OPENAI_MODEL_PREF when set", () => { + process.env.AZURE_OPENAI_MODEL_PREF = "my-azure-deployment"; + process.env.OPEN_MODEL_PREF = "gpt-4o"; + const { getBaseLLMProviderModel } = require("../../../utils/helpers/index"); + expect(getBaseLLMProviderModel({ provider: "azure" })).toBe("my-azure-deployment"); + }); + + test("falls back to OPEN_MODEL_PREF when AZURE_OPENAI_MODEL_PREF is not set (backwards compat)", () => { + process.env.OPEN_MODEL_PREF = "my-old-azure-deployment"; + const { getBaseLLMProviderModel } = require("../../../utils/helpers/index"); + expect(getBaseLLMProviderModel({ provider: "azure" })).toBe("my-old-azure-deployment"); + }); + + test("openai provider still uses OPEN_MODEL_PREF exclusively", () => { + process.env.OPEN_MODEL_PREF = "gpt-4o"; + process.env.AZURE_OPENAI_MODEL_PREF = "my-azure-deployment"; + const { getBaseLLMProviderModel } = require("../../../utils/helpers/index"); + expect(getBaseLLMProviderModel({ provider: "openai" })).toBe("gpt-4o"); + }); + + test("azure and openai return different values when both keys are set", () => { + process.env.OPEN_MODEL_PREF = "gpt-4o"; + process.env.AZURE_OPENAI_MODEL_PREF = "my-azure-deployment"; + const { getBaseLLMProviderModel } = require("../../../utils/helpers/index"); + expect(getBaseLLMProviderModel({ provider: "azure" })).not.toBe( + getBaseLLMProviderModel({ provider: "openai" }) + ); + }); + }); +}); diff --git a/server/__tests__/utils/helpers/convertTo.test.js b/server/__tests__/utils/helpers/convertTo.test.js new file mode 100644 index 0000000..2b974d8 --- /dev/null +++ b/server/__tests__/utils/helpers/convertTo.test.js @@ -0,0 +1,238 @@ +/* eslint-env jest */ +const { prepareChatsForExport } = require("../../../utils/helpers/chat/convertTo"); + +// Mock the database models +jest.mock("../../../models/workspaceChats"); +jest.mock("../../../models/embedChats"); + +const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { EmbedChats } = require("../../../models/embedChats"); + +const mockChat = (withImages = false) => { + return { + id: 1, + prompt: "Test prompt", + response: JSON.stringify({ + text: "Test response", + attachments: withImages ? [ + { mime: "image/png", name: "image.png", contentString: "data:image/png;base64,iVBORw0KGg....=" }, + { mime: "image/jpeg", name: "image2.jpeg", contentString: "data:image/jpeg;base64,iVBORw0KGg....=" } + ] : [], + sources: [], + metrics: {}, + }), + createdAt: new Date(), + workspace: { name: "Test Workspace", openAiPrompt: "Test OpenAI Prompt" }, + user: { username: "testuser" }, + feedbackScore: 1, + } +}; + +describe("prepareChatsForExport", () => { + beforeEach(() => { + jest.clearAllMocks(); + WorkspaceChats.whereWithData = jest.fn().mockResolvedValue([]); + EmbedChats.whereWithEmbedAndWorkspace = jest.fn().mockResolvedValue([]); + }); + + test("should throw error for invalid chat type", async () => { + await expect(prepareChatsForExport("json", "invalid")) + .rejects + .toThrow("Invalid chat type: invalid"); + }); + + test("should throw error for invalid export type", async () => { + await expect(prepareChatsForExport("invalid", "workspace")) + .rejects + .toThrow("Invalid export type: invalid"); + }); + + // CSV and JSON are the same format, so we can test them together + test("should return prepared data in csv and json format for workspace chat type", async () => { + const chatExample = mockChat(); + WorkspaceChats.whereWithData.mockResolvedValue([chatExample]); + const result = await prepareChatsForExport("json", "workspace"); + + const responseJson = JSON.parse(chatExample.response); + expect(result).toBeDefined(); + expect(result).toEqual([{ + id: chatExample.id, + prompt: chatExample.prompt, + response: responseJson.text, + sent_at: chatExample.createdAt, + rating: chatExample.feedbackScore ? "GOOD" : "BAD", + username: chatExample.user.username, + workspace: chatExample.workspace.name, + attachments: [], + }]); + }); + + test("Should handle attachments for workspace chat type when json format is selected", async () => { + const chatExample = mockChat(true); + WorkspaceChats.whereWithData.mockResolvedValue([chatExample]); + const result = await prepareChatsForExport("json", "workspace"); + + const responseJson = JSON.parse(chatExample.response); + expect(result).toBeDefined(); + expect(result).toEqual([{ + id: chatExample.id, + prompt: chatExample.prompt, + response: responseJson.text, + sent_at: chatExample.createdAt, + rating: chatExample.feedbackScore ? "GOOD" : "BAD", + username: chatExample.user.username, + workspace: chatExample.workspace.name, + attachments: [ + { + type: "image", + image: responseJson.attachments[0].contentString, + }, + { + type: "image", + image: responseJson.attachments[1].contentString, + }, + ] + }]); + }); + + test("Should ignore attachments for workspace chat type when csv format is selected", async () => { + const chatExample = mockChat(true); + WorkspaceChats.whereWithData.mockResolvedValue([chatExample]); + const result = await prepareChatsForExport("csv", "workspace"); + + const responseJson = JSON.parse(chatExample.response); + expect(result).toBeDefined(); + expect(result.attachments).not.toBeDefined(); + expect(result).toEqual([{ + id: chatExample.id, + prompt: chatExample.prompt, + response: responseJson.text, + sent_at: chatExample.createdAt, + rating: chatExample.feedbackScore ? "GOOD" : "BAD", + username: chatExample.user.username, + workspace: chatExample.workspace.name, + }]); + }); + + test("should return prepared data in jsonAlpaca format for workspace chat type", async () => { + const chatExample = mockChat(); + const imageChatExample = mockChat(true); + WorkspaceChats.whereWithData.mockResolvedValue([chatExample, imageChatExample]); + const result = await prepareChatsForExport("jsonAlpaca", "workspace"); + + const responseJson1 = JSON.parse(chatExample.response); + const responseJson2 = JSON.parse(imageChatExample.response); + expect(result).toBeDefined(); + + // Alpaca format does not support attachments - so they are not included + expect(result[0].attachments).not.toBeDefined(); + expect(result[1].attachments).not.toBeDefined(); + expect(result).toEqual([{ + instruction: chatExample.workspace.openAiPrompt, + input: chatExample.prompt, + output: responseJson1.text, + }, + { + instruction: chatExample.workspace.openAiPrompt, + input: imageChatExample.prompt, + output: responseJson2.text, + }]); + }); + + test("should return prepared data in jsonl format for workspace chat type", async () => { + const chatExample = mockChat(); + const responseJson = JSON.parse(chatExample.response); + WorkspaceChats.whereWithData.mockResolvedValue([chatExample]); + const result = await prepareChatsForExport("jsonl", "workspace"); + expect(result).toBeDefined(); + expect(result).toEqual( + { + [chatExample.workspace.id]: { + messages: [ + { + role: "system", + content: [{ + type: "text", + text: chatExample.workspace.openAiPrompt, + }], + }, + { + role: "user", + content: [{ + type: "text", + text: chatExample.prompt, + }], + }, + { + role: "assistant", + content: [{ + type: "text", + text: responseJson.text, + }], + }, + ], + }, + }, + ); + }); + + test("should return prepared data in jsonl format for workspace chat type with attachments", async () => { + const chatExample = mockChat(); + const imageChatExample = mockChat(true); + const responseJson = JSON.parse(chatExample.response); + const imageResponseJson = JSON.parse(imageChatExample.response); + + WorkspaceChats.whereWithData.mockResolvedValue([chatExample, imageChatExample]); + const result = await prepareChatsForExport("jsonl", "workspace"); + expect(result).toBeDefined(); + expect(result).toEqual( + { + [chatExample.workspace.id]: { + messages: [ + { + role: "system", + content: [{ + type: "text", + text: chatExample.workspace.openAiPrompt, + }], + }, + { + role: "user", + content: [{ + type: "text", + text: chatExample.prompt, + }], + }, + { + role: "assistant", + content: [{ + type: "text", + text: responseJson.text, + }], + }, + { + role: "user", + content: [{ + type: "text", + text: imageChatExample.prompt, + }, { + type: "image", + image: imageResponseJson.attachments[0].contentString, + }, { + type: "image", + image: imageResponseJson.attachments[1].contentString, + }], + }, + { + role: "assistant", + content: [{ + type: "text", + text: imageResponseJson.text, + }], + }, + ], + }, + }, + ); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js b/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js new file mode 100644 index 0000000..b8afa8e --- /dev/null +++ b/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js @@ -0,0 +1,43 @@ +const { + workspaceDeletionProtection, +} = require("../../../utils/middleware/workspaceDeletionProtection"); + +describe("workspaceDeletionProtection middleware", () => { + const originalValue = process.env.WORKSPACE_DELETION_PROTECTION; + const hadValue = "WORKSPACE_DELETION_PROTECTION" in process.env; + + afterEach(() => { + if (hadValue) { + process.env.WORKSPACE_DELETION_PROTECTION = originalValue; + } else { + delete process.env.WORKSPACE_DELETION_PROTECTION; + } + }); + + it("calls next when WORKSPACE_DELETION_PROTECTION is not present", () => { + delete process.env.WORKSPACE_DELETION_PROTECTION; + const next = jest.fn(); + + workspaceDeletionProtection({}, {}, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it("returns 403 when WORKSPACE_DELETION_PROTECTION is present with any value", () => { + process.env.WORKSPACE_DELETION_PROTECTION = "1"; + const next = jest.fn(); + const response = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + workspaceDeletionProtection({}, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith({ + success: false, + error: "Workspace deletion is blocked by the system administrator.", + }); + }); +}); diff --git a/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js b/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js new file mode 100644 index 0000000..2165698 --- /dev/null +++ b/server/__tests__/utils/safeJSONStringify/safeJSONStringify.test.js @@ -0,0 +1,60 @@ +/* eslint-env jest */ +const { safeJSONStringify } = require("../../../utils/helpers/chat/responses"); + +describe("safeJSONStringify", () => { + test("handles regular objects without BigInt", () => { + const obj = { a: 1, b: "test", c: true, d: null }; + expect(safeJSONStringify(obj)).toBe(JSON.stringify(obj)); + }); + + test("converts BigInt to string", () => { + const bigInt = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1); + expect(safeJSONStringify(bigInt)).toBe(`"${bigInt.toString()}"`); + }); + + test("handles nested BigInt values", () => { + const obj = { + metrics: { + tokens: BigInt(123), + nested: { moreBigInt: BigInt(456) } + }, + normal: "value" + }; + expect(safeJSONStringify(obj)).toBe( + '{"metrics":{"tokens":"123","nested":{"moreBigInt":"456"}},"normal":"value"}' + ); + }); + + test("handles arrays with BigInt", () => { + const arr = [BigInt(1), 2, BigInt(3)]; + expect(safeJSONStringify(arr)).toBe('["1",2,"3"]'); + }); + + test("handles mixed complex objects", () => { + const obj = { + id: 1, + bigNums: [BigInt(123), BigInt(456)], + nested: { + more: { huge: BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1) } + }, + normal: { str: "test", num: 42, bool: true, nil: null, sub_arr: ["alpha", "beta", "gamma", 1, 2, BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1), { map: { a: BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1) } }] } + }; + const result = JSON.parse(safeJSONStringify(obj)); // Should parse back without errors + expect(typeof result.bigNums[0]).toBe("string"); + expect(result.bigNums[0]).toEqual("123"); + expect(typeof result.nested.more.huge).toBe("string"); + expect(result.normal).toEqual({ str: "test", num: 42, bool: true, nil: null, sub_arr: ["alpha", "beta", "gamma", 1, 2, (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString(), { map: { a: (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString() } }] }); + expect(result.normal.sub_arr[6].map.a).toEqual((BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString()); + }); + + test("handles invariants", () => { + expect(safeJSONStringify({})).toBe("{}"); + expect(safeJSONStringify(null)).toBe("null"); + expect(safeJSONStringify(undefined)).toBe(undefined); + expect(safeJSONStringify(true)).toBe("true"); + expect(safeJSONStringify(false)).toBe("false"); + expect(safeJSONStringify(0)).toBe("0"); + expect(safeJSONStringify(1)).toBe("1"); + expect(safeJSONStringify(-1)).toBe("-1"); + }); +}); \ No newline at end of file diff --git a/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js b/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js new file mode 100644 index 0000000..bcac005 --- /dev/null +++ b/server/__tests__/utils/vectorDbProviders/pgvector/index.test.js @@ -0,0 +1,78 @@ +const { PGVector: PGVectorClass } = require("../../../../utils/vectorDbProviders/pgvector"); + +const PGVector = new PGVectorClass(); + +describe("PGVector.sanitizeForJsonb", () => { + it("returns null/undefined as-is", () => { + expect(PGVector.sanitizeForJsonb(null)).toBeNull(); + expect(PGVector.sanitizeForJsonb(undefined)).toBeUndefined(); + }); + + it("keeps safe whitespace (tab, LF, CR) and removes disallowed C0 controls", () => { + const input = "a\u0000\u0001\u0002\tline\ncarriage\rreturn\u001Fend"; + const result = PGVector.sanitizeForJsonb(input); + // Expect all < 0x20 except 9,10,13 removed; keep letters and allowed whitespace + expect(result).toBe("a\tline\ncarriage\rreturnend"); + }); + + it("removes only disallowed control chars; keeps normal printable chars", () => { + const input = "Hello\u0000, World! \u0007\u0008\u000B\u000C\u001F"; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toBe("Hello, World! "); + }); + + it("deeply sanitizes objects", () => { + const input = { + plain: "ok", + bad: "has\u0000nul", + nested: { + arr: ["fine", "bad\u0001", { deep: "\u0002oops" }], + }, + }; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toEqual({ + plain: "ok", + bad: "hasnul", + nested: { arr: ["fine", "bad", { deep: "oops" }] }, + }); + }); + + it("deeply sanitizes arrays", () => { + const input = ["\u0000", 1, true, { s: "bad\u0003" }, ["ok", "\u0004bad"]]; + const result = PGVector.sanitizeForJsonb(input); + expect(result).toEqual(["", 1, true, { s: "bad" }, ["ok", "bad"]]); + }); + + it("converts Date to ISO string", () => { + const d = new Date("2020-01-02T03:04:05.000Z"); + expect(PGVector.sanitizeForJsonb(d)).toBe(d.toISOString()); + }); + + it("returns primitives unchanged (number, boolean, bigint)", () => { + expect(PGVector.sanitizeForJsonb(42)).toBe(42); + expect(PGVector.sanitizeForJsonb(3.14)).toBe(3.14); + expect(PGVector.sanitizeForJsonb(true)).toBe(true); + expect(PGVector.sanitizeForJsonb(false)).toBe(false); + expect(PGVector.sanitizeForJsonb(BigInt(1))).toBe(BigInt(1)); + }); + + it("returns symbol unchanged", () => { + const sym = Symbol("x"); + expect(PGVector.sanitizeForJsonb(sym)).toBe(sym); + }); + + it("does not mutate original objects/arrays", () => { + const obj = { a: "bad\u0000", nested: { b: "ok" } }; + const arr = ["\u0001", { c: "bad\u0002" }]; + const objCopy = JSON.parse(JSON.stringify(obj)); + const arrCopy = JSON.parse(JSON.stringify(arr)); + const resultObj = PGVector.sanitizeForJsonb(obj); + const resultArr = PGVector.sanitizeForJsonb(arr); + // Original inputs remain unchanged + expect(obj).toEqual(objCopy); + expect(arr).toEqual(arrCopy); + // Results are sanitized copies + expect(resultObj).toEqual({ a: "bad", nested: { b: "ok" } }); + expect(resultArr).toEqual(["", { c: "bad" }]); + }); +}); diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js new file mode 100644 index 0000000..c817c67 --- /dev/null +++ b/server/endpoints/admin.js @@ -0,0 +1,566 @@ +const { ApiKey } = require("../models/apiKeys"); +const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey"); +const { Document } = require("../models/documents"); +const { EventLogs } = require("../models/eventLogs"); +const { Invite } = require("../models/invite"); +const { SystemSettings } = require("../models/systemSettings"); +const { User } = require("../models/user"); +const { DocumentVectors } = require("../models/vectors"); +const { Workspace } = require("../models/workspace"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { + getVectorDbClass, + getEmbeddingEngineSelection, +} = require("../utils/helpers"); +const { + validRoleSelection, + canModifyAdmin, + validCanModify, +} = require("../utils/helpers/admin"); +const { reqBody, userFromSession, safeJsonParse } = require("../utils/http"); +const { + strictMultiUserRoleValid, + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const ImportedPlugin = require("../utils/agents/imported"); +const { + simpleSSOLoginDisabledMiddleware, +} = require("../utils/middleware/simpleSSOEnabled"); +const { + workspaceDeletionProtection, +} = require("../utils/middleware/workspaceDeletionProtection"); + +function adminEndpoints(app) { + if (!app) return; + + app.get( + "/admin/users", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (_request, response) => { + try { + const users = await User.where(); + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/users/new", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const currUser = await userFromSession(request, response); + const newUserParams = reqBody(request); + const roleValidation = validRoleSelection(currUser, newUserParams); + + if (!roleValidation.valid) { + response + .status(200) + .json({ user: null, error: roleValidation.error }); + return; + } + + const { user: newUser, error } = await User.create(newUserParams); + if (!!newUser) { + await EventLogs.logEvent( + "user_created", + { + userName: newUser.username, + createdBy: currUser.username, + }, + currUser.id + ); + } + + response.status(200).json({ user: newUser, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/user/:id", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const currUser = await userFromSession(request, response); + const { id } = request.params; + const updates = reqBody(request); + const user = await User.get({ id: Number(id) }); + + const canModify = validCanModify(currUser, user); + if (!canModify.valid) { + response.status(200).json({ success: false, error: canModify.error }); + return; + } + + const roleValidation = validRoleSelection(currUser, updates); + if (!roleValidation.valid) { + response + .status(200) + .json({ success: false, error: roleValidation.error }); + return; + } + + const validAdminRoleModification = await canModifyAdmin(user, updates); + if (!validAdminRoleModification.valid) { + response + .status(200) + .json({ success: false, error: validAdminRoleModification.error }); + return; + } + + const { success, error } = await User.update(id, updates); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/user/:id", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const currUser = await userFromSession(request, response); + const { id } = request.params; + const user = await User.get({ id: Number(id) }); + + const canModify = validCanModify(currUser, user); + if (!canModify.valid) { + response.status(200).json({ success: false, error: canModify.error }); + return; + } + + await BrowserExtensionApiKey.deleteAllForUser(Number(id)); + await User.delete({ id: Number(id) }); + await EventLogs.logEvent( + "user_deleted", + { + userName: user.username, + deletedBy: currUser.username, + }, + currUser.id + ); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/invites", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (_request, response) => { + try { + const invites = await Invite.whereWithUsers(); + response.status(200).json({ invites }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/invite/new", + [ + validatedRequest, + strictMultiUserRoleValid([ROLES.admin, ROLES.manager]), + simpleSSOLoginDisabledMiddleware, + ], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const body = reqBody(request); + const { invite, error } = await Invite.create({ + createdByUserId: user.id, + workspaceIds: body?.workspaceIds || [], + }); + + await EventLogs.logEvent( + "invite_created", + { + inviteCode: invite.code, + createdBy: response.locals?.user?.username, + }, + response.locals?.user?.id + ); + response.status(200).json({ invite, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/invite/:id", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { id } = request.params; + const { success, error } = await Invite.deactivate(id); + await EventLogs.logEvent( + "invite_deleted", + { deletedBy: response.locals?.user?.username }, + response.locals?.user?.id + ); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/workspaces", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (_request, response) => { + try { + const workspaces = await Workspace.whereWithUsers(); + response.status(200).json({ workspaces }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/workspaces/:workspaceId/users", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { workspaceId } = request.params; + const users = await Workspace.workspaceUsers(workspaceId); + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/workspaces/new", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { name } = reqBody(request); + const { workspace, message: error } = await Workspace.new( + name, + user.id + ); + response.status(200).json({ workspace, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/workspaces/:workspaceId/update-users", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { workspaceId } = request.params; + const { userIds } = reqBody(request); + const { success, error } = await Workspace.updateUsers( + workspaceId, + userIds + ); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/workspaces/:id", + [ + validatedRequest, + strictMultiUserRoleValid([ROLES.admin, ROLES.manager]), + workspaceDeletionProtection, + ], + async (request, response) => { + try { + const { id } = request.params; + const VectorDb = getVectorDbClass(); + const workspace = await Workspace.get({ id: Number(id) }); + if (!workspace) { + response.sendStatus(404).end(); + return; + } + + await WorkspaceChats.delete({ workspaceId: Number(workspace.id) }); + await DocumentVectors.deleteForWorkspace(Number(workspace.id)); + await Document.delete({ workspaceId: Number(workspace.id) }); + await Workspace.delete({ id: Number(workspace.id) }); + try { + await VectorDb["delete-namespace"]({ namespace: workspace.slug }); + } catch (e) { + console.error(e.message); + } + + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + // System preferences but only by array of labels + app.get( + "/admin/system-preferences-for", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const requestedSettings = {}; + const labels = request.query.labels?.split(",") || []; + const needEmbedder = [ + "text_splitter_chunk_size", + "max_embed_chunk_size", + ]; + const noRecord = [ + "max_embed_chunk_size", + "agent_sql_connections", + "imported_agent_skills", + "feature_flags", + "meta_page_title", + "meta_page_favicon", + ]; + + // Managers can only read a limited set of settings. + // These match the ManagerRoute pages in the frontend. + const managerAllowedFields = [ + "custom_app_name", + "footer_data", + "support_email", + "meta_page_title", + "meta_page_favicon", + ]; + + for (const label of labels) { + // Skip any settings that are not explicitly defined as public + if (!SystemSettings.publicFields.includes(label)) continue; + + // Managers can only read manager-allowed fields + if ( + user?.role === ROLES.manager && + !managerAllowedFields.includes(label) + ) + continue; + + // Only get the embedder if the setting actually needs it + let embedder = needEmbedder.includes(label) + ? getEmbeddingEngineSelection() + : null; + // Only get the record from db if the setting actually needs it + let setting = noRecord.includes(label) + ? null + : await SystemSettings.get({ label }); + + switch (label) { + case "footer_data": + requestedSettings[label] = setting?.value ?? JSON.stringify([]); + break; + case "support_email": + requestedSettings[label] = setting?.value || null; + break; + case "text_splitter_chunk_size": + requestedSettings[label] = + setting?.value || embedder?.embeddingMaxChunkLength || null; + break; + case "text_splitter_chunk_overlap": + requestedSettings[label] = setting?.value || null; + break; + case "max_embed_chunk_size": + requestedSettings[label] = + embedder?.embeddingMaxChunkLength || 1000; + break; + case "agent_search_provider": + requestedSettings[label] = setting?.value || null; + break; + case "agent_sql_connections": + requestedSettings[label] = + await SystemSettings.agent_sql_connections(); + break; + case "default_agent_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_agent_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_filesystem_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_create_files_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_gmail_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_outlook_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "imported_agent_skills": + requestedSettings[label] = ImportedPlugin.listImportedPlugins(); + break; + case "custom_app_name": + requestedSettings[label] = setting?.value || null; + break; + case "feature_flags": + requestedSettings[label] = + (await SystemSettings.getFeatureFlags()) || {}; + break; + case "meta_page_title": + requestedSettings[label] = + await SystemSettings.getValueOrFallback({ label }, null); + break; + case "meta_page_favicon": + requestedSettings[label] = + await SystemSettings.getValueOrFallback({ label }, null); + break; + case "memory_enabled": + requestedSettings[label] = setting?.value || "false"; + break; + case "memory_auto_extraction": + requestedSettings[label] = setting?.value ?? "true"; + break; + default: + break; + } + } + + response.status(200).json({ settings: requestedSettings }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/system-preferences", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + let updates = reqBody(request); + + // Managers can only update a limited set of settings. + // These match the ManagerRoute pages in the frontend. + // Admin users can update all supportedFields without restriction. + if (user?.role === ROLES.manager) { + const managerAllowedFields = [ + "custom_app_name", + "footer_data", + "support_email", + "meta_page_title", + "meta_page_favicon", + ]; + const filteredUpdates = {}; + for (const key of Object.keys(updates)) { + if (managerAllowedFields.includes(key)) { + filteredUpdates[key] = updates[key]; + } + } + updates = filteredUpdates; + } + + await SystemSettings.updateSettings(updates); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/api-keys", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const apiKeys = await ApiKey.whereWithUser({}); + return response.status(200).json({ + apiKeys, + error: null, + }); + } catch (error) { + console.error(error); + response.status(500).json({ + apiKey: null, + error: "Could not find an API Keys.", + }); + } + } + ); + + app.post( + "/admin/generate-api-key", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { name = null } = reqBody(request); + const { apiKey, error } = await ApiKey.create(user.id, name); + await EventLogs.logEvent( + "api_key_created", + { createdBy: user?.username, name: apiKey?.name }, + user?.id + ); + return response.status(200).json({ + apiKey, + error, + }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/delete-api-key/:id", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + if (!id || isNaN(Number(id))) return response.sendStatus(400).end(); + await ApiKey.delete({ id: Number(id) }); + + await EventLogs.logEvent( + "api_key_deleted", + { deletedBy: response.locals?.user?.username }, + response?.locals?.user?.id + ); + return response.status(200).end(); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { adminEndpoints }; diff --git a/server/endpoints/agentFileServer.js b/server/endpoints/agentFileServer.js new file mode 100644 index 0000000..98e3571 --- /dev/null +++ b/server/endpoints/agentFileServer.js @@ -0,0 +1,178 @@ +const { + userFromSession, + multiUserMode, + safeJsonParse, +} = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { Workspace } = require("../models/workspace"); +const { ScheduledJobRun } = require("../models/scheduledJobRun"); +const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib"); +const { Telemetry } = require("../models/telemetry"); + +/** + * Endpoints for serving agent-generated files (PPTX, etc.) with authentication + * and ownership validation. + */ +function agentFileServerEndpoints(app) { + if (!app) return; + + /** + * Download a generated file by its storage filename. + * Validates that the requesting user has access to the workspace + * where the file was generated. + */ + app.get( + "/agent-skills/generated-files/:filename", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { filename } = request.params; + if (!filename) + return response.status(400).json({ error: "Filename is required" }); + + // Validate filename format + const parsed = createFilesLib.parseFilename(filename); + if (!parsed) { + return response + .status(400) + .json({ error: "Invalid filename format" }); + } + + // Find a chat or scheduled job run that references this file + const fileSource = await findFileSource(filename, { + user, + isMultiUser: multiUserMode(response), + }); + + if (!fileSource) { + return response.status(404).json({ + error: "File not found or access denied", + }); + } + + // Retrieve the file from storage + const fileData = await createFilesLib.getGeneratedFile(filename); + if (!fileData) { + return response + .status(404) + .json({ error: "File not found in storage" }); + } + + // Get mime type and set headers for download + const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`); + const safeFilename = createFilesLib.sanitizeFilenameForHeader( + fileSource.displayFilename || filename + ); + response.setHeader("Content-Type", mimeType); + response.setHeader( + "Content-Disposition", + `attachment; filename="${safeFilename}"` + ); + response.setHeader("Content-Length", fileData.buffer.length); + response.send(fileData.buffer); + Telemetry.sendTelemetry("agent_generated_file_downloaded", { + type: mimeType, + }).catch(() => {}); + return; + } catch (error) { + console.error("[agentFileServer] Download error:", error.message); + return response.status(500).json({ error: "Failed to download file" }); + } + } + ); +} + +/** + * Locates the source record (a workspace chat or a scheduled job run) that + * references the given storage filename, and confirms the requester has access. + * + * Search order: + * 1. Workspace chats the user can access (per multi-user permissions). + * 2. Scheduled job runs — single-user only, so no per-user access check. + * + * @param {string} storageFilename + * @param {{ user: object|null, isMultiUser: boolean }} ctx + * @returns {Promise<{workspaceId: number|null, displayFilename: string}|null>} + */ +async function findFileSource(storageFilename, { user, isMultiUser }) { + try { + const fromChat = await findInWorkspaceChats(storageFilename, { + user, + isMultiUser, + }); + if (fromChat) return fromChat; + + if (isMultiUser) return null; + + return await findInScheduledJobRuns(storageFilename); + } catch (error) { + console.error("[findFileSource] Error:", error.message); + return null; + } +} + +// Search workspace chats the user has access to. In single-user mode all +// workspaces are accessible; in multi-user mode only workspaces assigned to +// the user are. Returns the matching chat's workspace + display filename. +async function findInWorkspaceChats(storageFilename, { user, isMultiUser }) { + const workspaces = + isMultiUser && user + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + + const workspaceIds = workspaces.map((w) => w.id); + if (workspaceIds.length === 0) return null; + + // DB-level filter so we don't load every chat into memory. + const chats = await WorkspaceChats.where({ + workspaceId: { in: workspaceIds }, + include: true, + response: { contains: storageFilename }, + }); + + for (const chat of chats) { + const { outputs = [] } = safeJsonParse(chat.response, { outputs: [] }); + const output = outputs.find( + (o) => o?.payload?.storageFilename === storageFilename + ); + if (!output) continue; + return { + workspaceId: chat.workspaceId, + displayFilename: + output.payload.filename || output.payload.displayFilename, + }; + } + + return null; +} + +// Search completed scheduled job runs. Scheduled jobs are single-user only, +// so this skips access control. Returns the matching run's display filename. +async function findInScheduledJobRuns(storageFilename) { + const runs = await ScheduledJobRun.where({ + status: "completed", + result: { contains: storageFilename }, + }); + + for (const run of runs) { + const { outputs = [] } = safeJsonParse(run.result, { outputs: [] }); + const output = outputs.find( + (o) => o?.payload?.storageFilename === storageFilename + ); + if (!output) continue; + return { + workspaceId: null, + displayFilename: output.payload.filename || storageFilename, + }; + } + + return null; +} + +module.exports = { agentFileServerEndpoints }; diff --git a/server/endpoints/agentFlows.js b/server/endpoints/agentFlows.js new file mode 100644 index 0000000..0f3c164 --- /dev/null +++ b/server/endpoints/agentFlows.js @@ -0,0 +1,200 @@ +const { AgentFlows } = require("../utils/agentFlows"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); + +function agentFlowEndpoints(app) { + if (!app) return; + + // Save a flow configuration + app.post( + "/agent-flows/save", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { name, config, uuid } = request.body; + + if (!name || !config) { + return response.status(400).json({ + success: false, + error: "Name and config are required", + }); + } + + const flow = AgentFlows.saveFlow(name, config, uuid); + if (!flow || !flow.success) + return response + .status(200) + .json({ flow: null, error: flow.error || "Failed to save flow" }); + + if (!uuid) { + await Telemetry.sendTelemetry("agent_flow_created", { + blockCount: config.blocks?.length || 0, + }); + } + + return response.status(200).json({ + success: true, + flow, + }); + } catch (error) { + console.error("Error saving flow:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + // List all available flows + app.get( + "/agent-flows/list", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const flows = AgentFlows.listFlows(); + return response.status(200).json({ + success: true, + flows, + }); + } catch (error) { + console.error("Error listing flows:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + // Get a specific flow by UUID + app.get( + "/agent-flows/:uuid", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { uuid } = request.params; + const flow = AgentFlows.loadFlow(uuid); + if (!flow) { + return response.status(404).json({ + success: false, + error: "Flow not found", + }); + } + + return response.status(200).json({ + success: true, + flow, + }); + } catch (error) { + console.error("Error getting flow:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + // Run a specific flow + // app.post( + // "/agent-flows/:uuid/run", + // [validatedRequest, flexUserRoleValid([ROLES.admin])], + // async (request, response) => { + // try { + // const { uuid } = request.params; + // const { variables = {} } = request.body; + + // // TODO: Implement flow execution + // console.log("Running flow with UUID:", uuid); + + // await Telemetry.sendTelemetry("agent_flow_executed", { + // variableCount: Object.keys(variables).length, + // }); + + // return response.status(200).json({ + // success: true, + // results: { + // success: true, + // results: "test", + // variables: variables, + // }, + // }); + // } catch (error) { + // console.error("Error running flow:", error); + // return response.status(500).json({ + // success: false, + // error: error.message, + // }); + // } + // } + // ); + + // Delete a specific flow + app.delete( + "/agent-flows/:uuid", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { uuid } = request.params; + const { success } = AgentFlows.deleteFlow(uuid); + + if (!success) { + return response.status(500).json({ + success: false, + error: "Failed to delete flow", + }); + } + + return response.status(200).json({ + success, + }); + } catch (error) { + console.error("Error deleting flow:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + // Toggle flow active status + app.post( + "/agent-flows/:uuid/toggle", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { uuid } = request.params; + const { active } = request.body; + + const flow = AgentFlows.loadFlow(uuid); + if (!flow) { + return response + .status(404) + .json({ success: false, error: "Flow not found" }); + } + + flow.config.active = active; + const { success } = AgentFlows.saveFlow(flow.name, flow.config, uuid); + + if (!success) { + return response + .status(500) + .json({ success: false, error: "Failed to update flow" }); + } + + return response.json({ success: true, flow }); + } catch (error) { + console.error("Error toggling flow:", error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); +} + +module.exports = { agentFlowEndpoints }; diff --git a/server/endpoints/agentSkillWhitelist.js b/server/endpoints/agentSkillWhitelist.js new file mode 100644 index 0000000..c54dcef --- /dev/null +++ b/server/endpoints/agentSkillWhitelist.js @@ -0,0 +1,82 @@ +const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist"); +const { reqBody, userFromSession } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); + +function agentSkillWhitelistEndpoints(app) { + if (!app) return; + + app.get( + "/agent-skills/filesystem-agent/is-available", + [validatedRequest], + async (_request, response) => { + try { + const filesystemTool = require("../utils/agents/aibitat/plugins/filesystem/lib"); + return response + .status(200) + .json({ available: filesystemTool.isToolAvailable() }); + } catch (e) { + console.error(e); + return response + .status(500) + .json({ available: false, error: e.message }); + } + } + ); + + app.get( + "/agent-skills/create-files-agent/is-available", + [validatedRequest], + async (_request, response) => { + try { + const createFilesTool = require("../utils/agents/aibitat/plugins/create-files/lib"); + return response + .status(200) + .json({ available: createFilesTool.isToolAvailable() }); + } catch (e) { + console.error(e); + return response + .status(500) + .json({ available: false, error: e.message }); + } + } + ); + + app.post( + "/agent-skills/whitelist/add", + [validatedRequest, flexUserRoleValid(ROLES.all)], + async (request, response) => { + try { + const { skillName } = reqBody(request); + if (!skillName) { + response + .status(400) + .json({ success: false, error: "Missing skillName" }); + return; + } + + const user = await userFromSession(request, response); + if (!user && response.locals?.multiUserMode) { + return response + .status(401) + .json({ success: false, error: "Unauthorized" }); + } + + const userId = user?.id || null; + const { success, error } = await AgentSkillWhitelist.add( + skillName, + userId + ); + return response.status(success ? 200 : 400).json({ success, error }); + } catch (e) { + console.error(e); + return response.status(500).json({ success: false, error: e.message }); + } + } + ); +} + +module.exports = { agentSkillWhitelistEndpoints }; diff --git a/server/endpoints/agentWebsocket.js b/server/endpoints/agentWebsocket.js new file mode 100644 index 0000000..c49831e --- /dev/null +++ b/server/endpoints/agentWebsocket.js @@ -0,0 +1,64 @@ +const { Telemetry } = require("../models/telemetry"); +const { + WorkspaceAgentInvocation, +} = require("../models/workspaceAgentInvocation"); +const { AgentHandler } = require("../utils/agents"); +const { + WEBSOCKET_BAIL_COMMANDS, +} = require("../utils/agents/aibitat/plugins/websocket"); +const { safeJsonParse } = require("../utils/http"); + +// Setup listener for incoming messages to relay to socket so it can be handled by agent plugin. +function relayToSocket(message) { + if (this.handleFeedback) return this?.handleFeedback?.(message); + if (this.handleToolApproval) return this?.handleToolApproval?.(message); + if (this.handleClarificationResponse) + return this?.handleClarificationResponse?.(message); + this.checkBailCommand(message); +} + +function agentWebsocket(app) { + if (!app) return; + + app.ws("/agent-invocation/:uuid", async function (socket, request) { + try { + const agentHandler = await new AgentHandler({ + uuid: String(request.params.uuid), + }).init(); + + if (!agentHandler.invocation) { + socket.close(); + return; + } + + socket.on("message", relayToSocket); + socket.on("close", () => { + agentHandler.closeAlert(); + WorkspaceAgentInvocation.close(String(request.params.uuid)); + return; + }); + + socket.checkBailCommand = (data) => { + const content = safeJsonParse(data)?.feedback; + if (WEBSOCKET_BAIL_COMMANDS.includes(content)) { + agentHandler.log( + `User invoked bail command while processing. Closing session now.` + ); + agentHandler.aibitat.abort(); + socket.close(); + return; + } + }; + + await Telemetry.sendTelemetry("agent_chat_started"); + await agentHandler.createAIbitat({ socket }); + await agentHandler.startAgentCluster(); + } catch (e) { + console.error(e.message, e); + socket?.send(JSON.stringify({ type: "wssFailure", content: e.message })); + socket?.close(); + } + }); +} + +module.exports = { agentWebsocket }; diff --git a/server/endpoints/api/admin/index.js b/server/endpoints/api/admin/index.js new file mode 100644 index 0000000..a9a21d9 --- /dev/null +++ b/server/endpoints/api/admin/index.js @@ -0,0 +1,791 @@ +const { EventLogs } = require("../../../models/eventLogs"); +const { Invite } = require("../../../models/invite"); +const { SystemSettings } = require("../../../models/systemSettings"); +const { User } = require("../../../models/user"); +const { Workspace } = require("../../../models/workspace"); +const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { WorkspaceUser } = require("../../../models/workspaceUsers"); +const { canModifyAdmin } = require("../../../utils/helpers/admin"); +const { multiUserMode, reqBody } = require("../../../utils/http"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); + +function apiAdminEndpoints(app) { + if (!app) return; + + app.get("/v1/admin/is-multi-user-mode", [validApiKey], (_, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "isMultiUser": true + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + const isMultiUser = multiUserMode(response); + response.status(200).json({ isMultiUser }); + }); + + app.get("/v1/admin/users", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "users": [ + { + username: "sample-sam", + role: 'default', + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const users = await User.where(); + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post("/v1/admin/users/new", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Create a new user with username and password. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Key pair object that will define the new user to add to the system.', + required: true, + content: { + "application/json": { + example: { + username: "sample-sam", + password: 'hunter2', + role: 'default | admin' + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + user: { + id: 1, + username: 'sample-sam', + role: 'default', + }, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const newUserParams = reqBody(request); + const { user: newUser, error } = await User.create(newUserParams); + response.status(newUser ? 200 : 400).json({ user: newUser, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post("/v1/admin/users/:id", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.parameters['id'] = { + in: 'path', + description: 'id of the user in the database.', + required: true, + type: 'string' + } + #swagger.description = 'Update existing user settings. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Key pair object that will update the found user. All fields are optional and will not update unless specified.', + required: true, + content: { + "application/json": { + example: { + username: "sample-sam", + password: 'hunter2', + role: 'default | admin', + suspended: 0, + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const updates = reqBody(request); + const user = await User.get({ id: Number(id) }); + const validAdminRoleModification = await canModifyAdmin(user, updates); + + if (!validAdminRoleModification.valid) { + response + .status(200) + .json({ success: false, error: validAdminRoleModification.error }); + return; + } + + const { success, error } = await User.update(id, updates); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.delete( + "/v1/admin/users/:id", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Delete existing user by id. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.parameters['id'] = { + in: 'path', + description: 'id of the user in the database.', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const user = await User.get({ id: Number(id) }); + await User.delete({ id: user.id }); + await EventLogs.logEvent("api_user_deleted", { + userName: user.username, + }); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/v1/admin/invites", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'List all existing invitations to instance regardless of status. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "invites": [ + { + id: 1, + status: "pending", + code: 'abc-123', + claimedBy: null + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const invites = await Invite.whereWithUsers(); + response.status(200).json({ invites }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post("/v1/admin/invite/new", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Create a new invite code for someone to use to register with instance. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Request body for creation parameters of the invitation', + required: false, + content: { + "application/json": { + example: { + workspaceIds: [1,2,45], + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + invite: { + id: 1, + status: "pending", + code: 'abc-123', + }, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const body = reqBody(request); + const { invite, error } = await Invite.create({ + workspaceIds: body?.workspaceIds ?? [], + }); + response.status(200).json({ invite, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.delete( + "/v1/admin/invite/:id", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Deactivates (soft-delete) invite by id. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.parameters['id'] = { + in: 'path', + description: 'id of the invite in the database.', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const parsedId = Number(id); + if (isNaN(parsedId)) { + response + .status(400) + .json({ success: false, error: "Invalid invite id" }); + return; + } + + const { success, error } = await Invite.deactivate(parsedId); + if (!success) { + response.status(404).json({ + success: false, + error: "Invite not found or already disabled", + }); + return; + } + + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/admin/workspaces/:workspaceId/users", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.parameters['workspaceId'] = { + in: 'path', + description: 'id of the workspace.', + required: true, + type: 'string' + } + #swagger.description = 'Retrieve a list of users with permissions to access the specified workspace.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + users: [ + {"userId": 1, "role": "admin"}, + {"userId": 2, "role": "member"} + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const workspaceId = request.params.workspaceId; + const users = await Workspace.workspaceUsers(workspaceId); + + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/admin/workspaces/:workspaceId/update-users", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.deprecated = true + #swagger.parameters['workspaceId'] = { + in: 'path', + description: 'id of the workspace in the database.', + required: true, + type: 'string' + } + #swagger.description = 'Overwrite workspace permissions to only be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Entire array of user ids who can access the workspace. All fields are optional and will not update unless specified.', + required: true, + content: { + "application/json": { + example: { + userIds: [1,2,4,12], + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const { workspaceId } = request.params; + const { userIds } = reqBody(request); + const { success, error } = await Workspace.updateUsers( + workspaceId, + userIds + ); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/admin/workspaces/:workspaceSlug/manage-users", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.parameters['workspaceSlug'] = { + in: 'path', + description: 'slug of the workspace in the database', + required: true, + type: 'string' + } + #swagger.description = 'Set workspace permissions to be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Array of user ids who will be given access to the target workspace. reset will remove all existing users from the workspace and only add the new users - default false.', + required: true, + content: { + "application/json": { + example: { + userIds: [1,2,4,12], + reset: false + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + users: [ + {"userId": 1, "username": "main-admin", "role": "admin"}, + {"userId": 2, "username": "sample-sam", "role": "default"} + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const { workspaceSlug } = request.params; + const { userIds: _uids, reset = false } = reqBody(request); + const userIds = ( + await User.where({ id: { in: _uids.map(Number) } }) + ).map((user) => user.id); + const workspace = await Workspace.get({ slug: String(workspaceSlug) }); + const workspaceUsers = await Workspace.workspaceUsers(workspace.id); + + if (!workspace) { + response.status(404).json({ + success: false, + error: `Workspace ${workspaceSlug} not found`, + users: workspaceUsers, + }); + return; + } + + if (userIds.length === 0) { + response.status(404).json({ + success: false, + error: `No valid user IDs provided.`, + users: workspaceUsers, + }); + return; + } + + // Reset all users in the workspace and add the new users as the only users in the workspace + if (reset) { + const { success, error } = await Workspace.updateUsers( + workspace.id, + userIds + ); + return response.status(200).json({ + success, + error, + users: await Workspace.workspaceUsers(workspace.id), + }); + } + + // Add new users to the workspace if they are not already in the workspace + const existingUserIds = workspaceUsers.map((user) => user.userId); + const usersToAdd = userIds.filter( + (userId) => !existingUserIds.includes(userId) + ); + if (usersToAdd.length > 0) + await WorkspaceUser.createManyUsers(usersToAdd, workspace.id); + response.status(200).json({ + success: true, + error: null, + users: await Workspace.workspaceUsers(workspace.id), + }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/admin/workspace-chats", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'All chats in the system ordered by most recent. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Page offset to show of workspace chats. All fields are optional and will not update unless specified.', + required: false, + content: { + "application/json": { + example: { + offset: 2, + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const pgSize = 20; + const { offset = 0 } = reqBody(request); + const chats = await WorkspaceChats.whereWithData( + {}, + pgSize, + offset * pgSize, + { id: "desc" } + ); + + const hasPages = (await WorkspaceChats.count()) > (offset + 1) * pgSize; + response.status(200).json({ chats: chats, hasPages }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/admin/preferences", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Admin'] + #swagger.description = 'Update multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.' + #swagger.requestBody = { + description: 'Object with setting key and new value to set. All keys are optional and will not update unless specified.', + required: true, + content: { + "application/json": { + example: { + support_email: "support@example.com", + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Method denied", + } + */ + try { + if (!multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + const updates = reqBody(request); + await SystemSettings.updateSettings(updates); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { apiAdminEndpoints }; diff --git a/server/endpoints/api/auth/index.js b/server/endpoints/api/auth/index.js new file mode 100644 index 0000000..e58420b --- /dev/null +++ b/server/endpoints/api/auth/index.js @@ -0,0 +1,33 @@ +const { validApiKey } = require("../../../utils/middleware/validApiKey"); + +function apiAuthEndpoints(app) { + if (!app) return; + + app.get("/v1/auth", [validApiKey], (_, response) => { + /* + #swagger.tags = ['Authentication'] + #swagger.description = 'Verify the attached Authentication header contains a valid API token.' + #swagger.responses[200] = { + description: 'Valid auth token was found.', + content: { + "application/json": { + schema: { + type: 'object', + example: { + authenticated: true, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + response.status(200).json({ authenticated: true }); + }); +} + +module.exports = { apiAuthEndpoints }; diff --git a/server/endpoints/api/document/index.js b/server/endpoints/api/document/index.js new file mode 100644 index 0000000..54b01dd --- /dev/null +++ b/server/endpoints/api/document/index.js @@ -0,0 +1,1218 @@ +const { Telemetry } = require("../../../models/telemetry"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); +const { handleAPIFileUpload } = require("../../../utils/files/multer"); +const { + viewLocalFiles, + findDocumentInDocuments, + getDocumentsByFolder, + normalizePath, + isWithin, +} = require("../../../utils/files"); +const { reqBody, safeJsonParse } = require("../../../utils/http"); +const { EventLogs } = require("../../../models/eventLogs"); +const { CollectorApi } = require("../../../utils/collectorApi"); +const fs = require("fs"); +const path = require("path"); +const { Document } = require("../../../models/documents"); +const { purgeFolder } = require("../../../utils/files/purgeDocument"); +const createFilesLib = require("../../../utils/agents/aibitat/plugins/create-files/lib"); +const documentsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, "../../../storage/documents") + : path.resolve(process.env.STORAGE_DIR, `documents`); + +/** + * Runs a simple validation check on the addToWorkspaces query parameter to ensure it is a string of comma-separated workspace slugs. + * @param {*} request + * @param {*} response + * @param {*} next + * @returns + */ +function validateWorkspaceSlugQuery(request, response, next) { + const { addToWorkspaces = "" } = reqBody(request); + if (!addToWorkspaces) return next(); + if (typeof addToWorkspaces !== "string") { + return response + .status(422) + .json({ + success: false, + error: `addToWorkspaces must be a string of comma-separated workspace slugs. Got ${typeof addToWorkspaces}`, + }) + .end(); + } + next(); +} + +function apiDocumentEndpoints(app) { + if (!app) return; + + app.post( + "/v1/document/upload", + [validApiKey, handleAPIFileUpload, validateWorkspaceSlugQuery], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Upload a new file to AnythingLLM to be parsed and prepared for embedding, with optional metadata.' + #swagger.requestBody = { + description: 'File to be uploaded.', + required: true, + content: { + "multipart/form-data": { + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'The file to upload' + }, + addToWorkspaces: { + type: 'string', + description: 'comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2', + }, + metadata: { + type: 'object', + description: 'Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.', + example: { 'title': 'Custom Title', 'docAuthor': 'Author Name', 'description': 'A brief description', 'docSource': 'Source of the document' } + } + }, + required: ['file'] + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + documents: [ + { + "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "url": "file:///Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt", + "title": "anythingllm.txt", + "docAuthor": "Unknown", + "description": "Unknown", + "docSource": "a text file uploaded by the user.", + "chunkSource": "anythingllm.txt", + "published": "1/16/2024, 3:07:00 PM", + "wordCount": 93, + "token_count_estimate": 115, + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const Collector = new CollectorApi(); + const { originalname } = request.file; + const { addToWorkspaces = "", metadata: _metadata = {} } = + reqBody(request); + const metadata = + typeof _metadata === "string" + ? safeJsonParse(_metadata, {}) + : _metadata; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`, + }) + .end(); + return; + } + + const { success, reason, documents } = await Collector.processDocument( + originalname, + metadata + ); + + if (!success) { + return response + .status(500) + .json({ success: false, error: reason, documents }) + .end(); + } + + Collector.log( + `Document ${originalname} uploaded processed and successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("document_uploaded"); + await EventLogs.logEvent("api_document_uploaded", { + documentName: originalname, + }); + + if (!!addToWorkspaces) + await Document.api.uploadToWorkspace( + addToWorkspaces, + documents?.[0].location + ); + response.status(200).json({ success: true, error: null, documents }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/document/upload/:folderName", + [validApiKey, handleAPIFileUpload, validateWorkspaceSlugQuery], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Upload a new file to a specific folder in AnythingLLM to be parsed and prepared for embedding. If the folder does not exist, it will be created.' + #swagger.parameters['folderName'] = { + in: 'path', + description: 'Target folder path (defaults to \"custom-documents\" if not provided)', + required: true, + type: 'string', + example: 'my-folder' + } + #swagger.requestBody = { + description: 'File to be uploaded, with optional metadata.', + required: true, + content: { + "multipart/form-data": { + schema: { + type: 'object', + required: ['file'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'The file to upload' + }, + addToWorkspaces: { + type: 'string', + description: 'comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2', + }, + metadata: { + type: 'object', + description: 'Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.', + example: { 'title': 'Custom Title', 'docAuthor': 'Author Name', 'description': 'A brief description', 'docSource': 'Source of the document' } + } + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + documents: [{ + "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "url": "file:///Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt", + "title": "anythingllm.txt", + "docAuthor": "Unknown", + "description": "Unknown", + "docSource": "a text file uploaded by the user.", + "chunkSource": "anythingllm.txt", + "published": "1/16/2024, 3:07:00 PM", + "wordCount": 93, + "token_count_estimate": 115 + }] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[500] = { + description: "Internal Server Error", + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: false, + error: "Document processing API is not online. Document will not be processed automatically." + } + } + } + } + } + */ + try { + const { originalname } = request.file; + const { addToWorkspaces = "", metadata: _metadata = {} } = + reqBody(request); + const metadata = + typeof _metadata === "string" + ? safeJsonParse(_metadata, {}) + : _metadata; + + let folder = request.params?.folderName || "custom-documents"; + folder = normalizePath(folder); + const targetFolderPath = path.join(documentsPath, folder); + + if ( + !isWithin(path.resolve(documentsPath), path.resolve(targetFolderPath)) + ) + throw new Error("Invalid folder name"); + if (!fs.existsSync(targetFolderPath)) + fs.mkdirSync(targetFolderPath, { recursive: true }); + + const Collector = new CollectorApi(); + const processingOnline = await Collector.online(); + if (!processingOnline) { + return response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`, + }) + .end(); + } + + // Process the uploaded document with metadata + const { success, reason, documents } = await Collector.processDocument( + originalname, + metadata + ); + if (!success) { + return response + .status(500) + .json({ success: false, error: reason, documents }) + .end(); + } + + // For each processed document, check if it is already in the desired folder. + // If not, move it using similar logic as in the move-files endpoint. + for (const doc of documents) { + const currentFolder = path.dirname(doc.location); + if (currentFolder !== folder) { + const sourcePath = path.join( + documentsPath, + normalizePath(doc.location) + ); + const destinationPath = path.join( + targetFolderPath, + path.basename(doc.location) + ); + + if ( + !isWithin(documentsPath, sourcePath) || + !isWithin(documentsPath, destinationPath) + ) + throw new Error("Invalid file location"); + + fs.renameSync(sourcePath, destinationPath); + doc.location = path.join(folder, path.basename(doc.location)); + doc.name = path.basename(doc.location); + } + } + + Collector.log( + `Document ${originalname} uploaded, processed, and moved to folder ${folder} successfully.` + ); + + await Telemetry.sendTelemetry("document_uploaded"); + await EventLogs.logEvent("api_document_uploaded", { + documentName: originalname, + folder, + }); + + if (!!addToWorkspaces) + await Document.api.uploadToWorkspace( + addToWorkspaces, + documents?.[0].location + ); + response.status(200).json({ success: true, error: null, documents }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/document/upload-link", + [validApiKey, validateWorkspaceSlugQuery], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Upload a valid URL for AnythingLLM to scrape and prepare for embedding. Optionally, specify a comma-separated list of workspace slugs to embed the document into post-upload.' + #swagger.requestBody = { + description: 'Link of web address to be scraped and optionally a comma-separated list of workspace slugs to embed the document into post-upload, and optional metadata.', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + example: { + "link": "https://anythingllm.com", + "addToWorkspaces": "workspace1,workspace2", + "scraperHeaders": { + "Authorization": "Bearer token123", + "My-Custom-Header": "value" + }, + "metadata": { + "title": "Custom Title", + "docAuthor": "Author Name", + "description": "A brief description", + "docSource": "Source of the document" + } + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + documents: [ + { + "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc", + "url": "file://useanything_com.html", + "title": "useanything_com.html", + "docAuthor": "no author found", + "description": "No description found.", + "docSource": "URL link uploaded by the user.", + "chunkSource": "https:anythingllm.com.html", + "published": "1/16/2024, 3:46:33 PM", + "wordCount": 252, + "pageContent": "AnythingLLM is the best....", + "token_count_estimate": 447, + "location": "custom-documents/url-useanything_com-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const Collector = new CollectorApi(); + const { + link, + addToWorkspaces = "", + scraperHeaders = {}, + metadata: _metadata = {}, + } = reqBody(request); + const metadata = + typeof _metadata === "string" + ? safeJsonParse(_metadata, {}) + : _metadata; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + return response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Link ${link} will not be processed automatically.`, + }) + .end(); + } + + const { success, reason, documents } = await Collector.processLink( + link, + scraperHeaders, + metadata + ); + if (!success) { + return response + .status(500) + .json({ success: false, error: reason, documents }) + .end(); + } + + Collector.log( + `Link ${link} uploaded processed and successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("link_uploaded"); + await EventLogs.logEvent("api_link_uploaded", { + link, + }); + + if (!!addToWorkspaces) + await Document.api.uploadToWorkspace( + addToWorkspaces, + documents?.[0].location + ); + response.status(200).json({ success: true, error: null, documents }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/document/raw-text", + [validApiKey, validateWorkspaceSlugQuery], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Upload a file by specifying its raw text content and metadata values without having to upload a file.' + #swagger.requestBody = { + description: 'Text content and metadata of the file to be saved to the system. Use metadata-schema endpoint to get the possible metadata keys', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + example: { + "textContent": "This is the raw text that will be saved as a document in AnythingLLM.", + "addToWorkspaces": "workspace1,workspace2", + "metadata": { + "title": "This key is required. See in /server/endpoints/api/document/index.js:287", + "keyOne": "valueOne", + "keyTwo": "valueTwo", + "etc": "etc" + } + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + error: null, + documents: [ + { + "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc", + "url": "file://my-document.txt", + "title": "hello-world.txt", + "docAuthor": "no author found", + "description": "No description found.", + "docSource": "My custom description set during upload", + "chunkSource": "no chunk source specified", + "published": "1/16/2024, 3:46:33 PM", + "wordCount": 252, + "pageContent": "AnythingLLM is the best....", + "token_count_estimate": 447, + "location": "custom-documents/raw-my-doc-text-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const Collector = new CollectorApi(); + const requiredMetadata = ["title"]; + const { + textContent, + metadata: _metadata = {}, + addToWorkspaces = "", + } = reqBody(request); + const metadata = + typeof _metadata === "string" + ? safeJsonParse(_metadata, {}) + : _metadata; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + return response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Request will not be processed.`, + }) + .end(); + } + + if ( + !requiredMetadata.every( + (reqKey) => + Object.keys(metadata).includes(reqKey) && !!metadata[reqKey] + ) + ) { + return response + .status(422) + .json({ + success: false, + error: `You are missing required metadata key:value pairs in your request. Required metadata key:values are ${requiredMetadata + .map((v) => `'${v}'`) + .join(", ")}`, + }) + .end(); + } + + if (!textContent || textContent?.length === 0) { + return response + .status(422) + .json({ + success: false, + error: `The 'textContent' key cannot have an empty value.`, + }) + .end(); + } + + const { success, reason, documents } = await Collector.processRawText( + textContent, + metadata + ); + if (!success) { + return response + .status(500) + .json({ success: false, error: reason, documents }) + .end(); + } + + Collector.log( + `Document created successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("raw_document_uploaded"); + await EventLogs.logEvent("api_raw_document_uploaded"); + + if (!!addToWorkspaces) + await Document.api.uploadToWorkspace( + addToWorkspaces, + documents?.[0].location + ); + response.status(200).json({ success: true, error: null, documents }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/v1/documents", [validApiKey], async (_, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'List of all locally-stored documents in instance' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "localFiles": { + "name": "documents", + "type": "folder", + items: [ + { + "name": "my-stored-document.json", + "type": "file", + "id": "bb07c334-4dab-4419-9462-9d00065a49a1", + "url": "file://my-stored-document.txt", + "title": "my-stored-document.txt", + "cached": false + }, + ] + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const localFiles = await viewLocalFiles(); + response.status(200).json({ localFiles }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/v1/documents/folder/:folderName", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Get all documents stored in a specific folder.' + #swagger.parameters['folderName'] = { + in: 'path', + description: 'Name of the folder to retrieve documents from', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + folder: "custom-documents", + documents: [ + { + name: "document1.json", + type: "file", + cached: false, + pinnedWorkspaces: [], + watched: false, + more: "data", + }, + { + name: "document2.json", + type: "file", + cached: false, + pinnedWorkspaces: [], + watched: false, + more: "data", + }, + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { folderName } = request.params; + const result = await getDocumentsByFolder(folderName); + response.status(result.code).json({ + folder: result.folder, + documents: result.documents, + error: result.error, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/document/accepted-file-types", + [validApiKey], + async (_, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Check available filetypes and MIMEs that can be uploaded.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "types": { + "application/mbox": [ + ".mbox" + ], + "application/pdf": [ + ".pdf" + ], + "application/vnd.oasis.opendocument.text": [ + ".odt" + ], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [ + ".docx" + ], + "text/plain": [ + ".txt", + ".md" + ] + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const types = await new CollectorApi().acceptedFileTypes(); + if (!types) { + response.sendStatus(404).end(); + return; + } + + response.status(200).json({ types }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/document/metadata-schema", + [validApiKey], + async (_, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Get the known available metadata schema for when doing a raw-text upload and the acceptable type of value for each key.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "schema": { + "keyOne": "string | number | nullable", + "keyTwo": "string | number | nullable", + "specialKey": "number", + "title": "string", + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + response.status(200).json({ + schema: { + // If you are updating this be sure to update the collector METADATA_KEYS constant in /processRawText. + url: "string | nullable", + title: "string", + docAuthor: "string | nullable", + description: "string | nullable", + docSource: "string | nullable", + chunkSource: "string | nullable", + published: "epoch timestamp in ms | nullable", + }, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + // Be careful and place as last route to prevent override of the other /document/ GET + // endpoints! + app.get("/v1/document/:docName", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Get a single document by its unique AnythingLLM document name' + #swagger.parameters['docName'] = { + in: 'path', + description: 'Unique document name to find (name in /documents)', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "localFiles": { + "name": "documents", + "type": "folder", + items: [ + { + "name": "my-stored-document.txt-uuid1234.json", + "type": "file", + "id": "bb07c334-4dab-4419-9462-9d00065a49a1", + "url": "file://my-stored-document.txt", + "title": "my-stored-document.txt", + "cached": false + }, + ] + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { docName } = request.params; + const document = await findDocumentInDocuments(docName); + if (!document) { + response.sendStatus(404).end(); + return; + } + response.status(200).json({ document }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/v1/document/create-folder", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Create a new folder inside the documents storage directory.' + #swagger.requestBody = { + description: 'Name of the folder to create.', + required: true, + content: { + "application/json": { + schema: { + type: 'string', + example: { + "name": "new-folder" + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + message: null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { name } = reqBody(request); + const storagePath = path.join(documentsPath, normalizePath(name)); + if (!isWithin(path.resolve(documentsPath), path.resolve(storagePath))) + throw new Error("Invalid path name"); + + if (fs.existsSync(storagePath)) { + response.status(500).json({ + success: false, + message: "Folder by that name already exists", + }); + return; + } + + fs.mkdirSync(storagePath, { recursive: true }); + response.status(200).json({ success: true, message: null }); + } catch (e) { + console.error(e); + response.status(500).json({ + success: false, + message: `Failed to create folder: ${e.message}`, + }); + } + } + ); + + app.delete( + "/v1/document/remove-folder", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Remove a folder and all its contents from the documents storage directory.' + #swagger.requestBody = { + description: 'Name of the folder to remove.', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + properties: { + name: { + type: 'string', + example: "my-folder" + } + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + message: "Folder removed successfully" + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { name } = reqBody(request); + await purgeFolder(name); + response + .status(200) + .json({ success: true, message: "Folder removed successfully" }); + } catch (e) { + console.error(e); + response.status(500).json({ + success: false, + message: `Failed to remove folder: ${e.message}`, + }); + } + } + ); + + app.post( + "/v1/document/move-files", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Move files within the documents storage directory.' + #swagger.requestBody = { + description: 'Array of objects containing source and destination paths of files to move.', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + example: { + "files": [ + { + "from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json", + "to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json" + } + ] + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + message: null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { files } = reqBody(request); + const docpaths = files.map(({ from }) => from); + const documents = await Document.where({ docpath: { in: docpaths } }); + const embeddedFiles = documents.map((doc) => doc.docpath); + const moveableFiles = files.filter( + ({ from }) => !embeddedFiles.includes(from) + ); + const movePromises = moveableFiles.map(({ from, to }) => { + const sourcePath = path.join(documentsPath, normalizePath(from)); + const destinationPath = path.join(documentsPath, normalizePath(to)); + return new Promise((resolve, reject) => { + if ( + !isWithin(documentsPath, sourcePath) || + !isWithin(documentsPath, destinationPath) + ) + return reject("Invalid file location"); + + fs.rename(sourcePath, destinationPath, (err) => { + if (err) { + console.error(`Error moving file ${from} to ${to}:`, err); + reject(err); + } else { + resolve(); + } + }); + }); + }); + Promise.all(movePromises) + .then(() => { + const unmovableCount = files.length - moveableFiles.length; + if (unmovableCount > 0) { + response.status(200).json({ + success: true, + message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`, + }); + } else { + response.status(200).json({ + success: true, + message: null, + }); + } + }) + .catch((err) => { + console.error("Error moving files:", err); + response + .status(500) + .json({ success: false, message: "Failed to move some files." }); + }); + } catch (e) { + console.error(e); + response + .status(500) + .json({ success: false, message: "Failed to move files." }); + } + } + ); + + app.get( + "/v1/document/generated-files/:filename", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Documents'] + #swagger.description = 'Download a file generated by an agent skill (e.g., PDF, DOCX, XLSX, PPTX). The filename is returned in the `outputs` array of a chat response when an agent generates a file. Use the `storageFilename` value from that response to download the file.' + #swagger.parameters['filename'] = { + in: 'path', + description: 'The storage filename returned in the chat response outputs array (e.g., pdf-e9e14f28-d6b6-4f49-91a0-dd331517f567.pdf)', + required: true, + type: 'string' + } + #swagger.responses[200] = { + description: 'File downloaded successfully', + content: { + "application/octet-stream": { + schema: { + type: 'string', + format: 'binary' + } + } + } + } + #swagger.responses[400] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + error: "Invalid filename format" + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + error: "File not found" + } + } + } + } + } + */ + try { + const { filename } = request.params; + if (!filename) + return response.status(400).json({ error: "Filename is required" }); + + const parsed = createFilesLib.parseFilename(filename); + if (!parsed) { + return response + .status(400) + .json({ error: "Invalid filename format" }); + } + + const fileData = await createFilesLib.getGeneratedFile(filename); + if (!fileData) { + return response.status(404).json({ error: "File not found" }); + } + + const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`); + const safeFilename = createFilesLib.sanitizeFilenameForHeader(filename); + response.setHeader("Content-Type", mimeType); + response.setHeader( + "Content-Disposition", + `attachment; filename="${safeFilename}"` + ); + response.setHeader("Content-Length", fileData.buffer.length); + response.send(fileData.buffer); + + Telemetry.sendTelemetry("agent_generated_file_downloaded", { + type: mimeType, + }).catch(() => {}); + } catch (error) { + console.error( + "[document/generated-files] Download error:", + error.message + ); + return response.status(500).json({ error: "Failed to download file" }); + } + } + ); +} + +module.exports = { apiDocumentEndpoints }; diff --git a/server/endpoints/api/embed/index.js b/server/endpoints/api/embed/index.js new file mode 100644 index 0000000..dba6020 --- /dev/null +++ b/server/endpoints/api/embed/index.js @@ -0,0 +1,409 @@ +const { EmbedConfig } = require("../../../models/embedConfig"); +const { EmbedChats } = require("../../../models/embedChats"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); +const { reqBody } = require("../../../utils/http"); +const { Workspace } = require("../../../models/workspace"); + +function apiEmbedEndpoints(app) { + if (!app) return; + + app.get("/v1/embed", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'List all active embeds' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + embeds: [ + { + "id": 1, + "uuid": "embed-uuid-1", + "enabled": true, + "chat_mode": "query", + "createdAt": "2023-04-01T12:00:00Z", + "workspace": { + "id": 1, + "name": "Workspace 1" + }, + "chat_count": 10 + }, + { + "id": 2, + "uuid": "embed-uuid-2", + "enabled": false, + "chat_mode": "chat", + "createdAt": "2023-04-02T14:30:00Z", + "workspace": { + "id": 1, + "name": "Workspace 1" + }, + "chat_count": 10 + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const embeds = await EmbedConfig.whereWithWorkspace(); + const filteredEmbeds = embeds.map((embed) => ({ + id: embed.id, + uuid: embed.uuid, + enabled: embed.enabled, + chat_mode: embed.chat_mode, + createdAt: embed.createdAt, + workspace: { + id: embed.workspace.id, + name: embed.workspace.name, + }, + chat_count: embed._count.embed_chats, + })); + response.status(200).json({ embeds: filteredEmbeds }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/v1/embed/:embedUuid/chats", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'Get all chats for a specific embed' + #swagger.parameters['embedUuid'] = { + in: 'path', + description: 'UUID of the embed', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + chats: [ + { + "id": 1, + "session_id": "session-uuid-1", + "prompt": "Hello", + "response": "Hi there!", + "createdAt": "2023-04-01T12:00:00Z" + }, + { + "id": 2, + "session_id": "session-uuid-2", + "prompt": "How are you?", + "response": "I'm doing well, thank you!", + "createdAt": "2023-04-02T14:30:00Z" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + description: "Embed not found", + } + */ + try { + const { embedUuid } = request.params; + const chats = await EmbedChats.where({ + embed_config: { uuid: String(embedUuid) }, + }); + response.status(200).json({ chats }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/embed/:embedUuid/chats/:sessionUuid", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'Get chats for a specific embed and session' + #swagger.parameters['embedUuid'] = { + in: 'path', + description: 'UUID of the embed', + required: true, + type: 'string' + } + #swagger.parameters['sessionUuid'] = { + in: 'path', + description: 'UUID of the session', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + chats: [ + { + "id": 1, + "prompt": "Hello", + "response": "Hi there!", + "createdAt": "2023-04-01T12:00:00Z" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + description: "Embed or session not found", + } + */ + try { + const { embedUuid, sessionUuid } = request.params; + const chats = await EmbedChats.where({ + embed_config: { uuid: String(embedUuid) }, + session_id: String(sessionUuid), + }); + response.status(200).json({ chats }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post("/v1/embed/new", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'Create a new embed configuration' + #swagger.requestBody = { + description: 'JSON object containing embed configuration details', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + example: { + "workspace_slug": "workspace-slug-1", + "chat_mode": "chat", + "allowlist_domains": ["example.com"], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10 + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "embed": { + "id": 1, + "uuid": "embed-uuid-1", + "enabled": true, + "chat_mode": "chat", + "allowlist_domains": ["example.com"], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10, + "createdAt": "2023-04-01T12:00:00Z", + "workspace_slug": "workspace-slug-1" + }, + "error": null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + description: "Workspace not found" + } + */ + try { + const data = reqBody(request); + + if (!data.workspace_slug) + return response + .status(400) + .json({ error: "Workspace slug is required" }); + const workspace = await Workspace.get({ + slug: String(data.workspace_slug), + }); + + if (!workspace) + return response.status(404).json({ error: "Workspace not found" }); + + const { embed, message: error } = await EmbedConfig.new({ + ...data, + workspace_id: workspace.id, + }); + + response.status(200).json({ embed, error }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.post("/v1/embed/:embedUuid", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'Update an existing embed configuration' + #swagger.parameters['embedUuid'] = { + in: 'path', + description: 'UUID of the embed to update', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'JSON object containing embed configuration updates', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + example: { + "enabled": true, + "chat_mode": "chat", + "allowlist_domains": ["example.com"], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10 + } + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "success": true, + "error": null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + description: "Embed not found" + } + */ + try { + const { embedUuid } = request.params; + const data = reqBody(request); + + const embed = await EmbedConfig.get({ uuid: String(embedUuid) }); + if (!embed) { + return response.status(404).json({ error: "Embed not found" }); + } + + const { success, error } = await EmbedConfig.update(embed.id, data); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.delete( + "/v1/embed/:embedUuid", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Embed'] + #swagger.description = 'Delete an existing embed configuration' + #swagger.parameters['embedUuid'] = { + in: 'path', + description: 'UUID of the embed to delete', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "success": true, + "error": null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[404] = { + description: "Embed not found" + } + */ + try { + const { embedUuid } = request.params; + const embed = await EmbedConfig.get({ uuid: String(embedUuid) }); + if (!embed) + return response.status(404).json({ error: "Embed not found" }); + const success = await EmbedConfig.delete({ id: embed.id }); + response + .status(200) + .json({ success, error: success ? null : "Failed to delete embed" }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { apiEmbedEndpoints }; diff --git a/server/endpoints/api/index.js b/server/endpoints/api/index.js new file mode 100644 index 0000000..6879c01 --- /dev/null +++ b/server/endpoints/api/index.js @@ -0,0 +1,29 @@ +const { useSwagger } = require("../../swagger/utils"); +const { apiAdminEndpoints } = require("./admin"); +const { apiAuthEndpoints } = require("./auth"); +const { apiDocumentEndpoints } = require("./document"); +const { apiSystemEndpoints } = require("./system"); +const { apiWorkspaceEndpoints } = require("./workspace"); +const { apiWorkspaceThreadEndpoints } = require("./workspaceThread"); +const { apiUserManagementEndpoints } = require("./userManagement"); +const { apiOpenAICompatibleEndpoints } = require("./openai"); +const { apiEmbedEndpoints } = require("./embed"); + +// All endpoints must be documented and pass through the validApiKey Middleware. +// How to JSDoc an endpoint +// https://www.npmjs.com/package/swagger-autogen#openapi-3x +function developerEndpoints(app, router) { + if (!router) return; + useSwagger(app); + apiAuthEndpoints(router); + apiAdminEndpoints(router); + apiSystemEndpoints(router); + apiWorkspaceEndpoints(router); + apiDocumentEndpoints(router); + apiWorkspaceThreadEndpoints(router); + apiUserManagementEndpoints(router); + apiOpenAICompatibleEndpoints(router); + apiEmbedEndpoints(router); +} + +module.exports = { developerEndpoints }; diff --git a/server/endpoints/api/openai/compatibility-test-script.cjs b/server/endpoints/api/openai/compatibility-test-script.cjs new file mode 100644 index 0000000..96e56b0 --- /dev/null +++ b/server/endpoints/api/openai/compatibility-test-script.cjs @@ -0,0 +1,79 @@ +const OpenAI = require("openai"); + +/** + * @type {import("openai").OpenAI} + */ +const client = new OpenAI({ + baseURL: "http://localhost:3001/api/v1/openai", + apiKey: "ENTER_ANYTHINGLLM_API_KEY_HERE", +}); + +(async () => { + // Models endpoint testing. + console.log("Fetching /models"); + const modelList = await client.models.list(); + for await (const model of modelList) { + console.log({ model }); + } + + // Test sync chat completion + console.log("Running synchronous chat message"); + const syncCompletion = await client.chat.completions.create({ + messages: [ + { + role: "system", + content: "You are a helpful assistant who only speaks like a pirate.", + }, + { role: "user", content: "What is AnythingLLM?" }, + // { + // role: 'assistant', + // content: "Arrr, matey! AnythingLLM be a fine tool fer sailin' the treacherous sea o' information with a powerful language model at yer helm. It's a potent instrument to handle all manner o' tasks involvin' text, like answerin' questions, generating prose, or even havin' a chat with digital scallywags like meself. Be there any specific treasure ye seek in the realm o' AnythingLLM?" + // }, + // { role: "user", content: "Why are you talking like a pirate?" }, + ], + model: "anythingllm", // must be workspace-slug + }); + console.log(syncCompletion.choices[0]); + + // Test sync chat streaming completion + console.log("Running asynchronous chat message"); + const asyncCompletion = await client.chat.completions.create({ + messages: [ + { + role: "system", + content: "You are a helpful assistant who only speaks like a pirate.", + }, + { role: "user", content: "What is AnythingLLM?" }, + ], + model: "anythingllm", // must be workspace-slug + stream: true, + }); + + let message = ""; + for await (const chunk of asyncCompletion) { + message += chunk.choices[0].delta.content; + console.log({ message }); + } + + // Test embeddings creation + console.log("Creating embeddings"); + const embedding = await client.embeddings.create({ + model: null, // model is optional for AnythingLLM + input: "This is a test string for embedding", + encoding_format: "float", + }); + console.log("Embedding created successfully:"); + console.log(`Dimensions: ${embedding.data[0].embedding.length}`); + console.log( + `First few values:`, + embedding.data[0].embedding.slice(0, 5), + `+ ${embedding.data[0].embedding.length - 5} more` + ); + + // Vector DB functionality + console.log("Fetching /vector_stores"); + const vectorDBList = await client.beta.vectorStores.list(); + for await (const db of vectorDBList) { + console.log(db); + } +})(); diff --git a/server/endpoints/api/openai/helpers.js b/server/endpoints/api/openai/helpers.js new file mode 100644 index 0000000..6446730 --- /dev/null +++ b/server/endpoints/api/openai/helpers.js @@ -0,0 +1,50 @@ +/** + * Extracts text content from a multimodal message + * If the content has multiple text items, it will join them together with a newline. + * @param {string|Array} content - Message content that could be string or array of content objects + * @returns {string} - The text content + */ +function extractTextContent(content) { + if (!Array.isArray(content)) return content; + return content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +/** + * Detects mime type from a base64 data URL string, defaults to PNG if not detected + * @param {string} dataUrl - The data URL string (e.g. data:image/jpeg;base64,...) + * @returns {string} - The mime type or 'image/png' if not detected + */ +function getMimeTypeFromDataUrl(dataUrl) { + try { + const matches = dataUrl.match(/^data:([^;]+);base64,/); + return matches ? matches[1].toLowerCase() : "image/png"; + } catch { + return "image/png"; + } +} + +/** + * Extracts attachments from a multimodal message + * The attachments provided are in OpenAI format since this util is used in the OpenAI compatible chat. + * However, our backend internal chat uses the Attachment type we use elsewhere in the app so we have to convert it. + * @param {Array} content - Message content that could be string or array of content objects + * @returns {import("../../../utils/helpers").Attachment[]} - The attachments + */ +function extractAttachments(content) { + if (!Array.isArray(content)) return []; + return content + .filter((item) => item.type === "image_url") + .map((item, index) => ({ + name: `uploaded_image_${index}`, + mime: getMimeTypeFromDataUrl(item.image_url.url), + contentString: item.image_url.url, + })); +} + +module.exports = { + extractTextContent, + extractAttachments, +}; diff --git a/server/endpoints/api/openai/index.js b/server/endpoints/api/openai/index.js new file mode 100644 index 0000000..ae801c1 --- /dev/null +++ b/server/endpoints/api/openai/index.js @@ -0,0 +1,336 @@ +const { v4: uuidv4 } = require("uuid"); +const { Document } = require("../../../models/documents"); +const { Telemetry } = require("../../../models/telemetry"); +const { Workspace } = require("../../../models/workspace"); +const { getEmbeddingEngineSelection } = require("../../../utils/helpers"); +const { reqBody } = require("../../../utils/http"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); +const { EventLogs } = require("../../../models/eventLogs"); +const { + OpenAICompatibleChat, +} = require("../../../utils/chats/openaiCompatible"); +const { getModelTag } = require("../../utils"); +const { extractTextContent, extractAttachments } = require("./helpers"); + +function apiOpenAICompatibleEndpoints(app) { + if (!app) return; + + app.get("/v1/openai/models", [validApiKey], async (_, response) => { + /* + #swagger.tags = ['OpenAI Compatible Endpoints'] + #swagger.description = 'Get all available "models" which are workspaces you can use for chatting.' + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "example": { + "object": "list", + "data": [ + { + "id": "model-id-0", + "object": "model", + "created": 1686935002, + "owned_by": "organization-owner" + }, + { + "id": "model-id-1", + "object": "model", + "created": 1686935002, + "owned_by": "organization-owner" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const data = []; + const workspaces = await Workspace.where(); + for (const workspace of workspaces) { + data.push({ + id: workspace.slug, + object: "model", + created: Math.floor(Number(new Date(workspace.createdAt)) / 1000), + owned_by: workspace?.chatProvider || process.env.LLM_PROVIDER, + }); + } + return response.status(200).json({ + object: "list", + data, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/v1/openai/chat/completions", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['OpenAI Compatible Endpoints'] + #swagger.description = 'Execute a chat with a workspace with OpenAI compatibility. Supports streaming as well. Model must be a workspace slug from /models.' + #swagger.requestBody = { + description: 'Send a prompt to the workspace with full use of documents as if sending a chat in AnythingLLM. Only supports some values of OpenAI API. See example below.', + required: true, + content: { + "application/json": { + example: { + messages: [ + {"role":"system", content: "You are a helpful assistant"}, + {"role":"user", content: "What is AnythingLLM?"}, + {"role":"assistant", content: "AnythingLLM is...."}, + {"role":"user", content: "Follow up question..."} + ], + model: "sample-workspace", + stream: true, + temperature: 0.7 + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { + model, + messages = [], + temperature, + stream = false, + } = reqBody(request); + const workspace = await Workspace.get({ slug: String(model) }); + if (!workspace) return response.status(401).end(); + + const userMessage = messages.pop(); + if (userMessage.role !== "user") { + return response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: + "No user prompt found. Must be last element in message array with 'user' role.", + }); + } + + const systemPrompt = + messages.find((chat) => chat.role === "system")?.content ?? null; + const history = messages.filter((chat) => chat.role !== "system") ?? []; + + if (!stream) { + const chatResult = await OpenAICompatibleChat.chatSync({ + workspace, + systemPrompt, + history, + prompt: extractTextContent(userMessage.content), + attachments: extractAttachments(userMessage.content), + temperature: Number(temperature), + }); + + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: + workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + }); + return response.status(200).json(chatResult); + } + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + await OpenAICompatibleChat.streamChat({ + workspace, + systemPrompt, + history, + prompt: extractTextContent(userMessage.content), + attachments: extractAttachments(userMessage.content), + temperature: Number(temperature), + response, + }); + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + }); + response.end(); + } catch (e) { + console.error(e.message, e); + response.status(500).end(); + } + } + ); + + app.post( + "/v1/openai/embeddings", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['OpenAI Compatible Endpoints'] + #swagger.description = 'Get the embeddings of any arbitrary text string. This will use the embedder provider set in the system. Please ensure the token length of each string fits within the context of your embedder model.' + #swagger.requestBody = { + description: 'The input string(s) to be embedded. If the text is too long for the embedder model context, it will fail to embed. The vector and associated chunk metadata will be returned in the array order provided', + required: true, + content: { + "application/json": { + example: { + input: [ + "This is my first string to embed", + "This is my second string to embed", + ], + model: null, + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const body = reqBody(request); + // Support input or "inputs" (for backwards compatibility) as an array of strings or a single string + // TODO: "inputs" key support will eventually be fully removed. + let input = body?.input || body?.inputs || []; + // if input is not an array, make it an array and force to string content + if (!Array.isArray(input)) input = [String(input)]; + + if (Array.isArray(input)) { + if (input.length === 0) + throw new Error("Input array cannot be empty."); + const validArray = input.every((text) => typeof text === "string"); + if (!validArray) + throw new Error("All inputs to be embedded must be strings."); + } + + const Embedder = getEmbeddingEngineSelection(); + const embeddings = await Embedder.embedChunks(input); + const data = []; + embeddings.forEach((embedding, index) => { + data.push({ + object: "embedding", + embedding, + index, + }); + }); + + return response.status(200).json({ + object: "list", + data, + model: Embedder.model, + }); + } catch (e) { + console.error(e.message, e); + response.status(500).end(); + } + } + ); + + app.get( + "/v1/openai/vector_stores", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['OpenAI Compatible Endpoints'] + #swagger.description = 'List all the vector database collections connected to AnythingLLM. These are essentially workspaces but return their unique vector db identifier - this is the same as the workspace slug.' + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "example": { + "data": [ + { + "id": "slug-here", + "object": "vector_store", + "name": "My workspace", + "file_counts": { + "total": 3 + }, + "provider": "LanceDB" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + // We dump all in the first response and despite saying there is + // not more data the library still checks with a query param so if + // we detect one - respond with nothing. + if (Object.keys(request?.query ?? {}).length !== 0) { + return response.status(200).json({ + data: [], + has_more: false, + }); + } + + const data = []; + const VectorDBProvider = process.env.VECTOR_DB || "lancedb"; + const workspaces = await Workspace.where(); + + for (const workspace of workspaces) { + data.push({ + id: workspace.slug, + object: "vector_store", + name: workspace.name, + file_counts: { + total: await Document.count({ + workspaceId: Number(workspace.id), + }), + }, + provider: VectorDBProvider, + }); + } + return response.status(200).json({ + first_id: [...data].splice(0)?.[0]?.id, + last_id: [...data].splice(-1)?.[0]?.id ?? data.splice(1)?.[0]?.id, + data, + has_more: false, + }); + } catch (e) { + console.error(e.message, e); + response.status(500).end(); + } + } + ); +} + +module.exports = { apiOpenAICompatibleEndpoints }; diff --git a/server/endpoints/api/system/index.js b/server/endpoints/api/system/index.js new file mode 100644 index 0000000..017bec6 --- /dev/null +++ b/server/endpoints/api/system/index.js @@ -0,0 +1,285 @@ +const { EventLogs } = require("../../../models/eventLogs"); +const { SystemSettings } = require("../../../models/systemSettings"); +const { purgeDocument } = require("../../../utils/files/purgeDocument"); +const { getVectorDbClass } = require("../../../utils/helpers"); +const { + exportChatsAsType, + validExportTypes, +} = require("../../../utils/helpers/chat/convertTo"); +const { dumpENV, updateENV } = require("../../../utils/helpers/updateENV"); +const { reqBody } = require("../../../utils/http"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); + +function apiSystemEndpoints(app) { + if (!app) return; + + app.get("/v1/system/env-dump", async (_, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Dump all settings to file storage' + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + if (process.env.NODE_ENV !== "production") + return response.sendStatus(200).end(); + dumpENV(); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/v1/system", [validApiKey], async (_, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Get all current system settings that are defined.' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "settings": { + "VectorDB": "pinecone", + "PineConeKey": true, + "PineConeIndex": "my-pinecone-index", + "LLMProvider": "azure", + "[KEY_NAME]": "KEY_VALUE", + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const settings = await SystemSettings.currentSettings(); + response.status(200).json({ settings }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/v1/system/vector-count", [validApiKey], async (_, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Number of all vectors in connected vector database' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + "vectorCount": 5450 + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const VectorDb = getVectorDbClass(); + const vectorCount = await VectorDb.totalVectors(); + response.status(200).json({ vectorCount }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/v1/system/update-env", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Update a system setting or preference.' + #swagger.requestBody = { + description: 'Key pair object that matches a valid setting and value. Get keys from GET /v1/system or refer to codebase.', + required: true, + content: { + "application/json": { + example: { + VectorDB: "lancedb", + AnotherKey: "updatedValue" + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + newValues: {"[ENV_KEY]": 'Value'}, + error: 'error goes here, otherwise null' + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const body = reqBody(request); + const { newValues, error } = await updateENV(body); + response.status(200).json({ newValues, error }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/system/export-chats", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Export all of the chats from the system in a known format. Output depends on the type sent. Will be send with the correct header for the output.' + #swagger.parameters['type'] = { + in: 'query', + description: "Export format jsonl, json, csv, jsonAlpaca", + required: false, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: [ + { + "role": "user", + "content": "What is AnythinglLM?" + }, + { + "role": "assistant", + "content": "AnythingLLM is a knowledge graph and vector database management system built using NodeJS express server. It provides an interface for handling all interactions, including vectorDB management and LLM (Language Model) interactions." + }, + ] + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { type = "jsonl" } = request.query; + if (!validExportTypes.includes(type)) { + response.status(400).json({ + message: `Invalid export type: ${type}. Must be one of ${validExportTypes.join(", ")}`, + }); + return; + } + + const { contentType, data } = await exportChatsAsType( + type, + "workspace" + ); + await EventLogs.logEvent("exported_chats", { + type, + }); + response.setHeader("Content-Type", contentType); + response.status(200).send(data); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + app.delete( + "/v1/system/remove-documents", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['System Settings'] + #swagger.description = 'Permanently remove documents from the system.' + #swagger.requestBody = { + description: 'Array of document names to be removed permanently.', + required: true, + content: { + "application/json": { + schema: { + type: 'object', + properties: { + names: { + type: 'array', + items: { + type: 'string' + }, + example: [ + "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json" + ] + } + } + } + } + } + } + #swagger.responses[200] = { + description: 'Documents removed successfully.', + content: { + "application/json": { + schema: { + type: 'object', + example: { + success: true, + message: 'Documents removed successfully' + } + } + } + } + } + #swagger.responses[403] = { + description: 'Forbidden', + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[500] = { + description: 'Internal Server Error' + } + */ + try { + const { names } = reqBody(request); + for await (const name of names) await purgeDocument(name); + response + .status(200) + .json({ success: true, message: "Documents removed successfully" }) + .end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { apiSystemEndpoints }; diff --git a/server/endpoints/api/userManagement/index.js b/server/endpoints/api/userManagement/index.js new file mode 100644 index 0000000..733e1d3 --- /dev/null +++ b/server/endpoints/api/userManagement/index.js @@ -0,0 +1,124 @@ +const { User } = require("../../../models/user"); +const { TemporaryAuthToken } = require("../../../models/temporaryAuthToken"); +const { multiUserMode } = require("../../../utils/http"); +const { + simpleSSOEnabled, +} = require("../../../utils/middleware/simpleSSOEnabled"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); + +function apiUserManagementEndpoints(app) { + if (!app) return; + + app.get("/v1/users", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['User Management'] + #swagger.description = 'List all users' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + users: [ + { + "id": 1, + "username": "john_doe", + "role": "admin" + }, + { + "id": 2, + "username": "jane_smith", + "role": "default" + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Permission denied.", + } + */ + try { + if (!multiUserMode(response)) + return response + .status(401) + .send("Instance is not in Multi-User mode. Permission denied."); + + const users = await User.where(); + const filteredUsers = users.map((user) => ({ + id: user.id, + username: user.username, + role: user.role, + })); + response.status(200).json({ users: filteredUsers }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/v1/users/:id/issue-auth-token", + [validApiKey, simpleSSOEnabled], + async (request, response) => { + /* + #swagger.tags = ['User Management'] + #swagger.description = 'Issue a temporary auth token for a user' + #swagger.parameters['id'] = { + in: 'path', + description: 'The ID of the user to issue a temporary auth token for', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + token: "1234567890", + loginPath: "/sso/simple?token=1234567890" + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Permission denied.", + } + */ + try { + const { id: userId } = request.params; + const user = await User.get({ id: Number(userId) }); + if (!user) + return response.status(404).json({ error: "User not found" }); + + const { token, error } = await TemporaryAuthToken.issue(userId); + if (error) return response.status(500).json({ error: error }); + + response.status(200).json({ + token: String(token), + loginPath: `/sso/simple?token=${token}`, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { apiUserManagementEndpoints }; diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js new file mode 100644 index 0000000..e30baa0 --- /dev/null +++ b/server/endpoints/api/workspace/index.js @@ -0,0 +1,1031 @@ +const { v4: uuidv4 } = require("uuid"); +const { Document } = require("../../../models/documents"); +const { Telemetry } = require("../../../models/telemetry"); +const { DocumentVectors } = require("../../../models/vectors"); +const { Workspace } = require("../../../models/workspace"); +const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { + getVectorDbClass, + resolveProviderConnector, +} = require("../../../utils/helpers"); +const { multiUserMode, reqBody } = require("../../../utils/http"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); +const { VALID_CHAT_MODE } = require("../../../utils/chats/stream"); +const { EventLogs } = require("../../../models/eventLogs"); +const { + convertToChatHistory, + writeResponseChunk, +} = require("../../../utils/helpers/chat/responses"); +const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler"); +const { getModelTag } = require("../../utils"); +const { + workspaceDeletionProtection, +} = require("../../../utils/middleware/workspaceDeletionProtection"); + +function apiWorkspaceEndpoints(app) { + if (!app) return; + + app.post("/v1/workspace/new", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Create a new workspace' + #swagger.requestBody = { + description: 'JSON object containing workspace configuration.', + required: true, + content: { + "application/json": { + example: { + name: "My New Workspace", + similarityThreshold: 0.7, + openAiTemp: 0.7, + openAiHistory: 20, + openAiPrompt: "Custom prompt for responses", + queryRefusalResponse: "Custom refusal message", + chatMode: "chat", + topN: 4 + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + workspace: { + "id": 79, + "name": "Sample workspace", + "slug": "sample-workspace", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null + }, + message: 'Workspace created' + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { name = null, ...additionalFields } = reqBody(request); + const { workspace, message } = await Workspace.new( + name, + null, + additionalFields + ); + + if (!workspace) { + response.status(400).json({ workspace: null, message }); + return; + } + + await Telemetry.sendTelemetry("workspace_created", { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + await EventLogs.logEvent("api_workspace_created", { + workspaceName: workspace?.name || "Unknown Workspace", + }); + response.status(200).json({ workspace, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/v1/workspaces", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'List all current workspaces' + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + workspaces: [ + { + "id": 79, + "name": "Sample workspace", + "slug": "sample-workspace", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "threads": [] + } + ], + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const workspaces = await Workspace._findMany({ + where: {}, + include: { + threads: { + select: { + user_id: true, + slug: true, + name: true, + }, + }, + }, + }); + response.status(200).json({ workspaces }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/v1/workspace/:slug", [validApiKey], async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Get a workspace by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to find', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + workspace: [ + { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [], + "threads": [] + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug } = request.params; + const workspace = await Workspace._findMany({ + where: { + slug: String(slug), + }, + include: { + documents: true, + threads: { + select: { + user_id: true, + slug: true, + }, + }, + }, + }); + + response.status(200).json({ workspace }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.delete( + "/v1/workspace/:slug", + [validApiKey, workspaceDeletionProtection], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Deletes a workspace by its slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to delete', + required: true, + type: 'string' + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug = "" } = request.params; + const VectorDb = getVectorDbClass(); + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + const workspaceId = Number(workspace.id); + await WorkspaceChats.delete({ workspaceId: workspaceId }); + await DocumentVectors.deleteForWorkspace(workspaceId); + await Document.delete({ workspaceId: workspaceId }); + await Workspace.delete({ id: workspaceId }); + + await EventLogs.logEvent("api_workspace_deleted", { + workspaceName: workspace?.name || "Unknown Workspace", + }); + try { + await VectorDb["delete-namespace"]({ namespace: slug }); + } catch (e) { + console.error(e.message); + } + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/update", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Update workspace settings by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to find', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'JSON object containing new settings to update a workspace. All keys are optional and will not update unless provided', + required: true, + content: { + "application/json": { + example: { + "name": 'Updated Workspace Name', + "openAiTemp": 0.2, + "openAiHistory": 20, + "openAiPrompt": "Respond to all inquires and questions in binary - do not respond in any other format." + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + workspace: { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [] + }, + message: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug = null } = request.params; + const data = reqBody(request); + const currWorkspace = await Workspace.get({ slug: String(slug) }); + + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + const { workspace, message } = await Workspace.update( + currWorkspace.id, + data + ); + response.status(200).json({ workspace, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/workspace/:slug/chats", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Get a workspaces chats regardless of user by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to find', + required: true, + type: 'string' + } + #swagger.parameters['apiSessionId'] = { + in: 'query', + description: 'Optional apiSessionId to filter by', + required: false, + type: 'string' + } + #swagger.parameters['limit'] = { + in: 'query', + description: 'Optional number of chat messages to return (default: 100)', + required: false, + type: 'integer' + } + #swagger.parameters['orderBy'] = { + in: 'query', + description: 'Optional order of chat messages (asc or desc)', + required: false, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + history: [ + { + "role": "user", + "content": "What is AnythingLLM?", + "sentAt": 1692851630 + }, + { + "role": "assistant", + "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.", + "sources": [{"source": "object about source document and snippets used"}] + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug } = request.params; + const { + apiSessionId = null, + limit = 100, + orderBy = "asc", + } = request.query; + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + const validLimit = Math.max(1, parseInt(limit)); + const validOrderBy = ["asc", "desc"].includes(orderBy) + ? orderBy + : "asc"; + + const history = apiSessionId + ? await WorkspaceChats.forWorkspaceByApiSessionId( + workspace.id, + apiSessionId, + validLimit, + { createdAt: validOrderBy } + ) + : await WorkspaceChats.forWorkspace(workspace.id, validLimit, { + createdAt: validOrderBy, + }); + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/update-embeddings", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Add or remove documents from a workspace by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to find', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'JSON object of additions and removals of documents to add to update a workspace. The value should be the folder + filename with the exclusions of the top-level documents path.', + required: true, + content: { + "application/json": { + example: { + adds: ["custom-documents/my-pdf.pdf-hash.json"], + deletes: ["custom-documents/anythingllm.txt-hash.json"] + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + workspace: { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [] + }, + message: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug = null } = request.params; + const { adds = [], deletes = [] } = reqBody(request); + const currWorkspace = await Workspace.get({ slug: String(slug) }); + + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + await Document.removeDocuments(currWorkspace, deletes); + await Document.addDocuments(currWorkspace, adds); + const updatedWorkspace = await Workspace.get({ + id: Number(currWorkspace.id), + }); + response.status(200).json({ workspace: updatedWorkspace }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/update-pin", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Add or remove pin from a document in a workspace by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to find', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'JSON object with the document path and pin status to update.', + required: true, + content: { + "application/json": { + example: { + docPath: "custom-documents/my-pdf.pdf-hash.json", + pinStatus: true + } + } + } + } + #swagger.responses[200] = { + description: 'OK', + content: { + "application/json": { + schema: { + type: 'object', + example: { + message: 'Pin status updated successfully' + } + } + } + } + } + #swagger.responses[404] = { + description: 'Document not found' + } + #swagger.responses[500] = { + description: 'Internal Server Error' + } + */ + try { + const { slug = null } = request.params; + const { docPath, pinStatus = false } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + + const document = await Document.get({ + workspaceId: workspace.id, + docpath: docPath, + }); + if (!document) return response.sendStatus(404).end(); + + await Document.update(document.id, { pinned: pinStatus }); + return response + .status(200) + .json({ message: "Pin status updated successfully" }) + .end(); + } catch (error) { + console.error("Error processing the pin status update:", error); + return response.status(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/chat", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Execute a chat with a workspace' + #swagger.requestBody = { + description: 'Send a prompt to the workspace and the type of conversation (automatic, query or chat).
Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.
Automatic: Will use tool-calling if the provider supports native tool calling without needing to invoke @agent.
Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.
Attachments: Can include images and documents.
     Document attachments: must have the mime type application/anythingllm-document - otherwise it will be passed to the LLM as an image and may fail to process. This uses the built-in document processor to first parse the document to text before injecting it into the context window.', + required: true, + content: { + "application/json": { + example: { + message: "What is AnythingLLM?", + mode:"automatic | query | chat", + sessionId: "identifier-to-partition-chats-by-external-id", + attachments: [ + { + name: "image.png", + mime: "image/png", + contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + name: "this is a document.pdf", + mime: "application/anythingllm-document", + contentString: "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + reset: false + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + id: 'chat-uuid', + type: "abort | textResponse", + textResponse: "Response to your query", + sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM,"}], + close: true, + error: "null | text string of the failure mode." + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug } = request.params; + const { + message, + mode = null, + sessionId = null, + attachments = [], + reset = false, + } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `Workspace ${slug} is not a valid workspace.`, + }); + return; + } + + const resolvedMode = mode ?? workspace.chatMode; + if ( + (!message?.length || !VALID_CHAT_MODE.includes(resolvedMode)) && + !reset + ) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: !message?.length + ? "Message is empty" + : `${resolvedMode} is not a valid mode.`, + }); + return; + } + + const result = await ApiChatHandler.chatSync({ + workspace, + message, + mode: resolvedMode, + user: null, + thread: null, + sessionId: !!sessionId ? String(sessionId) : null, + attachments, + reset, + }); + + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: + workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + }); + return response.status(200).json({ ...result }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + } + } + ); + + app.post( + "/v1/workspace/:slug/stream-chat", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Execute a streamable chat with a workspace' + #swagger.requestBody = { + description: 'Send a prompt to the workspace and the type of conversation (automatic, query or chat).
Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.
Automatic: Will use tool-calling if the provider supports native tool calling without needing to invoke @agent.
Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.
Attachments: Can include images and documents.
     Document attachments: must have the mime type application/anythingllm-document - otherwise it will be passed to the LLM as an image and may fail to process. This uses the built-in document processor to first parse the document to text before injecting it into the context window.', + required: true, + content: { + "application/json": { + example: { + message: "What is AnythingLLM?", + mode: "automatic | query | chat", + sessionId: "identifier-to-partition-chats-by-external-id", + attachments: [ + { + name: "image.png", + mime: "image/png", + contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + name: "this is a document.pdf", + mime: "application/anythingllm-document", + contentString: "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + reset: false + } + } + } + } + #swagger.responses[200] = { + content: { + "text/event-stream": { + schema: { + type: 'array', + items: { + type: 'string', + }, + example: [ + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "First chunk", + sources: [], + close: false, + error: "null | text string of the failure mode." + }, + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "chunk two", + sources: [], + close: false, + error: "null | text string of the failure mode." + }, + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "final chunk of LLM output!", + sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."}], + close: true, + error: "null | text string of the failure mode." + } + ] + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug } = request.params; + const { + message, + mode = null, + sessionId = null, + attachments = [], + reset = false, + } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `Workspace ${slug} is not a valid workspace.`, + }); + return; + } + + const resolvedMode = mode ?? workspace.chatMode; + if ( + (!message?.length || !VALID_CHAT_MODE.includes(resolvedMode)) && + !reset + ) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: !message?.length + ? "Message is empty" + : `${resolvedMode} is not a valid mode.`, + }); + return; + } + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + await ApiChatHandler.streamChat({ + response, + workspace, + message, + mode: resolvedMode, + user: null, + thread: null, + sessionId: !!sessionId ? String(sessionId) : null, + attachments, + reset, + }); + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: + workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + }); + response.end(); + } catch (e) { + console.error(e.message, e); + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + response.end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/vector-search", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspaces'] + #swagger.description = 'Perform a vector similarity search in a workspace' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace to search in', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'Query to perform vector search with and optional parameters', + required: true, + content: { + "application/json": { + example: { + query: "What is the meaning of life?", + topN: 4, + scoreThreshold: 0.75 + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + results: [ + { + id: "5a6bee0a-306c-47fc-942b-8ab9bf3899c4", + text: "Document chunk content...", + metadata: { + url: "file://document.txt", + title: "document.txt", + author: "no author specified", + description: "no description found", + docSource: "post:123456", + chunkSource: "document.txt", + published: "12/1/2024, 11:39:39 AM", + wordCount: 8, + tokenCount: 9 + }, + distance: 0.541887640953064, + score: 0.45811235904693604 + } + ] + } + } + } + } + } + */ + try { + const { slug } = request.params; + const { query, topN, scoreThreshold } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) + return response.status(400).json({ + message: `Workspace ${slug} is not a valid workspace.`, + }); + + if (!query?.length) + return response.status(400).json({ + message: "Query parameter cannot be empty.", + }); + + const VectorDb = getVectorDbClass(); + const hasVectorizedSpace = await VectorDb.hasNamespace(workspace.slug); + const embeddingsCount = await VectorDb.namespaceCount(workspace.slug); + + if (!hasVectorizedSpace || embeddingsCount === 0) + return response.status(200).json({ + results: [], + message: "No embeddings found for this workspace.", + }); + + const parseSimilarityThreshold = () => { + let input = parseFloat(scoreThreshold); + if (isNaN(input) || input < 0 || input > 1) + return workspace?.similarityThreshold ?? 0.25; + return input; + }; + + const parseTopN = () => { + let input = Number(topN); + if (isNaN(input) || input < 1) return workspace?.topN ?? 4; + return input; + }; + + const { connector: LLMConnector } = await resolveProviderConnector({ + workspace, + prompt: String(query), + }); + + const results = await VectorDb.performSimilaritySearch({ + namespace: workspace.slug, + input: String(query), + LLMConnector, + similarityThreshold: parseSimilarityThreshold(), + topN: parseTopN(), + rerank: workspace?.vectorSearchMode === "rerank", + }); + + response.status(200).json({ + results: results.sources.map((source) => ({ + id: source.id, + text: source.text, + metadata: { + url: source.url, + title: source.title, + author: source.docAuthor, + description: source.description, + docSource: source.docSource, + chunkSource: source.chunkSource, + published: source.published, + wordCount: source.wordCount, + tokenCount: source.token_count_estimate, + }, + distance: source._distance, + score: source.score, + })), + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { apiWorkspaceEndpoints }; diff --git a/server/endpoints/api/workspaceThread/index.js b/server/endpoints/api/workspaceThread/index.js new file mode 100644 index 0000000..9d06fb1 --- /dev/null +++ b/server/endpoints/api/workspaceThread/index.js @@ -0,0 +1,668 @@ +const { v4: uuidv4 } = require("uuid"); +const { WorkspaceThread } = require("../../../models/workspaceThread"); +const { Workspace } = require("../../../models/workspace"); +const { validApiKey } = require("../../../utils/middleware/validApiKey"); +const { reqBody, multiUserMode } = require("../../../utils/http"); +const { VALID_CHAT_MODE } = require("../../../utils/chats/stream"); +const { Telemetry } = require("../../../models/telemetry"); +const { EventLogs } = require("../../../models/eventLogs"); +const { + writeResponseChunk, + convertToChatHistory, +} = require("../../../utils/helpers/chat/responses"); +const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { User } = require("../../../models/user"); +const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler"); +const { getModelTag } = require("../../utils"); + +function apiWorkspaceThreadEndpoints(app) { + if (!app) return; + + app.post( + "/v1/workspace/:slug/thread/new", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Create a new workspace thread' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'Optional userId associated with the thread, thread slug and thread name', + required: false, + content: { + "application/json": { + example: { + userId: 1, + name: 'Name', + slug: 'thread-slug' + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + thread: { + "id": 1, + "name": "Thread", + "slug": "thread-uuid", + "user_id": 1, + "workspace_id": 1 + }, + message: null + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const wslug = request.params.slug; + let { userId = null, name = null, slug = null } = reqBody(request); + const workspace = await Workspace.get({ slug: String(wslug) }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + // If the system is not multi-user and you pass in a userId + // it needs to be nullified as no users exist. This can still fail validation + // as we don't check if the userID is valid. + if (!response.locals.multiUserMode && !!userId) userId = null; + + const { thread, message } = await WorkspaceThread.new( + workspace, + userId ? Number(userId) : null, + { name, slug } + ); + + await Telemetry.sendTelemetry("workspace_thread_created", { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + }); + await EventLogs.logEvent("api_workspace_thread_created", { + workspaceName: workspace?.name || "Unknown Workspace", + }); + response.status(200).json({ thread, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/thread/:threadSlug/update", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Update thread name by its unique slug.' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.parameters['threadSlug'] = { + in: 'path', + description: 'Unique slug of thread', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'JSON object containing new name to update the thread.', + required: true, + content: { + "application/json": { + example: { + "name": 'Updated Thread Name' + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + thread: { + "id": 1, + "name": "Updated Thread Name", + "slug": "thread-uuid", + "user_id": 1, + "workspace_id": 1 + }, + message: null, + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug, threadSlug } = request.params; + const { name } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + if (!workspace) { + response.status(404).json({ message: "Workspace not found" }); + return; + } + + const thread = await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }); + if (!thread) { + response.status(404).json({ message: "Thread not found" }); + return; + } + + const { thread: updatedThread, message } = await WorkspaceThread.update( + thread, + { name } + ); + response.status(200).json({ thread: updatedThread, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/v1/workspace/:slug/thread/:threadSlug", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Delete a workspace thread' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.parameters['threadSlug'] = { + in: 'path', + description: 'Unique slug of thread', + required: true, + type: 'string' + } + #swagger.responses[200] = { + description: 'Thread deleted successfully' + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug, threadSlug } = request.params; + const workspace = await Workspace.get({ slug: String(slug) }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + await WorkspaceThread.delete({ + slug: String(threadSlug), + workspace_id: workspace.id, + }); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/v1/workspace/:slug/thread/:threadSlug/chats", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Get chats for a workspace thread' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.parameters['threadSlug'] = { + in: 'path', + description: 'Unique slug of thread', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + history: [ + { + "role": "user", + "content": "What is AnythingLLM?", + "sentAt": 1692851630 + }, + { + "role": "assistant", + "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.", + "sources": [{"source": "object about source document and snippets used"}] + } + ] + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug, threadSlug } = request.params; + const workspace = await Workspace.get({ slug: String(slug) }); + if (!workspace) { + response.status(404).json({ message: "Workspace not found" }); + return; + } + + const thread = await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }); + if (!thread) { + response.status(404).json({ message: "Thread not found" }); + return; + } + + const history = await WorkspaceChats.where( + { + workspaceId: workspace.id, + thread_id: thread.id, + api_session_id: null, // Do not include API session chats. + include: true, + }, + null, + { id: "asc" } + ); + + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/v1/workspace/:slug/thread/:threadSlug/chat", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Chat with a workspace thread' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.parameters['threadSlug'] = { + in: 'path', + description: 'Unique slug of thread', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'Send a prompt to the workspace thread and the type of conversation (query or chat).', + required: true, + content: { + "application/json": { + example: { + message: "What is AnythingLLM?", + mode: "query | chat", + userId: 1, + attachments: [ + { + name: "image.png", + mime: "image/png", + contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + reset: false + } + } + } + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + id: 'chat-uuid', + type: "abort | textResponse", + textResponse: "Response to your query", + sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM."}], + close: true, + error: "null | text string of the failure mode." + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug, threadSlug } = request.params; + const { + message, + mode = null, + userId, + attachments = [], + reset = false, + } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + if (!workspace) { + response.status(404).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `Workspace ${slug} not found.`, + }); + return; + } + + const thread = await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }); + if (!thread) { + response.status(404).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `Thread ${threadSlug} not found.`, + }); + return; + } + + const resolvedMode = mode ?? workspace.chatMode; + if ( + (!message?.length || !VALID_CHAT_MODE.includes(resolvedMode)) && + !reset + ) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: !message?.length + ? "Message is empty" + : `${resolvedMode} is not a valid mode.`, + }); + return; + } + + const user = userId ? await User.get({ id: Number(userId) }) : null; + const result = await ApiChatHandler.chatSync({ + workspace, + message, + mode: resolvedMode, + user, + thread, + attachments, + reset, + }); + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + threadName: thread?.name, + userId: user?.id, + }); + response.status(200).json({ ...result }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + } + } + ); + + app.post( + "/v1/workspace/:slug/thread/:threadSlug/stream-chat", + [validApiKey], + async (request, response) => { + /* + #swagger.tags = ['Workspace Threads'] + #swagger.description = 'Stream chat with a workspace thread' + #swagger.parameters['slug'] = { + in: 'path', + description: 'Unique slug of workspace', + required: true, + type: 'string' + } + #swagger.parameters['threadSlug'] = { + in: 'path', + description: 'Unique slug of thread', + required: true, + type: 'string' + } + #swagger.requestBody = { + description: 'Send a prompt to the workspace thread and the type of conversation (query or chat).', + required: true, + content: { + "application/json": { + example: { + message: "What is AnythingLLM?", + mode: "query | chat", + userId: 1, + attachments: [ + { + name: "image.png", + mime: "image/png", + contentString: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + name: "this is a document.pdf", + mime: "application/anythingllm-document", + contentString: "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + reset: false + } + } + } + } + #swagger.responses[200] = { + content: { + "text/event-stream": { + schema: { + type: 'array', + items: { + type: 'string', + }, + example: [ + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "First chunk", + sources: [], + close: false, + error: "null | text string of the failure mode." + }, + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "chunk two", + sources: [], + close: false, + error: "null | text string of the failure mode." + }, + { + id: 'uuid-123', + type: "abort | textResponseChunk", + textResponse: "final chunk of LLM output!", + sources: [{title: "anythingllm.txt", chunk: "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk."}], + close: true, + error: "null | text string of the failure mode." + } + ] + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + */ + try { + const { slug, threadSlug } = request.params; + const { + message, + mode = null, + userId, + attachments = [], + reset = false, + } = reqBody(request); + const workspace = await Workspace.get({ slug: String(slug) }); + const thread = await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }); + + if (!workspace || !thread) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `Workspace ${slug} or thread ${threadSlug} is not valid.`, + }); + return; + } + + const resolvedMode = mode ?? workspace.chatMode; + if ( + (!message?.length || !VALID_CHAT_MODE.includes(resolvedMode)) && + !reset + ) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: !message?.length + ? "Message is empty" + : `${resolvedMode} is not a valid mode.`, + }); + return; + } + + const user = userId ? await User.get({ id: Number(userId) }) : null; + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + await ApiChatHandler.streamChat({ + response, + workspace, + message, + mode: resolvedMode, + user, + thread, + attachments, + reset, + }); + await Telemetry.sendTelemetry("sent_chat", { + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + await EventLogs.logEvent("api_sent_chat", { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + threadName: thread?.name, + userId: user?.id, + }); + response.end(); + } catch (e) { + console.error(e.message, e); + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + response.end(); + } + } + ); +} + +module.exports = { apiWorkspaceThreadEndpoints }; diff --git a/server/endpoints/browserExtension.js b/server/endpoints/browserExtension.js new file mode 100644 index 0000000..844da00 --- /dev/null +++ b/server/endpoints/browserExtension.js @@ -0,0 +1,224 @@ +const { Workspace } = require("../models/workspace"); +const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey"); +const { Document } = require("../models/documents"); +const { + validBrowserExtensionApiKey, +} = require("../utils/middleware/validBrowserExtensionApiKey"); +const { CollectorApi } = require("../utils/collectorApi"); +const { reqBody, multiUserMode, userFromSession } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { Telemetry } = require("../models/telemetry"); + +function browserExtensionEndpoints(app) { + if (!app) return; + + app.get( + "/browser-extension/check", + [validBrowserExtensionApiKey], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspaces = multiUserMode(response) + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + + const apiKeyId = response.locals.apiKey.id; + response.status(200).json({ + connected: true, + workspaces, + apiKeyId, + }); + } catch (error) { + console.error(error); + response + .status(500) + .json({ connected: false, error: "Failed to fetch workspaces" }); + } + } + ); + + app.delete( + "/browser-extension/disconnect", + [validBrowserExtensionApiKey], + async (_request, response) => { + try { + const apiKeyId = response.locals.apiKey.id; + const { success, error } = + await BrowserExtensionApiKey.delete(apiKeyId); + if (!success) throw new Error(error); + response.status(200).json({ success: true }); + } catch (error) { + console.error(error); + response + .status(500) + .json({ error: "Failed to disconnect and revoke API key" }); + } + } + ); + + app.get( + "/browser-extension/workspaces", + [validBrowserExtensionApiKey], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspaces = multiUserMode(response) + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + + response.status(200).json({ workspaces }); + } catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to fetch workspaces" }); + } + } + ); + + app.post( + "/browser-extension/embed-content", + [validBrowserExtensionApiKey], + async (request, response) => { + try { + const { workspaceId, textContent, metadata } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { id: parseInt(workspaceId) }) + : await Workspace.get({ id: parseInt(workspaceId) }); + + if (!workspace) { + response.status(404).json({ error: "Workspace not found" }); + return; + } + + const Collector = new CollectorApi(); + const { success, reason, documents } = await Collector.processRawText( + textContent, + metadata + ); + + if (!success) { + response.status(500).json({ success: false, error: reason }); + return; + } + + const { failedToEmbed = [], errors = [] } = await Document.addDocuments( + workspace, + [documents[0].location], + user?.id + ); + + if (failedToEmbed.length > 0) { + response.status(500).json({ success: false, error: errors[0] }); + return; + } + + await Telemetry.sendTelemetry("browser_extension_embed_content"); + response.status(200).json({ success: true }); + } catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to embed content" }); + } + } + ); + + app.post( + "/browser-extension/upload-content", + [validBrowserExtensionApiKey], + async (request, response) => { + try { + const { textContent, metadata } = reqBody(request); + const Collector = new CollectorApi(); + const { success, reason } = await Collector.processRawText( + textContent, + metadata + ); + + if (!success) { + response.status(500).json({ success: false, error: reason }); + return; + } + + await Telemetry.sendTelemetry("browser_extension_upload_content"); + response.status(200).json({ success: true }); + } catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to embed content" }); + } + } + ); + + // Internal endpoints for managing API keys + app.get( + "/browser-extension/api-keys", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const apiKeys = multiUserMode(response) + ? await BrowserExtensionApiKey.whereWithUser(user) + : await BrowserExtensionApiKey.where(); + + response.status(200).json({ success: true, apiKeys }); + } catch (error) { + console.error(error); + response + .status(500) + .json({ success: false, error: "Failed to fetch API keys" }); + } + } + ); + + app.post( + "/browser-extension/api-keys/new", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { apiKey, error } = await BrowserExtensionApiKey.create( + user?.id || null + ); + if (error) throw new Error(error); + response.status(200).json({ + apiKey: apiKey.key, + }); + } catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to create API key" }); + } + } + ); + + app.delete( + "/browser-extension/api-keys/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { id } = request.params; + const user = await userFromSession(request, response); + + if (multiUserMode(response) && user.role !== ROLES.admin) { + const apiKey = await BrowserExtensionApiKey.get({ + id: parseInt(id), + user_id: user?.id, + }); + if (!apiKey) { + return response.status(403).json({ error: "Unauthorized" }); + } + } + + const { success, error } = await BrowserExtensionApiKey.delete(id); + if (!success) throw new Error(error); + response.status(200).json({ success: true }); + } catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to revoke API key" }); + } + } + ); +} + +module.exports = { browserExtensionEndpoints }; diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js new file mode 100644 index 0000000..1f7e5ec --- /dev/null +++ b/server/endpoints/chat.js @@ -0,0 +1,212 @@ +const { v4: uuidv4 } = require("uuid"); +const { reqBody, userFromSession, multiUserMode } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); +const { streamChatWithWorkspace } = require("../utils/chats/stream"); +const { + ROLES, + flexUserRoleValid, +} = require("../utils/middleware/multiUserProtected"); +const { EventLogs } = require("../models/eventLogs"); +const { + validWorkspaceAndThreadSlug, + validWorkspaceSlug, +} = require("../utils/middleware/validWorkspace"); +const { writeResponseChunk } = require("../utils/helpers/chat/responses"); +const { WorkspaceThread } = require("../models/workspaceThread"); +const { User } = require("../models/user"); +const { getModelTag } = require("./utils"); + +function chatEndpoints(app) { + if (!app) return; + + app.post( + "/workspace/:slug/stream-chat", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { message, attachments = [] } = reqBody(request); + const workspace = response.locals.workspace; + + if (typeof message !== "string" || message.trim().length === 0) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: "Message is empty.", + }); + return; + } + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + if (multiUserMode(response) && !(await User.canSendChat(user))) { + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`, + }); + return; + } + + await streamChatWithWorkspace( + response, + workspace, + message, + workspace?.chatMode, + user, + null, + attachments + ); + await Telemetry.sendTelemetry("sent_chat", { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + multiModal: Array.isArray(attachments) && attachments?.length !== 0, + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + + await EventLogs.logEvent( + "sent_chat", + { + workspaceName: workspace?.name, + chatModel: workspace?.chatModel || "System Default", + }, + user?.id + ); + response.end(); + } catch (e) { + console.error(e); + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + response.end(); + } + } + ); + + app.post( + "/workspace/:slug/thread/:threadSlug/stream-chat", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { message, attachments = [] } = reqBody(request); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + + if (typeof message !== "string" || message.trim().length === 0) { + response.status(400).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: "Message is empty.", + }); + return; + } + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + if (multiUserMode(response) && !(await User.canSendChat(user))) { + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `You have met your maximum 24 hour chat quota of ${user.dailyMessageLimit} chats. Try again later.`, + }); + return; + } + + await streamChatWithWorkspace( + response, + workspace, + message, + workspace?.chatMode, + user, + thread, + attachments + ); + + // If thread was renamed emit event to frontend via special `action` response. + await WorkspaceThread.autoRenameThread({ + thread, + workspace, + user, + prompt: message, + onRename: (thread) => { + writeResponseChunk(response, { + action: "rename_thread", + thread: { + slug: thread.slug, + name: thread.name, + }, + }); + }, + }); + + await Telemetry.sendTelemetry("sent_chat", { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + multiModal: Array.isArray(attachments) && attachments?.length !== 0, + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + + await EventLogs.logEvent( + "sent_chat", + { + workspaceName: workspace.name, + thread: thread.name, + chatModel: workspace?.chatModel || "System Default", + }, + user?.id + ); + response.end(); + } catch (e) { + console.error(e); + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + response.end(); + } + } + ); +} + +module.exports = { chatEndpoints }; diff --git a/server/endpoints/communityHub.js b/server/endpoints/communityHub.js new file mode 100644 index 0000000..241434a --- /dev/null +++ b/server/endpoints/communityHub.js @@ -0,0 +1,219 @@ +const { SystemSettings } = require("../models/systemSettings"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { reqBody } = require("../utils/http"); +const { CommunityHub } = require("../models/communityHub"); +const { + communityHubDownloadsEnabled, + communityHubItem, +} = require("../utils/middleware/communityHubDownloadsEnabled"); +const { EventLogs } = require("../models/eventLogs"); +const { Telemetry } = require("../models/telemetry"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); + +function communityHubEndpoints(app) { + if (!app) return; + + app.get( + "/community-hub/settings", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_, response) => { + try { + const { connectionKey } = await SystemSettings.hubSettings(); + response.status(200).json({ success: true, connectionKey }); + } catch (error) { + console.error(error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); + + app.post( + "/community-hub/settings", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const data = reqBody(request); + const result = await SystemSettings.updateSettings(data); + if (result.error) throw new Error(result.error); + response.status(200).json({ success: true, error: null }); + } catch (error) { + console.error(error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); + + app.get( + "/community-hub/explore", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_, response) => { + try { + const exploreItems = await CommunityHub.fetchExploreItems(); + response.status(200).json({ success: true, result: exploreItems }); + } catch (error) { + console.error(error); + response.status(500).json({ + success: false, + result: null, + error: error.message, + }); + } + } + ); + + app.post( + "/community-hub/item", + [validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem], + async (_request, response) => { + try { + response.status(200).json({ + success: true, + item: response.locals.bundleItem, + error: null, + }); + } catch (error) { + console.error(error); + response.status(500).json({ + success: false, + item: null, + error: error.message, + }); + } + } + ); + + /** + * Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts. + */ + app.post( + "/community-hub/apply", + [validatedRequest, flexUserRoleValid([ROLES.admin]), communityHubItem], + async (request, response) => { + try { + const { options = {} } = reqBody(request); + const item = response.locals.bundleItem; + const { error: applyError } = await CommunityHub.applyItem(item, { + ...options, + currentUser: response.locals?.user, + }); + if (applyError) throw new Error(applyError); + + await Telemetry.sendTelemetry("community_hub_import", { + itemType: response.locals.bundleItem.itemType, + visibility: response.locals.bundleItem.visibility, + }); + await EventLogs.logEvent( + "community_hub_import", + { + itemId: response.locals.bundleItem.id, + itemType: response.locals.bundleItem.itemType, + }, + response.locals?.user?.id + ); + + response.status(200).json({ success: true, error: null }); + } catch (error) { + console.error(error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); + + /** + * Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it. + * or whatever the item type requires. This is not used if the item is a simple text responses like + * slash commands or system prompts. + */ + app.post( + "/community-hub/import", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin]), + communityHubItem, + communityHubDownloadsEnabled, + ], + async (_, response) => { + try { + const { error: importError } = await CommunityHub.importBundleItem({ + url: response.locals.bundleUrl, + item: response.locals.bundleItem, + }); + if (importError) throw new Error(importError); + + await Telemetry.sendTelemetry("community_hub_import", { + itemType: response.locals.bundleItem.itemType, + visibility: response.locals.bundleItem.visibility, + }); + await EventLogs.logEvent( + "community_hub_import", + { + itemId: response.locals.bundleItem.id, + itemType: response.locals.bundleItem.itemType, + }, + response.locals?.user?.id + ); + + response.status(200).json({ success: true, error: null }); + } catch (error) { + console.error(error); + response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + app.get( + "/community-hub/items", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_, response) => { + try { + const { connectionKey } = await SystemSettings.hubSettings(); + const items = await CommunityHub.fetchUserItems(connectionKey); + response.status(200).json({ success: true, ...items }); + } catch (error) { + console.error(error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); + + app.post( + "/community-hub/:communityHubItemType/create", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { communityHubItemType } = request.params; + const { connectionKey } = await SystemSettings.hubSettings(); + if (!connectionKey) + throw new Error("Community Hub connection key not found"); + + const data = reqBody(request); + const { success, error, itemId } = await CommunityHub.createStaticItem( + communityHubItemType, + data, + connectionKey + ); + if (!success) throw new Error(error); + + await EventLogs.logEvent( + "community_hub_publish", + { itemType: communityHubItemType }, + response.locals?.user?.id + ); + response + .status(200) + .json({ success: true, error: null, item: { id: itemId } }); + } catch (error) { + console.error(error); + response.status(500).json({ success: false, error: error.message }); + } + } + ); +} + +module.exports = { communityHubEndpoints }; diff --git a/server/endpoints/document.js b/server/endpoints/document.js new file mode 100644 index 0000000..e4c311a --- /dev/null +++ b/server/endpoints/document.js @@ -0,0 +1,111 @@ +const { Document } = require("../models/documents"); +const { normalizePath, documentsPath, isWithin } = require("../utils/files"); +const { reqBody } = require("../utils/http"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const fs = require("fs"); +const path = require("path"); + +function documentEndpoints(app) { + if (!app) return; + app.post( + "/document/create-folder", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { name } = reqBody(request); + const storagePath = path.join(documentsPath, normalizePath(name)); + if (!isWithin(path.resolve(documentsPath), path.resolve(storagePath))) + throw new Error("Invalid folder name."); + + if (fs.existsSync(storagePath)) { + response.status(500).json({ + success: false, + message: "Folder by that name already exists", + }); + return; + } + + fs.mkdirSync(storagePath, { recursive: true }); + response.status(200).json({ success: true, message: null }); + } catch (e) { + console.error(e); + response.status(500).json({ + success: false, + message: `Failed to create folder: ${e.message} `, + }); + } + } + ); + + app.post( + "/document/move-files", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { files } = reqBody(request); + const docpaths = files.map(({ from }) => from); + const documents = await Document.where({ docpath: { in: docpaths } }); + + const embeddedFiles = documents.map((doc) => doc.docpath); + const moveableFiles = files.filter( + ({ from }) => !embeddedFiles.includes(from) + ); + + const movePromises = moveableFiles.map(({ from, to }) => { + const sourcePath = path.join(documentsPath, normalizePath(from)); + const destinationPath = path.join(documentsPath, normalizePath(to)); + + return new Promise((resolve, reject) => { + if ( + !isWithin(documentsPath, sourcePath) || + !isWithin(documentsPath, destinationPath) + ) + return reject("Invalid file location"); + + fs.rename(sourcePath, destinationPath, (err) => { + if (err) { + console.error(`Error moving file ${from} to ${to}:`, err); + reject(err); + } else { + resolve(); + } + }); + }); + }); + + Promise.all(movePromises) + .then(() => { + const unmovableCount = files.length - moveableFiles.length; + if (unmovableCount > 0) { + response.status(200).json({ + success: true, + message: `${unmovableCount}/${files.length} files not moved. Unembed them from all workspaces.`, + }); + } else { + response.status(200).json({ + success: true, + message: null, + }); + } + }) + .catch((err) => { + console.error("Error moving files:", err); + response + .status(500) + .json({ success: false, message: "Failed to move some files." }); + }); + } catch (e) { + console.error(e); + response + .status(500) + .json({ success: false, message: "Failed to move files." }); + } + } + ); +} + +module.exports = { documentEndpoints }; diff --git a/server/endpoints/embed/index.js b/server/endpoints/embed/index.js new file mode 100644 index 0000000..4eb1d4a --- /dev/null +++ b/server/endpoints/embed/index.js @@ -0,0 +1,110 @@ +const { v4: uuidv4 } = require("uuid"); +const { reqBody, multiUserMode } = require("../../utils/http"); +const { Telemetry } = require("../../models/telemetry"); +const { streamChatWithForEmbed } = require("../../utils/chats/embed"); +const { EmbedChats } = require("../../models/embedChats"); +const { + validEmbedConfig, + canRespond, + setConnectionMeta, +} = require("../../utils/middleware/embedMiddleware"); +const { + convertToChatHistory, + writeResponseChunk, +} = require("../../utils/helpers/chat/responses"); + +function embeddedEndpoints(app) { + if (!app) return; + + app.post( + "/embed/:embedId/stream-chat", + [validEmbedConfig, setConnectionMeta, canRespond], + async (request, response) => { + try { + const embed = response.locals.embedConfig; + const { + sessionId, + message, + // optional keys for override of defaults if enabled. + prompt = null, + model = null, + temperature = null, + username = null, + } = reqBody(request); + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + await streamChatWithForEmbed(response, embed, message, sessionId, { + promptOverride: prompt, + modelOverride: model, + temperatureOverride: temperature, + username, + }); + await Telemetry.sendTelemetry("embed_sent_chat", { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + }); + response.end(); + } catch (e) { + console.error(e); + writeResponseChunk(response, { + id: uuidv4(), + type: "abort", + sources: [], + textResponse: null, + close: true, + error: e.message, + }); + response.end(); + } + } + ); + + app.get( + "/embed/:embedId/:sessionId", + [validEmbedConfig], + async (request, response) => { + try { + const { sessionId } = request.params; + const embed = response.locals.embedConfig; + const history = await EmbedChats.forEmbedByUser( + embed.id, + sessionId, + null, + null, + true + ); + + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/embed/:embedId/:sessionId", + [validEmbedConfig], + async (request, response) => { + try { + const { sessionId } = request.params; + const embed = response.locals.embedConfig; + + await EmbedChats.markHistoryInvalid(embed.id, sessionId); + response.status(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { embeddedEndpoints }; diff --git a/server/endpoints/embedManagement.js b/server/endpoints/embedManagement.js new file mode 100644 index 0000000..8bee4dd --- /dev/null +++ b/server/endpoints/embedManagement.js @@ -0,0 +1,131 @@ +const { EmbedChats } = require("../models/embedChats"); +const { EmbedConfig } = require("../models/embedConfig"); +const { EventLogs } = require("../models/eventLogs"); +const { reqBody, userFromSession } = require("../utils/http"); +const { validEmbedConfigId } = require("../utils/middleware/embedMiddleware"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + chatHistoryViewable, +} = require("../utils/middleware/chatHistoryViewable"); + +function embedManagementEndpoints(app) { + if (!app) return; + + app.get( + "/embeds", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_, response) => { + try { + const embeds = await EmbedConfig.whereWithWorkspace({}, null, { + createdAt: "desc", + }); + response.status(200).json({ embeds }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/embeds/new", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const data = reqBody(request); + const { embed, message: error } = await EmbedConfig.new(data, user?.id); + await EventLogs.logEvent( + "embed_created", + { embedId: embed.id }, + user?.id + ); + response.status(200).json({ embed, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/embed/update/:embedId", + [validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { embedId } = request.params; + const updates = reqBody(request); + const { success, error } = await EmbedConfig.update(embedId, updates); + await EventLogs.logEvent("embed_updated", { embedId }, user?.id); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/embed/:embedId", + [validatedRequest, flexUserRoleValid([ROLES.admin]), validEmbedConfigId], + async (request, response) => { + try { + const { embedId } = request.params; + await EmbedConfig.delete({ id: Number(embedId) }); + await EventLogs.logEvent( + "embed_deleted", + { embedId }, + response?.locals?.user?.id + ); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/embed/chats", + [chatHistoryViewable, validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { offset = 0, limit = 20 } = reqBody(request); + const embedChats = await EmbedChats.whereWithEmbedAndWorkspace( + {}, + limit, + { id: "desc" }, + offset * limit + ); + const totalChats = await EmbedChats.count(); + const hasPages = totalChats > (offset + 1) * limit; + response.status(200).json({ chats: embedChats, hasPages, totalChats }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/embed/chats/:chatId", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { chatId } = request.params; + await EmbedChats.delete({ id: Number(chatId) }); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { embedManagementEndpoints }; diff --git a/server/endpoints/experimental/imported-agent-plugins.js b/server/endpoints/experimental/imported-agent-plugins.js new file mode 100644 index 0000000..cabe23d --- /dev/null +++ b/server/endpoints/experimental/imported-agent-plugins.js @@ -0,0 +1,65 @@ +const ImportedPlugin = require("../../utils/agents/imported"); +const { reqBody } = require("../../utils/http"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); + +function importedAgentPluginEndpoints(app) { + if (!app) return; + + app.post( + "/experimental/agent-plugins/:hubId/toggle", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + (request, response) => { + try { + const { hubId } = request.params; + const { active } = reqBody(request); + const updatedConfig = ImportedPlugin.updateImportedPlugin(hubId, { + active: Boolean(active), + }); + response.status(200).json(updatedConfig); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); + + app.post( + "/experimental/agent-plugins/:hubId/config", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + (request, response) => { + try { + const { hubId } = request.params; + const { updates } = reqBody(request); + const updatedConfig = ImportedPlugin.updateImportedPlugin( + hubId, + updates + ); + response.status(200).json(updatedConfig); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); + + app.delete( + "/experimental/agent-plugins/:hubId", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { hubId } = request.params; + const result = ImportedPlugin.deletePlugin(hubId); + response.status(200).json(result); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); +} + +module.exports = { importedAgentPluginEndpoints }; diff --git a/server/endpoints/experimental/index.js b/server/endpoints/experimental/index.js new file mode 100644 index 0000000..f7dc867 --- /dev/null +++ b/server/endpoints/experimental/index.js @@ -0,0 +1,12 @@ +const { liveSyncEndpoints } = require("./liveSync"); +const { importedAgentPluginEndpoints } = require("./imported-agent-plugins"); + +// All endpoints here are not stable and can move around - have breaking changes +// or are opt-in features that are not fully released. +// When a feature is promoted it should be removed from here and added to the appropriate scope. +function experimentalEndpoints(router) { + liveSyncEndpoints(router); + importedAgentPluginEndpoints(router); +} + +module.exports = { experimentalEndpoints }; diff --git a/server/endpoints/experimental/liveSync.js b/server/endpoints/experimental/liveSync.js new file mode 100644 index 0000000..2a22d9a --- /dev/null +++ b/server/endpoints/experimental/liveSync.js @@ -0,0 +1,114 @@ +const { DocumentSyncQueue } = require("../../models/documentSyncQueue"); +const { Document } = require("../../models/documents"); +const { EventLogs } = require("../../models/eventLogs"); +const { SystemSettings } = require("../../models/systemSettings"); +const { Telemetry } = require("../../models/telemetry"); +const { reqBody } = require("../../utils/http"); +const { + featureFlagEnabled, +} = require("../../utils/middleware/featureFlagEnabled"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { validWorkspaceSlug } = require("../../utils/middleware/validWorkspace"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); + +function liveSyncEndpoints(app) { + if (!app) return; + + app.post( + "/experimental/toggle-live-sync", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { updatedStatus = false } = reqBody(request); + const newStatus = + SystemSettings.validations.experimental_live_file_sync(updatedStatus); + const currentStatus = + (await SystemSettings.get({ label: "experimental_live_file_sync" })) + ?.value || "disabled"; + if (currentStatus === newStatus) + return response + .status(200) + .json({ liveSyncEnabled: newStatus === "enabled" }); + + // Already validated earlier - so can hot update. + await SystemSettings._updateSettings({ + experimental_live_file_sync: newStatus, + }); + if (newStatus === "enabled") { + await Telemetry.sendTelemetry("experimental_feature_enabled", { + feature: "live_file_sync", + }); + await EventLogs.logEvent("experimental_feature_enabled", { + feature: "live_file_sync", + }); + DocumentSyncQueue.bootWorkers(); + } else { + DocumentSyncQueue.killWorkers(); + } + + response.status(200).json({ liveSyncEnabled: newStatus === "enabled" }); + } catch (e) { + console.error(e); + response.status(500).end(); + } + } + ); + + app.get( + "/experimental/live-sync/queues", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin]), + featureFlagEnabled(DocumentSyncQueue.featureKey), + ], + async (_, response) => { + const queues = await DocumentSyncQueue.where( + {}, + null, + { createdAt: "asc" }, + { + workspaceDoc: { + include: { + workspace: true, + }, + }, + } + ); + response.status(200).json({ queues }); + } + ); + + // Should be in workspace routes, but is here for now. + app.post( + "/workspace/:slug/update-watch-status", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + featureFlagEnabled(DocumentSyncQueue.featureKey), + ], + async (request, response) => { + try { + const { docPath, watchStatus = false } = reqBody(request); + const workspace = response.locals.workspace; + + const document = await Document.get({ + workspaceId: workspace.id, + docpath: docPath, + }); + if (!document) return response.sendStatus(404).end(); + + await DocumentSyncQueue.toggleWatchStatus(document, watchStatus); + return response.status(200).end(); + } catch (error) { + console.error("Error processing the watch status update:", error); + return response.status(500).end(); + } + } + ); +} + +module.exports = { liveSyncEndpoints }; diff --git a/server/endpoints/extensions/index.js b/server/endpoints/extensions/index.js new file mode 100644 index 0000000..0976da8 --- /dev/null +++ b/server/endpoints/extensions/index.js @@ -0,0 +1,197 @@ +const { Telemetry } = require("../../models/telemetry"); +const { CollectorApi } = require("../../utils/collectorApi"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { + isSupportedRepoProvider, +} = require("../../utils/middleware/isSupportedRepoProviders"); + +function extensionEndpoints(app) { + if (!app) return; + + app.post( + "/ext/:repo_platform/branches", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + isSupportedRepoProvider, + ], + async (request, response) => { + try { + const { repo_platform } = request.params; + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: `/ext/${repo_platform}-repo/branches`, + method: "POST", + body: request.body, + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/:repo_platform/repo", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + isSupportedRepoProvider, + ], + async (request, response) => { + try { + const { repo_platform } = request.params; + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: `/ext/${repo_platform}-repo`, + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: `${repo_platform}_repo`, + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/youtube/transcript", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/youtube-transcript", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "youtube_transcript", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/confluence", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/confluence", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "confluence", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + app.post( + "/ext/website-depth", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/website-depth", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "website_depth", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + app.post( + "/ext/drupalwiki", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/drupalwiki", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "drupalwiki", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/obsidian/vault", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/obsidian/vault", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "obsidian_vault", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/ext/paperless-ngx", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const responseFromProcessor = + await new CollectorApi().forwardExtensionRequest({ + endpoint: "/ext/paperless-ngx", + method: "POST", + body: request.body, + }); + await Telemetry.sendTelemetry("extension_invoked", { + type: "paperless_ngx", + }); + response.status(200).json(responseFromProcessor); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { extensionEndpoints }; diff --git a/server/endpoints/invite.js b/server/endpoints/invite.js new file mode 100644 index 0000000..f33ba45 --- /dev/null +++ b/server/endpoints/invite.js @@ -0,0 +1,81 @@ +const { EventLogs } = require("../models/eventLogs"); +const { Invite } = require("../models/invite"); +const { User } = require("../models/user"); +const { reqBody } = require("../utils/http"); +const { + simpleSSOLoginDisabledMiddleware, +} = require("../utils/middleware/simpleSSOEnabled"); + +function inviteEndpoints(app) { + if (!app) return; + + app.get("/invite/:code", async (request, response) => { + try { + const { code } = request.params; + const invite = await Invite.get({ code }); + if (!invite) { + response.status(200).json({ invite: null, error: "Invite not found." }); + return; + } + + if (invite.status !== "pending") { + response + .status(200) + .json({ invite: null, error: "Invite is no longer valid." }); + return; + } + + response + .status(200) + .json({ invite: { code, status: invite.status }, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/invite/:code", + [simpleSSOLoginDisabledMiddleware], + async (request, response) => { + try { + const { code } = request.params; + const { username, password } = reqBody(request); + const invite = await Invite.get({ code }); + if (!invite || invite.status !== "pending") { + response + .status(200) + .json({ success: false, error: "Invite not found or is invalid." }); + return; + } + + const { user, error } = await User.create({ + username, + password, + role: "default", + }); + if (!user) { + console.error("Accepting invite:", error); + response.status(200).json({ success: false, error }); + return; + } + + await Invite.markClaimed(invite.id, user); + await EventLogs.logEvent( + "invite_accepted", + { + username: user.username, + }, + user.id + ); + + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { inviteEndpoints }; diff --git a/server/endpoints/mcpServers.js b/server/endpoints/mcpServers.js new file mode 100644 index 0000000..9e1fe38 --- /dev/null +++ b/server/endpoints/mcpServers.js @@ -0,0 +1,127 @@ +const { reqBody } = require("../utils/http"); +const MCPCompatibilityLayer = require("../utils/MCP"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); + +function mcpServersEndpoints(app) { + if (!app) return; + + app.get( + "/mcp-servers/force-reload", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const mcp = new MCPCompatibilityLayer(); + await mcp.reloadMCPServers(); + return response.status(200).json({ + success: true, + error: null, + servers: await mcp.servers(), + }); + } catch (error) { + console.error("Error force reloading MCP servers:", error); + return response.status(500).json({ + success: false, + error: error.message, + servers: [], + }); + } + } + ); + + app.get( + "/mcp-servers/list", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const servers = await new MCPCompatibilityLayer().servers(); + return response.status(200).json({ + success: true, + servers, + }); + } catch (error) { + console.error("Error listing MCP servers:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + app.post( + "/mcp-servers/toggle", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { name } = reqBody(request); + const result = await new MCPCompatibilityLayer().toggleServerStatus( + name + ); + return response.status(200).json({ + success: result.success, + error: result.error, + }); + } catch (error) { + console.error("Error toggling MCP server:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + app.post( + "/mcp-servers/delete", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { name } = reqBody(request); + const result = await new MCPCompatibilityLayer().deleteServer(name); + return response.status(200).json({ + success: result.success, + error: result.error, + }); + } catch (error) { + console.error("Error deleting MCP server:", error); + return response.status(500).json({ + success: false, + error: error.message, + }); + } + } + ); + + app.post( + "/mcp-servers/toggle-tool", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { serverName, toolName, enabled } = reqBody(request); + const result = await new MCPCompatibilityLayer().toggleToolSuppression( + serverName, + toolName, + enabled + ); + return response.status(200).json({ + success: result.success, + error: result.error, + suppressedTools: result.suppressedTools, + }); + } catch (error) { + console.error("Error toggling MCP tool:", error); + return response.status(500).json({ + success: false, + error: error.message, + suppressedTools: [], + }); + } + } + ); +} + +module.exports = { mcpServersEndpoints }; diff --git a/server/endpoints/memory.js b/server/endpoints/memory.js new file mode 100644 index 0000000..64af4a6 --- /dev/null +++ b/server/endpoints/memory.js @@ -0,0 +1,196 @@ +const { Memory } = require("../models/memory"); +const { SystemSettings } = require("../models/systemSettings"); +const { userFromSession, reqBody } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace"); + +async function memoryFeatureEnabled(_req, response, next) { + const enabled = await SystemSettings.memoriesEnabled(); + if (!enabled) + return response.status(403).json({ error: "Personalization is disabled." }); + next(); +} + +/** + * Loads the memory by :memoryId and, in multi-user mode, scopes the query to the requester's userId. + * A memory owned by another user returns null here and is indistinguishable from "not found" — 404 either way. + */ +async function validateMemoryOwner(request, response, next) { + try { + const clause = { id: Number(request.params.memoryId) }; + if (response.locals.multiUserMode) { + const user = await userFromSession(request, response); + clause.userId = user?.id ?? null; + } + + const memory = await Memory.get(clause); + if (!memory) + return response.status(404).json({ error: "Memory not found." }); + + next(); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } +} + +function memoryEndpoints(app) { + if (!app) return; + + app.get( + "/workspaces/:slug/memories", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validWorkspaceSlug, + ], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + const [globalMemories, workspaceMemories] = await Promise.all([ + Memory.globalForUser(user?.id), + Memory.forUserWorkspace(user?.id, workspace.id), + ]); + response.status(200).json({ + memories: { global: globalMemories, workspace: workspaceMemories }, + }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); + + app.post( + "/workspaces/:slug/memories", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validWorkspaceSlug, + ], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const { content, scope = "workspace" } = reqBody(request); + const { memory, message } = await Memory.create({ + userId: user?.id, + workspaceId: scope === "global" ? null : workspace.id, + scope, + content: content.trim(), + }); + + if (!memory) return response.status(400).json({ error: message }); + response.status(200).json({ memory }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); + + app.put( + "/memories/:memoryId", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validateMemoryOwner, + ], + async (request, response) => { + try { + const memoryId = Number(request.params.memoryId); + const { content } = reqBody(request); + const { memory, message } = await Memory.update(memoryId, { + content: content.trim(), + }); + + if (!memory) return response.status(400).json({ error: message }); + response.status(200).json({ memory }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); + + app.delete( + "/memories/:memoryId", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validateMemoryOwner, + ], + async (request, response) => { + try { + const memoryId = Number(request.params.memoryId); + await Memory.delete(memoryId); + response.status(200).json({ success: true }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); + + app.post( + "/memories/:memoryId/promote", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validateMemoryOwner, + ], + async (request, response) => { + try { + const memoryId = Number(request.params.memoryId); + const { memory, message } = await Memory.promoteToGlobal(memoryId); + if (!memory) return response.status(400).json({ error: message }); + + response.status(200).json({ memory }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); + + app.post( + "/memories/:memoryId/demote/:slug", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + memoryFeatureEnabled, + validateMemoryOwner, + validWorkspaceSlug, + ], + async (request, response) => { + try { + const memoryId = Number(request.params.memoryId); + const targetWorkspace = response.locals.workspace; + + const { memory, message } = await Memory.demoteToWorkspace( + memoryId, + targetWorkspace.id + ); + + if (!memory) return response.status(400).json({ error: message }); + response.status(200).json({ memory }); + } catch (e) { + console.error(e); + return response.sendStatus(500); + } + } + ); +} + +module.exports = { memoryEndpoints }; diff --git a/server/endpoints/mobile/index.js b/server/endpoints/mobile/index.js new file mode 100644 index 0000000..ca9e11a --- /dev/null +++ b/server/endpoints/mobile/index.js @@ -0,0 +1,160 @@ +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { MobileDevice } = require("../../models/mobileDevice"); +const { handleMobileCommand } = require("./utils"); +const { validDeviceToken, validRegistrationToken } = require("./middleware"); +const { reqBody } = require("../../utils/http"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); + +function mobileEndpoints(app) { + if (!app) return; + + /** + * Gets all the devices from the database. + * @param {import("express").Request} request + * @param {import("express").Response} response + */ + app.get( + "/mobile/devices", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const devices = await MobileDevice.where({}, null, null, { + user: { select: { id: true, username: true } }, + }); + return response.status(200).json({ devices }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + /** + * Updates the device status via an updates object. + * @param {import("express").Request} request + * @param {import("express").Response} response + */ + app.post( + "/mobile/update/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const body = reqBody(request); + const updates = await MobileDevice.update( + Number(request.params.id), + body + ); + if (updates.error) + return response.status(400).json({ error: updates.error }); + return response.status(200).json({ updates }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + /** + * Deletes a device from the database. + * @param {import("express").Request} request + * @param {import("express").Response} response + */ + app.delete( + "/mobile/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const device = await MobileDevice.get({ + id: Number(request.params.id), + }); + if (!device) + return response.status(404).json({ error: "Device not found" }); + await MobileDevice.delete(device.id); + return response.status(200).json({ message: "Device deleted" }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/mobile/connect-info", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + return response.status(200).json({ + connectionUrl: MobileDevice.connectionURL(response.locals?.user), + }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + /** + * Checks if the device auth token is valid + * against approved devices. + */ + app.get("/mobile/auth", [validDeviceToken], async (_, response) => { + try { + return response + .status(200) + .json({ success: true, message: "Device authenticated" }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + /** + * Registers a new device (is open so that the mobile app can register itself) + * Will create a new device in the database but requires approval by the user + * before it can be used. + * @param {import("express").Request} request + * @param {import("express").Response} response + */ + app.post( + "/mobile/register", + [validRegistrationToken], + async (request, response) => { + try { + const body = reqBody(request); + const result = await MobileDevice.create({ + deviceOs: body.deviceOs, + deviceName: body.deviceName, + userId: response.locals?.user?.id, + }); + + if (result.error) + return response.status(400).json({ error: result.error }); + return response.status(200).json({ + token: result.device.token, + platform: MobileDevice.platform, + }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/mobile/send/:command", + [validDeviceToken], + async (request, response) => { + try { + return handleMobileCommand(request, response); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { mobileEndpoints }; diff --git a/server/endpoints/mobile/middleware/index.js b/server/endpoints/mobile/middleware/index.js new file mode 100644 index 0000000..148f5e1 --- /dev/null +++ b/server/endpoints/mobile/middleware/index.js @@ -0,0 +1,97 @@ +const { MobileDevice } = require("../../../models/mobileDevice"); +const { SystemSettings } = require("../../../models/systemSettings"); +const { User } = require("../../../models/user"); + +/** + * Validates the device id from the request headers by checking if the device + * exists in the database and is approved. + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + */ +async function validDeviceToken(request, response, next) { + try { + const token = request.header("x-anythingllm-mobile-device-token"); + if (!token) + return response.status(400).json({ error: "Device token is required" }); + + const device = await MobileDevice.get( + { token: String(token) }, + { user: true } + ); + if (!device) + return response.status(400).json({ error: "Device not found" }); + if (!device.approved) + return response.status(400).json({ error: "Device not approved" }); + + // If the device is associated with a user then we can associate it with the locals + // so we can reuse it later. + if (device.user) { + if (device.user.suspended) + return response.status(400).json({ error: "User is suspended." }); + response.locals.user = device.user; + } + + delete device.user; + response.locals.device = device; + next(); + } catch (error) { + console.error("validDeviceToken", error); + response.status(500).json({ error: "Invalid middleware response" }); + } +} + +/** + * Validates a temporary registration token that is passed in the request + * and associates the user with the token (if valid). Temporary token is consumed + * and cannot be used again after this middleware is called. + * @param {*} request + * @param {*} response + * @param {*} next + */ +async function validRegistrationToken(request, response, next) { + try { + const authHeader = request.header("Authorization"); + const tempToken = authHeader ? authHeader.split(" ")[1] : null; + if (!tempToken) + return response + .status(400) + .json({ error: "Registration token is required" }); + + const tempTokenData = MobileDevice.tempToken(tempToken); + if (!tempTokenData) + return response + .status(400) + .json({ error: "Invalid or expired registration token" }); + + // If in multi-user mode, we need to validate the user id + // associated exists, is not banned and then associate with locals so we can reuse it later. + // If not in multi-user mode then simply having a valid token is enough. + const multiUserMode = await SystemSettings.isMultiUserMode(); + if (multiUserMode) { + if (!tempTokenData.userId) + return response + .status(400) + .json({ error: "User id not found in registration token" }); + const user = await User.get({ id: Number(tempTokenData.userId) }); + if (!user) return response.status(400).json({ error: "User not found" }); + if (user.suspended) + return response + .status(400) + .json({ error: "User is suspended - cannot register device" }); + response.locals.user = user; + } + + next(); + } catch (error) { + console.error("validRegistrationToken:error", error); + response.status(500).json({ + error: "Invalid middleware response from validRegistrationToken", + }); + } +} + +module.exports = { + validDeviceToken, + validRegistrationToken, +}; diff --git a/server/endpoints/mobile/utils/index.js b/server/endpoints/mobile/utils/index.js new file mode 100644 index 0000000..88be878 --- /dev/null +++ b/server/endpoints/mobile/utils/index.js @@ -0,0 +1,195 @@ +const { Workspace } = require("../../../models/workspace"); +const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { WorkspaceThread } = require("../../../models/workspaceThread"); +const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler"); +const { reqBody } = require("../../../utils/http"); +const prisma = require("../../../utils/prisma"); +const { getModelTag } = require("../../utils"); +const { MobileDevice } = require("../../../models/mobileDevice"); + +/** + * + * @param {import("express").Request} request + * @param {import("express").Response} response + * @returns + */ +async function handleMobileCommand(request, response) { + const { command } = request.params; + const user = response.locals.user ?? null; + const body = reqBody(request); + + if (command === "workspaces") { + const workspaces = user + ? await Workspace.whereWithUser(user, {}) + : await Workspace.where({}); + for (const workspace of workspaces) { + const [threadCount, chatCount] = await Promise.all([ + prisma.workspace_threads.count({ + where: { + workspace_id: workspace.id, + ...(user ? { user_id: user.id } : {}), + }, + }), + prisma.workspace_chats.count({ + where: { + workspaceId: workspace.id, + include: true, + ...(user ? { user_id: user.id } : {}), + }, + }), + ]); + workspace.threadCount = threadCount; + workspace.chatCount = chatCount; + workspace.platform = MobileDevice.platform; + } + return response.status(200).json({ workspaces }); + } + + if (command === "workspace-content") { + const workspace = user + ? await Workspace.getWithUser(user, { slug: String(body.workspaceSlug) }) + : await Workspace.get({ slug: String(body.workspaceSlug) }); + + if (!workspace) + return response.status(400).json({ error: "Workspace not found" }); + const threads = [ + { + id: 0, + name: "Default Thread", + slug: "default-thread", + workspace_id: workspace.id, + createdAt: new Date(), + lastUpdatedAt: new Date(), + }, + ...(await prisma.workspace_threads.findMany({ + where: { + workspace_id: workspace.id, + ...(user ? { user_id: user.id } : {}), + }, + })), + ]; + const chats = ( + await prisma.workspace_chats.findMany({ + where: { + workspaceId: workspace.id, + include: true, + ...(user ? { user_id: user.id } : {}), + }, + }) + ).map((chat) => ({ + ...chat, + // Create a dummy thread_id for the default thread so the chats can be mapped correctly. + ...(chat.thread_id === null ? { thread_id: 0 } : {}), + createdAt: chat.createdAt.toISOString(), + lastUpdatedAt: chat.lastUpdatedAt.toISOString(), + })); + return response.status(200).json({ threads, chats }); + } + + // Get the model for this workspace (workspace -> system) + if (command === "model-tag") { + const { workspaceSlug } = body; + const workspace = user + ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) }) + : await Workspace.get({ slug: String(workspaceSlug) }); + + if (!workspace) + return response.status(400).json({ error: "Workspace not found" }); + if (workspace.chatModel) + return response.status(200).json({ model: workspace.chatModel }); + else return response.status(200).json({ model: getModelTag() }); + } + + if (command === "reset-chat") { + const { workspaceSlug, threadSlug } = body; + const workspace = user + ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) }) + : await Workspace.get({ slug: String(workspaceSlug) }); + + if (!workspace) + return response.status(400).json({ error: "Workspace not found" }); + const threadId = threadSlug + ? await prisma.workspace_threads.findFirst({ + where: { + workspace_id: workspace.id, + slug: String(threadSlug), + ...(user ? { user_id: user.id } : {}), + }, + })?.id + : null; + + await WorkspaceChats.markThreadHistoryInvalidV2({ + workspaceId: workspace.id, + ...(user ? { user_id: user.id } : {}), + thread_id: threadId, // if threadId is null, this will reset the default thread. + }); + return response.status(200).json({ success: true }); + } + + if (command === "new-thread") { + const { workspaceSlug } = body; + const workspace = user + ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) }) + : await Workspace.get({ slug: String(workspaceSlug) }); + + if (!workspace) + return response.status(400).json({ error: "Workspace not found" }); + const { thread } = await WorkspaceThread.new(workspace, user?.id); + return response.status(200).json({ thread }); + } + + if (command === "stream-chat") { + const { workspaceSlug = null, threadSlug = null, message } = body; + if (!workspaceSlug) + return response.status(400).json({ error: "Workspace ID is required" }); + else if (!message) + return response.status(400).json({ error: "Message is required" }); + + const workspace = user + ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) }) + : await Workspace.get({ slug: String(workspaceSlug) }); + + if (!workspace) + return response.status(400).json({ error: "Workspace not found" }); + const thread = threadSlug + ? await prisma.workspace_threads.findFirst({ + where: { + workspace_id: workspace.id, + slug: String(threadSlug), + ...(user ? { user_id: user.id } : {}), + }, + }) + : null; + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + await ApiChatHandler.streamChat({ + response, + workspace, + thread, + message, + mode: "chat", + user: user, + sessionId: null, + attachments: [], + reset: false, + }); + return response.end(); + } + + if (command === "unregister-device") { + if (!response.locals.device) + return response.status(200).json({ success: true }); + await MobileDevice.delete(response.locals.device.id); + return response.status(200).json({ success: true }); + } + + return response.status(400).json({ error: "Invalid command" }); +} + +module.exports = { + handleMobileCommand, +}; diff --git a/server/endpoints/modelRouter.js b/server/endpoints/modelRouter.js new file mode 100644 index 0000000..62724be --- /dev/null +++ b/server/endpoints/modelRouter.js @@ -0,0 +1,204 @@ +const { ModelRouter } = require("../models/modelRouter"); +const { ModelRouterRule } = require("../models/modelRouterRule"); +const { Telemetry } = require("../models/telemetry"); +const { ModelRouterService } = require("../utils/router"); +const { reqBody, userFromSession } = require("../utils/http"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); + +function modelRouterEndpoints(app) { + if (!app) return; + + app.get( + "/model-routers", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_request, response) => { + try { + const routers = await ModelRouter.getAllWithCounts(); + response.status(200).json({ routers }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.get( + "/model-routers/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const router = await ModelRouter.getWithRulesAndCount({ + id: Number(id), + }); + if (!router) { + response + .status(404) + .json({ router: null, error: "Router not found." }); + return; + } + response.status(200).json({ router }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.post( + "/model-routers/new", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const data = reqBody(request); + const { router, error } = await ModelRouter.create( + data, + user?.id || null + ); + + if (error) return response.status(400).json({ router, error }); + await Telemetry.sendTelemetry("model_router_created"); + return response.status(200).json({ router }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.put( + "/model-routers/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const data = reqBody(request); + const { router, error } = await ModelRouter.update(Number(id), data); + if (error) return response.status(400).json({ router, error }); + ModelRouterService.invalidateRouter(Number(id)); + return response.status(200).json({ router }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.delete( + "/model-routers/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const success = await ModelRouter.delete(Number(id)); + if (!success) + return response + .status(400) + .json({ success: false, error: "Failed to delete router." }); + ModelRouterService.invalidateRouter(Number(id)); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.post( + "/model-routers/:id/rules/new", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const user = await userFromSession(request, response); + const data = reqBody(request); + const routerId = Number(id); + const { rule, error } = await ModelRouterRule.create( + routerId, + data, + user?.id || null + ); + if (error) return response.status(400).json({ rule, error }); + + await Telemetry.sendTelemetry("model_router_rule_created"); + ModelRouterService.invalidateRouter(routerId); + return response.status(200).json({ rule }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.put( + "/model-routers/:id/rules/reorder", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const { ruleUpdates } = reqBody(request); + if (!Array.isArray(ruleUpdates)) + return response + .status(400) + .json({ success: false, error: "ruleUpdates must be an array." }); + + const { success, error } = + await ModelRouterRule.reorderRules(ruleUpdates); + if (error) return response.status(400).json({ success, error }); + ModelRouterService.invalidateRouter(Number(id)); + return response.status(200).json({ success }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.put( + "/model-routers/:id/rules/:ruleId", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id, ruleId } = request.params; + const data = reqBody(request); + const { rule, error } = await ModelRouterRule.update( + Number(ruleId), + data + ); + if (error) return response.status(400).json({ rule, error }); + ModelRouterService.invalidateRouter(Number(id)); + return response.status(200).json({ rule }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); + + app.delete( + "/model-routers/:id/rules/:ruleId", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id, ruleId } = request.params; + const success = await ModelRouterRule.delete(Number(ruleId)); + if (!success) + return response + .status(400) + .json({ success: false, error: "Failed to delete rule." }); + ModelRouterService.invalidateRouter(Number(id)); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e); + response.sendStatus(500); + } + } + ); +} + +module.exports = { modelRouterEndpoints }; diff --git a/server/endpoints/scheduledJobs.js b/server/endpoints/scheduledJobs.js new file mode 100644 index 0000000..bbe93a8 --- /dev/null +++ b/server/endpoints/scheduledJobs.js @@ -0,0 +1,376 @@ +const { ScheduledJob } = require("../models/scheduledJob"); +const { ScheduledJobRun } = require("../models/scheduledJobRun"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { isSingleUserMode } = require("../utils/middleware/multiUserProtected"); +const { reqBody, safeJsonParse } = require("../utils/http"); +const { BackgroundService } = require("../utils/BackgroundWorkers"); +const { Telemetry } = require("../models/telemetry"); + +// BackgroundService is a singleton, so `new BackgroundService()` anywhere in +// the codebase returns the same instance that `server/index.js` booted. We +// grab that reference once and reuse it across handlers. +const backgroundService = new BackgroundService(); + +function scheduledJobEndpoints(app) { + if (!app) return; + + // List available tools for job configuration + app.get( + "/scheduled-jobs/available-tools", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const tools = await ScheduledJob.availableTools(); + return response.status(200).json({ tools }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).json({ tools: [] }); + } + } + ); + + // Get a single run detail + app.get( + "/scheduled-jobs/runs/:runId", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const run = await ScheduledJobRun.get({ + id: Number(request.params.runId), + }); + if (!run) { + return response + .status(404) + .json({ run: null, error: "Run not found" }); + } + + const job = await ScheduledJob.get({ id: run.jobId }); + return response.status(200).json({ + run: { + ...run, + result: safeJsonParse(run.result, null), + }, + job, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Mark a run as read or continue in thread, or kill a running or queued job run + app.post( + "/scheduled-jobs/runs/:runId/:action", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { action } = request.params; + + if (!["read", "continue", "kill"].includes(action)) + throw new Error("Invalid action"); + + if (action === "read") { + await ScheduledJobRun.markRead(Number(request.params.runId)); + return response.status(200).json({ success: true }); + } + + if (action === "continue") { + const { workspace, thread, error } = + await ScheduledJobRun.continueInThread( + Number(request.params.runId) + ); + if (error) return response.status(500).json({ error }); + + return response.status(200).json({ + workspaceSlug: workspace.slug, + threadSlug: thread.slug, + }); + } + + if (action === "kill") { + const run = await ScheduledJobRun.get({ + id: Number(request.params.runId), + }); + if (!run) + return response.status(404).json({ error: "Run not found" }); + if (!["queued", "running"].includes(run.status)) { + return response.status(400).json({ + error: "Only running or queued jobs can be killed", + }); + } + + const killed = backgroundService.killRun(run.jobId, run.id); + if (!killed) await ScheduledJobRun.kill(run.id); + return response.status(200).json({ success: true }); + } + } catch { + response.sendStatus(500); + } + } + ); + + // List all scheduled jobs + app.get( + "/scheduled-jobs", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const jobs = await ScheduledJob.where({}, null, null, { + runs: { + take: 1, + orderBy: { startedAt: "desc" }, + }, + }); + + const jobsWithStatus = jobs.map(({ runs, ...job }) => ({ + ...job, + latestRun: runs[0] || null, + })); + + return response.status(200).json({ jobs: jobsWithStatus }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Create a new scheduled job + app.post( + "/scheduled-jobs/new", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { name, prompt, tools, schedule } = reqBody(request); + let errorMessage = null; + + if (!name?.trim()) { + errorMessage = "Name is required"; + } else if (!prompt?.trim()) { + errorMessage = "Prompt is required"; + } else if (!schedule?.trim()) { + errorMessage = "Schedule is required"; + } else if (!ScheduledJob.isValidCron(schedule)) { + errorMessage = "Invalid cron expression"; + } else if (tools?.length > 0 && !Array.isArray(tools)) { + errorMessage = "Tools must be an array"; + } + if (errorMessage) + return response.status(400).json({ + job: null, + error: errorMessage, + }); + + // New jobs default to enabled, so creating one always counts as an + // activation. Reject if it would push us past the configured cap. + const activation = await ScheduledJob.canActivate(); + if (!activation.allowed) { + return response.status(400).json({ + job: null, + error: `Cannot create: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`, + }); + } + + const { job, error } = await ScheduledJob.create({ + name: name.trim(), + prompt: prompt.trim(), + tools: tools || null, + schedule: schedule.trim(), + }); + + if (error) { + return response.status(400).json({ job: null, error }); + } + + backgroundService.addScheduledJob(job); + Telemetry.sendTelemetry("scheduled_job_created").catch(() => {}); + return response.status(201).json({ job, error: null }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Get a single scheduled job + app.get( + "/scheduled-jobs/:id", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const job = await ScheduledJob.get({ + id: Number(request.params.id), + }); + if (!job) { + return response + .status(404) + .json({ job: null, error: "Job not found" }); + } + return response.status(200).json({ job }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Update a scheduled job + app.put( + "/scheduled-jobs/:id", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { name, prompt, tools, schedule, enabled } = reqBody(request); + const updates = {}; + + if (name !== undefined) updates.name = String(name).trim(); + if (prompt !== undefined) updates.prompt = String(prompt).trim(); + if (tools !== undefined) updates.tools = tools; + if (enabled !== undefined) updates.enabled = Boolean(enabled); + if (schedule !== undefined) { + if (!ScheduledJob.isValidCron(schedule)) { + return response + .status(400) + .json({ job: null, error: "Invalid cron expression" }); + } + updates.schedule = String(schedule).trim(); + } + + // If this update would activate the job, enforce the active-jobs cap. + // We pass excludeId so a re-save of an already-enabled job is not + // double-counted against the limit. + if (updates.enabled === true) { + const activation = await ScheduledJob.canActivate({ + excludeId: Number(request.params.id), + }); + if (!activation.allowed) { + return response.status(400).json({ + job: null, + error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`, + }); + } + } + + const { job, error } = await ScheduledJob.update( + Number(request.params.id), + updates + ); + + if (error) { + return response.status(400).json({ job: null, error }); + } + + await backgroundService.syncScheduledJob(job.id); + + return response.status(200).json({ job, error: null }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Delete a scheduled job + app.delete( + "/scheduled-jobs/:id", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + backgroundService.removeScheduledJob(Number(request.params.id)); + + const success = await ScheduledJob.delete(Number(request.params.id)); + return response.status(200).json({ success }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Toggle enable/disable + app.post( + "/scheduled-jobs/:id/toggle", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const job = await ScheduledJob.get({ + id: Number(request.params.id), + }); + if (!job) { + return response.status(404).json({ error: "Job not found" }); + } + + // Toggling a disabled job to enabled is an activation — enforce the cap. + // Disabling never needs a check. + if (!job.enabled) { + const activation = await ScheduledJob.canActivate({ + excludeId: job.id, + }); + if (!activation.allowed) { + return response.status(400).json({ + job: null, + error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`, + }); + } + } + + const { job: updated } = await ScheduledJob.update(job.id, { + enabled: !job.enabled, + }); + + await backgroundService.syncScheduledJob(job.id); + + return response.status(200).json({ job: updated }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // Manual trigger — runs the job immediately + app.post( + "/scheduled-jobs/:id/trigger", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const job = await ScheduledJob.get({ + id: Number(request.params.id), + }); + if (!job) { + return response.status(404).json({ error: "Job not found" }); + } + + const run = await backgroundService.enqueueScheduledJob(job.id); + return response + .status(200) + .json({ success: true, skipped: !run, error: null }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + // List runs for a job + app.get( + "/scheduled-jobs/:id/runs", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const runs = await ScheduledJobRun.where( + { jobId: Number(request.params.id) }, + 50, + { startedAt: "desc" } + ); + return response.status(200).json({ runs }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); +} + +module.exports = { scheduledJobEndpoints }; diff --git a/server/endpoints/system.js b/server/endpoints/system.js new file mode 100644 index 0000000..a5c464b --- /dev/null +++ b/server/endpoints/system.js @@ -0,0 +1,1552 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); +const { viewLocalFiles, normalizePath, isWithin } = require("../utils/files"); +const { purgeDocument, purgeFolder } = require("../utils/files/purgeDocument"); +const { getVectorDbClass } = require("../utils/helpers"); +const { updateENV, dumpENV } = require("../utils/helpers/updateENV"); +const { + reqBody, + makeJWT, + userFromSession, + multiUserMode, + queryParams, +} = require("../utils/http"); +const { + handleAssetUpload, + handlePfpUpload, + handleAudioUpload, +} = require("../utils/files/multer"); +const { v4 } = require("uuid"); +const { SystemSettings } = require("../models/systemSettings"); +const { User } = require("../models/user"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const fs = require("fs"); +const path = require("path"); +const { + getDefaultFilename, + determineLogoFilepath, + fetchLogo, + validFilename, + renameLogoFile, + removeCustomLogo, + LOGO_FILENAME, + isDefaultFilename, +} = require("../utils/files/logo"); +const { Telemetry } = require("../models/telemetry"); +const { ApiKey } = require("../models/apiKeys"); +const { getCustomModels } = require("../utils/helpers/customModels"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { + flexUserRoleValid, + ROLES, + isMultiUserSetup, +} = require("../utils/middleware/multiUserProtected"); +const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp"); +const { exportChatsAsType } = require("../utils/helpers/chat/convertTo"); +const { EventLogs } = require("../models/eventLogs"); +const { CollectorApi } = require("../utils/collectorApi"); +const { + recoverAccount, + resetPassword, + generateRecoveryCodes, +} = require("../utils/PasswordRecovery"); +const { SlashCommandPresets } = require("../models/slashCommandsPresets"); +const { EncryptionManager } = require("../utils/EncryptionManager"); +const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey"); +const { MobileDevice } = require("../models/mobileDevice"); +const { + chatHistoryViewable, +} = require("../utils/middleware/chatHistoryViewable"); +const { + simpleSSOEnabled, + simpleSSOLoginDisabled, +} = require("../utils/middleware/simpleSSOEnabled"); +const { TemporaryAuthToken } = require("../models/temporaryAuthToken"); +const { SystemPromptVariables } = require("../models/systemPromptVariables"); +const { VALID_COMMANDS } = require("../utils/chats"); +const { AgentSkillWhitelist } = require("../models/agentSkillWhitelist"); +const { Memory } = require("../models/memory"); + +function systemEndpoints(app) { + if (!app) return; + + app.get("/ping", (_, response) => { + response.status(200).json({ online: true }); + }); + + app.get("/migrate", async (_, response) => { + response.sendStatus(200); + }); + + app.get("/env-dump", async (_, response) => { + if (process.env.NODE_ENV !== "production") + return response.sendStatus(200).end(); + dumpENV(); + response.sendStatus(200).end(); + }); + + app.get("/onboarding", async (_, response) => { + try { + const results = await SystemSettings.isOnboardingComplete(); + response.status(200).json({ onboardingComplete: results }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.post("/onboarding", [validatedRequest], async (_, response) => { + try { + await SystemSettings.markOnboardingComplete(); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/setup-complete", async (_, response) => { + try { + const results = await SystemSettings.currentSettings(); + response.status(200).json({ results }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/system/check-token", + [validatedRequest], + async (request, response) => { + try { + if (multiUserMode(response)) { + const user = await userFromSession(request, response); + if (!user || user.suspended) { + response.sendStatus(403).end(); + return; + } + + response.sendStatus(200).end(); + return; + } + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + /** + * Refreshes the user object from the session from a provided token. + * This does not refresh the token itself - if that is expired or invalid, the user will be logged out. + * This simply keeps the user object in sync with the database over the course of the session. + * @returns {Promise<{success: boolean, user: Object | null, message: string | null}>} + */ + app.get( + "/system/refresh-user", + [validatedRequest], + async (request, response) => { + try { + if (!multiUserMode(response)) + return response + .status(200) + .json({ success: true, user: null, message: null }); + + const user = await userFromSession(request, response); + if (!user) + return response.status(200).json({ + success: false, + user: null, + message: "Session expired or invalid.", + }); + + if (user.suspended) + return response.status(200).json({ + success: false, + user: null, + message: "User is suspended.", + }); + + return response.status(200).json({ + success: true, + user: User.filterFields(user), + message: null, + }); + } catch (e) { + return response.status(500).json({ + success: false, + user: null, + message: e.message, + }); + } + } + ); + + app.post("/request-token", async (request, response) => { + try { + const bcrypt = require("bcryptjs"); + + if (await SystemSettings.isMultiUserMode()) { + if (simpleSSOLoginDisabled()) { + response.status(403).json({ + user: null, + valid: false, + token: null, + message: + "[005] Login via credentials has been disabled by the administrator.", + }); + return; + } + + const { username, password } = reqBody(request); + const existingUser = await User._get({ username: String(username) }); + + if (!existingUser) { + await EventLogs.logEvent( + "failed_login_invalid_username", + { + ip: request.ip || "Unknown IP", + username: username || "Unknown user", + }, + existingUser?.id + ); + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[001] Invalid login credentials.", + }); + return; + } + + if (!bcrypt.compareSync(String(password), existingUser.password)) { + await EventLogs.logEvent( + "failed_login_invalid_password", + { + ip: request.ip || "Unknown IP", + username: username || "Unknown user", + }, + existingUser?.id + ); + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[002] Invalid login credentials.", + }); + return; + } + + if (existingUser.suspended) { + await EventLogs.logEvent( + "failed_login_account_suspended", + { + ip: request.ip || "Unknown IP", + username: username || "Unknown user", + }, + existingUser?.id + ); + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[004] Account suspended by admin.", + }); + return; + } + + await Telemetry.sendTelemetry( + "login_event", + { multiUserMode: false }, + existingUser?.id + ); + + await EventLogs.logEvent( + "login_event", + { + ip: request.ip || "Unknown IP", + username: existingUser.username || "Unknown user", + }, + existingUser?.id + ); + + // Generate a session token for the user then check if they have seen the recovery codes + // and if not, generate recovery codes and return them to the frontend. + const sessionToken = makeJWT( + { id: existingUser.id, username: existingUser.username }, + process.env.JWT_EXPIRY + ); + if (!existingUser.seen_recovery_codes) { + const plainTextCodes = await generateRecoveryCodes(existingUser.id); + response.status(200).json({ + valid: true, + user: User.filterFields(existingUser), + token: sessionToken, + message: null, + recoveryCodes: plainTextCodes, + }); + return; + } + + response.status(200).json({ + valid: true, + user: User.filterFields(existingUser), + token: sessionToken, + message: null, + }); + return; + } else { + const { password } = reqBody(request); + if ( + !bcrypt.compareSync( + password, + bcrypt.hashSync(process.env.AUTH_TOKEN, 10) + ) + ) { + await EventLogs.logEvent("failed_login_invalid_password", { + ip: request.ip || "Unknown IP", + multiUserMode: false, + }); + response.status(401).json({ + valid: false, + token: null, + message: "[003] Invalid password provided", + }); + return; + } + + await Telemetry.sendTelemetry("login_event", { multiUserMode: false }); + await EventLogs.logEvent("login_event", { + ip: request.ip || "Unknown IP", + multiUserMode: false, + }); + response.status(200).json({ + valid: true, + token: makeJWT( + { p: new EncryptionManager().encrypt(password) }, + process.env.JWT_EXPIRY + ), + message: null, + }); + } + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/request-token/sso/simple", + [simpleSSOEnabled], + async (request, response) => { + const { token: tempAuthToken } = request.query; + const { sessionToken, token, error } = + await TemporaryAuthToken.validate(tempAuthToken); + + if (error) { + await EventLogs.logEvent("failed_login_invalid_temporary_auth_token", { + ip: request.ip || "Unknown IP", + multiUserMode: true, + }); + return response.status(401).json({ + valid: false, + token: null, + message: `[001] An error occurred while validating the token: ${error}`, + }); + } + + await Telemetry.sendTelemetry( + "login_event", + { multiUserMode: true }, + token.user.id + ); + await EventLogs.logEvent( + "login_event", + { + ip: request.ip || "Unknown IP", + username: token.user.username || "Unknown user", + }, + token.user.id + ); + + response.status(200).json({ + valid: true, + user: User.filterFields(token.user), + token: sessionToken, + message: null, + }); + } + ); + + app.post( + "/system/recover-account", + [isMultiUserSetup], + async (request, response) => { + try { + const { username, recoveryCodes } = reqBody(request); + const { success, resetToken, error } = await recoverAccount( + username, + recoveryCodes + ); + + if (success) { + response.status(200).json({ success, resetToken }); + } else { + response.status(400).json({ success, message: error }); + } + } catch (error) { + console.error("Error recovering account:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/system/reset-password", + [isMultiUserSetup], + async (request, response) => { + try { + const { token, newPassword, confirmPassword } = reqBody(request); + const { success, message, error } = await resetPassword( + token, + newPassword, + confirmPassword + ); + + if (success) { + response.status(200).json({ success, message }); + } else { + response.status(400).json({ success, error }); + } + } catch (error) { + console.error("Error resetting password:", error); + response.status(500).json({ success: false, message: error.message }); + } + } + ); + + app.get( + "/system/system-vectors", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const query = queryParams(request); + const VectorDb = getVectorDbClass(); + const vectorCount = !!query.slug + ? await VectorDb.namespaceCount(query.slug) + : await VectorDb.totalVectors(); + response.status(200).json({ vectorCount }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/system/remove-document", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { name } = reqBody(request); + await purgeDocument(name); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/system/remove-documents", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { names } = reqBody(request); + for await (const name of names) await purgeDocument(name); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/system/remove-folder", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { name } = reqBody(request); + await purgeFolder(name); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/system/local-files", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (_, response) => { + try { + const localFiles = await viewLocalFiles(); + response.status(200).json({ localFiles }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/system/document-processing-status", + [validatedRequest], + async (_, response) => { + try { + const online = await new CollectorApi().online(); + response.sendStatus(online ? 200 : 503); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/system/accepted-document-types", + [validatedRequest], + async (_, response) => { + try { + const types = await new CollectorApi().acceptedFileTypes(); + if (!types) { + response.sendStatus(404).end(); + return; + } + + response.status(200).json({ types }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/system/update-env", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const body = reqBody(request); + const { newValues, error } = await updateENV( + body, + false, + response?.locals?.user?.id + ); + response.status(200).json({ newValues, error }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/system/update-password", + [validatedRequest], + async (request, response) => { + try { + // Cannot update password in multi - user mode. + if (multiUserMode(response)) { + response.sendStatus(401).end(); + return; + } + + let error = null; + const { usePassword, newPassword } = reqBody(request); + if (!usePassword) { + // Password is being disabled so directly unset everything to bypass validation. + process.env.AUTH_TOKEN = ""; + process.env.JWT_SECRET = ""; + } else { + error = await updateENV( + { + AuthToken: newPassword, + JWTSecret: v4(), + }, + true + )?.error; + } + response.status(200).json({ success: !error, error }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/system/enable-multi-user", + [validatedRequest], + async (request, response) => { + try { + if (response.locals.multiUserMode) { + response.status(200).json({ + success: false, + error: "Multi-user mode is already enabled.", + }); + return; + } + + const { username, password } = reqBody(request); + const { user, error } = await User.create({ + username, + password, + role: ROLES.admin, + }); + + if (error || !user) { + response.status(400).json({ + success: false, + error: error || "Failed to enable multi-user mode.", + }); + return; + } + + await SystemSettings._updateSettings({ + multi_user_mode: true, + }); + await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id); + await Memory.migrateToMultiUser(user.id); + await WorkspaceChats.migrateToMultiUser(user.id); + await MobileDevice.migrateDevicesToMultiUser(user.id); + await SlashCommandPresets.migrateToMultiUser(user.id); + await AgentSkillWhitelist.clearSingleUserWhitelist(); + await updateENV( + { + JWTSecret: process.env.JWT_SECRET || v4(), + }, + true + ); + await Telemetry.sendTelemetry("enabled_multi_user_mode", { + multiUserMode: true, + }); + await EventLogs.logEvent("multi_user_mode_enabled", {}, user?.id); + response.status(200).json({ success: !!user, error }); + } catch (e) { + await User.delete({}); + await SystemSettings._updateSettings({ + multi_user_mode: false, + }); + + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/system/multi-user-mode", async (_, response) => { + try { + const multiUserMode = await SystemSettings.isMultiUserMode(); + response.status(200).json({ multiUserMode }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); + + app.get("/system/logo", async function (request, response) { + try { + const darkMode = + !request?.query?.theme || request?.query?.theme === "default"; + const defaultFilename = getDefaultFilename(darkMode); + const logoPath = await determineLogoFilepath(defaultFilename); + const { found, buffer, size, mime } = fetchLogo(logoPath); + + if (!found) { + response.sendStatus(204).end(); + return; + } + + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + response.writeHead(200, { + "Access-Control-Expose-Headers": + "Content-Disposition,X-Is-Custom-Logo,Content-Type,Content-Length", + "Content-Type": mime || "image/png", + "Content-Disposition": `attachment; filename=${path.basename( + logoPath + )}`, + "Content-Length": size, + "X-Is-Custom-Logo": + currentLogoFilename !== null && + currentLogoFilename !== defaultFilename && + !isDefaultFilename(currentLogoFilename), + }); + response.end(Buffer.from(buffer, "base64")); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.get("/system/footer-data", [validatedRequest], async (_, response) => { + try { + const footerData = + (await SystemSettings.get({ label: "footer_data" }))?.value ?? + JSON.stringify([]); + response.status(200).json({ footerData: footerData }); + } catch (error) { + console.error("Error fetching footer data:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.get("/system/support-email", [validatedRequest], async (_, response) => { + try { + const supportEmail = + ( + await SystemSettings.get({ + label: "support_email", + }) + )?.value ?? null; + response.status(200).json({ supportEmail: supportEmail }); + } catch (error) { + console.error("Error fetching support email:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + // No middleware protection in order to get this on the login page + app.get("/system/custom-app-name", async (_, response) => { + try { + const customAppName = + ( + await SystemSettings.get({ + label: "custom_app_name", + }) + )?.value ?? null; + response.status(200).json({ customAppName: customAppName }); + } catch (error) { + console.error("Error fetching custom app name:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.get( + "/system/pfp/:id", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const { id } = request.params; + if (response.locals?.user?.id !== Number(id)) + return response.sendStatus(204).end(); + + const pfpPath = await determinePfpFilepath(id); + if (!pfpPath) return response.sendStatus(204).end(); + + const { found, buffer, size, mime } = fetchPfp(pfpPath); + if (!found) return response.sendStatus(204).end(); + + response.writeHead(200, { + "Content-Type": mime || "image/png", + "Content-Disposition": `attachment; filename=${path.basename(pfpPath)}`, + "Content-Length": size, + }); + response.end(Buffer.from(buffer, "base64")); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/system/upload-pfp", + [validatedRequest, flexUserRoleValid([ROLES.all]), handlePfpUpload], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const uploadedFileName = request.randomFileName; + if (!uploadedFileName) { + return response.status(400).json({ message: "File upload failed." }); + } + + const userRecord = await User.get({ id: user.id }); + const oldPfpFilename = userRecord.pfpFilename; + if (oldPfpFilename) { + const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const oldPfpPath = path.join( + storagePath, + normalizePath(userRecord.pfpFilename) + ); + if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath))) + throw new Error("Invalid path name"); + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { success, error } = await User.update(user.id, { + pfpFilename: uploadedFileName, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Profile picture uploaded successfully." + : error || "Failed to update with new profile picture.", + }); + } catch (error) { + console.error("Error processing the profile picture upload:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + app.get( + "/system/default-system-prompt", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (_, response) => { + try { + const defaultSystemPrompt = await SystemSettings.get({ + label: "default_system_prompt", + }); + + response.status(200).json({ + success: true, + defaultSystemPrompt: + defaultSystemPrompt?.value || + SystemSettings.saneDefaultSystemPrompt, + saneDefaultSystemPrompt: SystemSettings.saneDefaultSystemPrompt, + }); + } catch (error) { + console.error("Error fetching default system prompt:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/system/default-system-prompt", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { defaultSystemPrompt } = reqBody(request); + const { success, error } = await SystemSettings.updateSettings({ + default_system_prompt: defaultSystemPrompt, + }); + if (!success) + throw new Error( + error.message || "Failed to update default system prompt." + ); + response.status(200).json({ + success: true, + message: "Default system prompt updated successfully.", + }); + } catch (error) { + console.error("Error updating default system prompt:", error); + response.status(500).json({ + success: false, + message: error.message || "Internal server error", + }); + } + } + ); + + app.delete( + "/system/remove-pfp", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const userRecord = await User.get({ id: user.id }); + const oldPfpFilename = userRecord.pfpFilename; + + if (oldPfpFilename) { + const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const oldPfpPath = path.join( + storagePath, + normalizePath(oldPfpFilename) + ); + if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath))) + throw new Error("Invalid path name"); + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { success, error } = await User.update(user.id, { + pfpFilename: null, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Profile picture removed successfully." + : error || "Failed to remove profile picture.", + }); + } catch (error) { + console.error("Error processing the profile picture removal:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/system/upload-logo", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handleAssetUpload, + ], + async (request, response) => { + if (!request?.file || !request?.file.originalname) { + return response.status(400).json({ message: "No logo file provided." }); + } + + if (!validFilename(request.file.originalname)) { + return response.status(400).json({ + message: "Invalid file name. Please choose a different file.", + }); + } + + try { + const newFilename = await renameLogoFile(request.file.originalname); + const existingLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(existingLogoFilename); + + const { success, error } = await SystemSettings._updateSettings({ + logo_filename: newFilename, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo uploaded successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo upload:", error); + response.status(500).json({ message: "Error uploading the logo." }); + } + } + ); + + app.get("/system/is-default-logo", async (_, response) => { + try { + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + const isDefaultLogo = + !currentLogoFilename || currentLogoFilename === LOGO_FILENAME; + response.status(200).json({ isDefaultLogo }); + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + + app.get( + "/system/remove-logo", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (_request, response) => { + try { + const currentLogoFilename = await SystemSettings.currentLogoFilename(); + await removeCustomLogo(currentLogoFilename); + const { success, error } = await SystemSettings._updateSettings({ + logo_filename: LOGO_FILENAME, + }); + + return response.status(success ? 200 : 500).json({ + message: success + ? "Logo removed successfully." + : error || "Failed to update with new logo.", + }); + } catch (error) { + console.error("Error processing the logo removal:", error); + response.status(500).json({ message: "Error removing the logo." }); + } + } + ); + + app.get("/system/api-keys", [validatedRequest], async (_, response) => { + try { + if (response.locals.multiUserMode) { + return response.sendStatus(401).end(); + } + + const apiKeys = await ApiKey.where({}); + return response.status(200).json({ + apiKeys, + error: null, + }); + } catch (error) { + console.error(error); + response.status(500).json({ + apiKey: null, + error: "Could not find an API Key.", + }); + } + }); + + app.post( + "/system/generate-api-key", + [validatedRequest], + async (request, response) => { + try { + if (response.locals.multiUserMode) { + return response.sendStatus(401).end(); + } + + const { name = null } = reqBody(request); + const { apiKey, error } = await ApiKey.create(null, name); + await EventLogs.logEvent( + "api_key_created", + { name: apiKey?.name }, + response?.locals?.user?.id + ); + return response.status(200).json({ + apiKey, + error, + }); + } catch (error) { + console.error(error); + response.status(500).json({ + apiKey: null, + error: "Error generating api key.", + }); + } + } + ); + + // TODO: This endpoint is replicated in the admin endpoints file. + // and should be consolidated to be a single endpoint with flexible role protection. + app.delete( + "/system/api-key/:id", + [validatedRequest], + async (request, response) => { + try { + if (response.locals.multiUserMode) + return response.sendStatus(401).end(); + const { id } = request.params; + if (!id || isNaN(Number(id))) return response.sendStatus(400).end(); + + await ApiKey.delete({ id: Number(id) }); + await EventLogs.logEvent( + "api_key_deleted", + { deletedBy: response.locals?.user?.username }, + response?.locals?.user?.id + ); + return response.status(200).end(); + } catch (error) { + console.error(error); + response.status(500).end(); + } + } + ); + + app.post( + "/system/custom-models", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { + provider, + apiKey = null, + basePath = null, + options = {}, + } = reqBody(request); + const { models, error } = await getCustomModels( + provider, + apiKey, + basePath, + options + ); + return response.status(200).json({ + models, + error, + }); + } catch (error) { + console.error(error); + response.status(500).end(); + } + } + ); + + app.post( + "/system/event-logs", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { offset = 0, limit = 10 } = reqBody(request); + const logs = await EventLogs.whereWithData({}, limit, offset * limit, { + id: "desc", + }); + const totalLogs = await EventLogs.count(); + const hasPages = totalLogs > (offset + 1) * limit; + + response.status(200).json({ logs: logs, hasPages, totalLogs }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/system/event-logs", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (_, response) => { + try { + await EventLogs.delete(); + await EventLogs.logEvent( + "event_logs_cleared", + {}, + response?.locals?.user?.id + ); + response.json({ success: true }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/system/workspace-chats", + [ + chatHistoryViewable, + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + ], + async (request, response) => { + try { + const { offset = 0, limit = 20 } = reqBody(request); + const chats = await WorkspaceChats.whereWithData( + {}, + limit, + offset * limit, + { id: "desc" } + ); + const totalChats = await WorkspaceChats.count(); + const hasPages = totalChats > (offset + 1) * limit; + + response.status(200).json({ chats: chats, hasPages, totalChats }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/system/workspace-chats/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { id } = request.params; + Number(id) === -1 + ? await WorkspaceChats.delete({}, true) + : await WorkspaceChats.delete({ id: Number(id) }); + response.json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/system/export-chats", + [ + chatHistoryViewable, + validatedRequest, + flexUserRoleValid([ROLES.manager, ROLES.admin]), + ], + async (request, response) => { + try { + const { type = "jsonl", chatType = "workspace" } = request.query; + const { contentType, data } = await exportChatsAsType(type, chatType); + await EventLogs.logEvent( + "exported_chats", + { + type, + chatType, + }, + response.locals.user?.id + ); + response.setHeader("Content-Type", contentType); + response.status(200).send(data); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + // Used for when a user in multi-user updates their own profile + // from the UI. + app.post("/system/user", [validatedRequest], async (request, response) => { + try { + const sessionUser = await userFromSession(request, response); + const { username, password, bio } = reqBody(request); + const id = Number(sessionUser.id); + + if (!id) { + response.status(400).json({ success: false, error: "Invalid user ID" }); + return; + } + + const updates = {}; + // If the username is being changed, validate it. + // Otherwise, do not attempt to validate it to allow existing users to keep their username if not changing it. + if (username !== sessionUser.username) + updates.username = User.validations.username(String(username)); + if (password) updates.password = String(password); + if (bio) updates.bio = String(bio); + + if (Object.keys(updates).length === 0) { + response + .status(400) + .json({ success: false, error: "No updates provided" }); + return; + } + + const { success, error } = await User.update(id, updates); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response + .status(500) + .json({ success: false, error: e.message || "Internal server error" }); + } + }); + + app.get( + "/system/slash-command-presets", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const userPresets = await SlashCommandPresets.getUserPresets(user?.id); + response.status(200).json({ presets: userPresets }); + } catch (error) { + console.error("Error fetching slash command presets:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/system/slash-command-presets", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { command, prompt, description } = reqBody(request); + const formattedCommand = SlashCommandPresets.formatCommand( + String(command) + ); + + if (Object.keys(VALID_COMMANDS).includes(formattedCommand)) { + return response.status(400).json({ + message: + "Cannot create a preset with a command that matches a system command", + }); + } + + const presetData = { + command: formattedCommand, + prompt: String(prompt), + description: String(description), + }; + + const preset = await SlashCommandPresets.create(user?.id, presetData); + if (!preset) { + return response + .status(500) + .json({ message: "Failed to create preset" }); + } + response.status(201).json({ preset }); + } catch (error) { + console.error("Error creating slash command preset:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/system/slash-command-presets/:slashCommandId", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { slashCommandId } = request.params; + const { command, prompt, description } = reqBody(request); + const formattedCommand = SlashCommandPresets.formatCommand( + String(command) + ); + + if (Object.keys(VALID_COMMANDS).includes(formattedCommand)) { + return response.status(400).json({ + message: + "Cannot update a preset to use a command that matches a system command", + }); + } + + // Valid user running owns the preset if user session is valid. + const ownsPreset = await SlashCommandPresets.get({ + userId: user?.id ?? null, + id: Number(slashCommandId), + }); + if (!ownsPreset) + return response.status(404).json({ message: "Preset not found" }); + + const updates = { + command: formattedCommand, + prompt: String(prompt), + description: String(description), + }; + + const preset = await SlashCommandPresets.update( + Number(slashCommandId), + updates + ); + if (!preset) return response.sendStatus(422); + response.status(200).json({ preset: { ...ownsPreset, ...updates } }); + } catch (error) { + console.error("Error updating slash command preset:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.delete( + "/system/slash-command-presets/:slashCommandId", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { slashCommandId } = request.params; + const user = await userFromSession(request, response); + + // Valid user running owns the preset if user session is valid. + const ownsPreset = await SlashCommandPresets.get({ + userId: user?.id ?? null, + id: Number(slashCommandId), + }); + if (!ownsPreset) + return response + .status(403) + .json({ message: "Failed to delete preset" }); + + await SlashCommandPresets.delete(Number(slashCommandId)); + response.sendStatus(204); + } catch (error) { + console.error("Error deleting slash command preset:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.get( + "/system/prompt-variables", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const variables = await SystemPromptVariables.getAll(user?.id); + response.status(200).json({ variables }); + } catch (error) { + console.error("Error fetching system prompt variables:", error); + response.status(500).json({ + success: false, + error: `Failed to fetch system prompt variables: ${error.message}`, + }); + } + } + ); + + app.post( + "/system/prompt-variables", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { key, value, description = null } = reqBody(request); + + if (!key || !value) { + return response.status(400).json({ + success: false, + error: "Key and value are required", + }); + } + + const variable = await SystemPromptVariables.create({ + key, + value, + description, + userId: user?.id || null, + }); + + response.status(200).json({ + success: true, + variable, + }); + } catch (error) { + console.error("Error creating system prompt variable:", error); + response.status(500).json({ + success: false, + error: `Failed to create system prompt variable: ${error.message}`, + }); + } + } + ); + + app.put( + "/system/prompt-variables/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const { key, value, description = null } = reqBody(request); + + if (!key || !value) { + return response.status(400).json({ + success: false, + error: "Key and value are required", + }); + } + + const variable = await SystemPromptVariables.update(Number(id), { + key, + value, + description, + }); + + if (!variable) { + return response.status(404).json({ + success: false, + error: "Variable not found", + }); + } + + response.status(200).json({ + success: true, + variable, + }); + } catch (error) { + console.error("Error updating system prompt variable:", error); + response.status(500).json({ + success: false, + error: `Failed to update system prompt variable: ${error.message}`, + }); + } + } + ); + + app.delete( + "/system/prompt-variables/:id", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { id } = request.params; + const success = await SystemPromptVariables.delete(Number(id)); + + if (!success) { + return response.status(404).json({ + success: false, + error: "System prompt variable not found or could not be deleted", + }); + } + + response.status(200).json({ + success: true, + }); + } catch (error) { + console.error("Error deleting system prompt variable:", error); + response.status(500).json({ + success: false, + error: `Failed to delete system prompt variable: ${error.message}`, + }); + } + } + ); + + app.post( + "/system/transcribe-audio", + [validatedRequest, flexUserRoleValid([ROLES.all]), handleAudioUpload], + async (request, response) => { + try { + if (!request.file?.buffer) { + return response + .status(400) + .json({ success: false, error: "No audio file provided." }); + } + + const provider = process.env.STT_PROVIDER || "native"; + if (provider === "native") { + return response.status(400).json({ + success: false, + error: + "Server-side transcription is disabled. Set STT_PROVIDER to a supported provider.", + }); + } + + const { getSTTProvider } = require("../utils/SpeechToText"); + const stt = getSTTProvider(); + const text = await stt.transcribe( + request.file.buffer, + request.file.originalname || "audio.webm" + ); + return response.status(200).json({ success: true, text }); + } catch (error) { + console.error("STT transcription error:", error); + return response.status(500).json({ + success: false, + error: error.message || "Transcription failed", + }); + } + } + ); + + app.post( + "/system/validate-sql-connection", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + const { engine, connectionString } = reqBody(request); + try { + if (!engine || !connectionString) { + return response.status(400).json({ + success: false, + error: "Both engine and connection details are required.", + }); + } + + const { + validateConnection, + } = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors"); + const result = await validateConnection(engine, { connectionString }); + + if (!result.success) { + return response.status(200).json({ + success: false, + error: `Unable to connect to ${engine}. Please verify your connection details.`, + }); + } + + response.status(200).json(result); + } catch (error) { + console.error("SQL validation error:", error); + response.status(500).json({ + success: false, + error: `Unable to connect to ${engine}. Please verify your connection details.`, + }); + } + } + ); +} + +module.exports = { systemEndpoints }; diff --git a/server/endpoints/telegram.js b/server/endpoints/telegram.js new file mode 100644 index 0000000..7dc4cba --- /dev/null +++ b/server/endpoints/telegram.js @@ -0,0 +1,335 @@ +const { + ExternalCommunicationConnector, +} = require("../models/externalCommunicationConnector"); +const { Telemetry } = require("../models/telemetry"); +const { TelegramBotService } = require("../utils/telegramBot"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { isSingleUserMode } = require("../utils/middleware/multiUserProtected"); +const { reqBody } = require("../utils/http"); +const { EventLogs } = require("../models/eventLogs"); +const { Workspace } = require("../models/workspace"); +const { WorkspaceThread } = require("../models/workspaceThread"); +const { encryptToken } = require("../utils/telegramBot/utils"); + +function telegramEndpoints(app) { + if (!app) return; + + app.get( + "/telegram/config", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const connector = await ExternalCommunicationConnector.get("telegram"); + if (!connector) { + return response.status(200).json({ config: null }); + } + + const service = new TelegramBotService(); + + // Resolve workspace, thread, and model from the first approved user's + // active state, falling back to the default workspace config. + const approvedUsers = connector.config.approved_users || []; + const activeUser = approvedUsers[0]; + const workspaceSlug = + activeUser?.active_workspace || + connector.config.default_workspace || + null; + const threadSlug = activeUser?.active_thread || null; + + let thread = null; + let workspace = await Workspace.get({ slug: workspaceSlug }); + if (!workspace) { + const available = await Workspace.where({}, 1); + if (available.length) workspace = available[0]; + } + + if (!threadSlug) { + const availableThreads = await WorkspaceThread.where({ + workspace_id: workspace.id, + }); + if (availableThreads.length) thread = availableThreads[0]; + } + + return response.status(200).json({ + config: { + active: connector.active, + connected: service.isRunning, + bot_username: connector.config.bot_username || null, + default_workspace: workspace?.name || workspaceSlug || "—", + active_thread_name: thread?.name || "Default", + chat_model: workspace?.chatModel || "System default", + voice_response_mode: + connector.config.voice_response_mode || "text_only", + }, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + /** + * Verify token, save config, and start the Telegram bot. + */ + app.post( + "/telegram/connect", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { bot_token, default_workspace = null } = reqBody(request); + if (!bot_token) { + return response.status(400).json({ + success: false, + error: "Bot token is required.", + }); + } + + // Verify the token with Telegram API + const verification = await TelegramBotService.verifyToken( + String(bot_token) + ); + if (!verification.valid) { + return response.status(400).json({ + success: false, + error: `Invalid bot token: ${verification.error}`, + }); + } + + let workspaceSlug = null; + if (default_workspace) workspaceSlug = String(default_workspace); + else { + const workspaces = await Workspace.where({}, 1); + if (workspaces.length) workspaceSlug = workspaces[0].slug; + else { + const { workspace } = await Workspace.new( + `${verification.username} Workspace`, + null, + { chatMode: "automatic" } + ); + if (workspace) workspaceSlug = workspace.slug; + } + } + + if (!workspaceSlug) { + return response.status(400).json({ + success: false, + error: "No workspace found or could be created.", + }); + } + + // Preserve approved users when reconnecting with a new token + const existing = await ExternalCommunicationConnector.get("telegram"); + const storedConfig = { + bot_username: verification.username, + default_workspace: workspaceSlug, + approved_users: existing?.config?.approved_users || [], + voice_response_mode: + existing?.config?.voice_response_mode || "text_only", + }; + + // Save config with encrypted token + const { error } = await ExternalCommunicationConnector.upsert( + "telegram", + { + ...storedConfig, + bot_token: encryptToken(String(bot_token)), + active: true, + } + ); + if (error) return response.status(500).json({ success: false, error }); + + // Start the bot with the plaintext token + const service = new TelegramBotService(); + await service.start({ ...storedConfig, bot_token: String(bot_token) }); + + await EventLogs.logEvent("telegram_bot_connected", { + bot_username: verification.username, + }); + await Telemetry.sendTelemetry("telegram_bot_connected"); + return response.status(200).json({ + success: true, + bot_username: verification.username, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.post( + "/telegram/disconnect", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const service = new TelegramBotService(); + await service.stop(); + await ExternalCommunicationConnector.delete("telegram"); + await EventLogs.logEvent("telegram_bot_disconnected"); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.get( + "/telegram/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const connector = await ExternalCommunicationConnector.get("telegram"); + const service = new TelegramBotService(); + return response.status(200).json({ + active: connector?.active && service.isRunning, + bot_username: connector?.config?.bot_username || null, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.get( + "/telegram/pending-users", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const service = new TelegramBotService(); + return response + .status(200) + .json({ users: service.pendingPairings || [] }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.get( + "/telegram/approved-users", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const connector = await ExternalCommunicationConnector.get("telegram"); + const approved = connector?.config?.approved_users || []; + return response.status(200).json({ users: approved }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.post( + "/telegram/approve-user", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { chatId } = reqBody(request); + if (!chatId) + return response + .status(400) + .json({ success: false, error: "chatId is required." }); + + const service = new TelegramBotService(); + await service.approvePendingUser(chatId); + await EventLogs.logEvent("telegram_user_approved", { chatId }); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.post( + "/telegram/deny-user", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { chatId } = reqBody(request); + if (!chatId) + return response + .status(400) + .json({ success: false, error: "chatId is required." }); + + const service = new TelegramBotService(); + await service.denyPendingUser(chatId); + await EventLogs.logEvent("telegram_user_denied", { chatId }); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.post( + "/telegram/revoke-user", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { chatId } = reqBody(request); + if (!chatId) + return response + .status(400) + .json({ success: false, error: "chatId is required." }); + + const service = new TelegramBotService(); + await service.revokeExistingUser(chatId); + await EventLogs.logEvent("telegram_user_revoked", { chatId }); + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); + + app.post( + "/telegram/update-config", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { voice_response_mode } = reqBody(request); + const updates = {}; + + if ( + voice_response_mode && + ["text_only", "mirror", "always_voice"].includes(voice_response_mode) + ) { + updates.voice_response_mode = voice_response_mode; + } + + if (Object.keys(updates).length === 0) { + return response + .status(400) + .json({ success: false, error: "No valid updates provided." }); + } + + const { error } = await ExternalCommunicationConnector.updateConfig( + "telegram", + updates + ); + if (error) { + return response.status(500).json({ success: false, error }); + } + + // Update the running bot's config so changes take effect immediately + const service = new TelegramBotService(); + if (service.isRunning) service.updateConfig(updates); + + return response.status(200).json({ success: true }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500); + } + } + ); +} + +module.exports = { telegramEndpoints }; diff --git a/server/endpoints/utils.js b/server/endpoints/utils.js new file mode 100644 index 0000000..fd92999 --- /dev/null +++ b/server/endpoints/utils.js @@ -0,0 +1,279 @@ +const { SystemSettings } = require("../models/systemSettings"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { + validExportTypes, + sendChatHistoryFile, +} = require("../utils/chats/exportChatToFile"); +const { multiUserMode, reqBody, userFromSession } = require("../utils/http"); + +function utilEndpoints(app) { + if (!app) return; + + app.get("/utils/metrics", async (_, response) => { + try { + const metrics = { + online: true, + version: getGitVersion(), + mode: (await SystemSettings.isMultiUserMode()) + ? "multi-user" + : "single-user", + vectorDB: process.env.VECTOR_DB || "lancedb", + storage: await getDiskStorage(), + appVersion: getDeploymentVersion(), + }; + response.status(200).json(metrics); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/export-chat/:type", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { type } = request.params; + if (!validExportTypes.includes(type)) + return response.sendStatus(400).end(); + + const { workspaceSlug, threadSlug } = reqBody(request); + const { Workspace } = require("../models/workspace"); + const { WorkspaceThread } = require("../models/workspaceThread"); + const { WorkspaceChats } = require("../models/workspaceChats"); + + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug: String(workspaceSlug) }) + : await Workspace.get({ slug: String(workspaceSlug) }); + if (!workspace) return response.sendStatus(404).end(); + + let thread; + if (threadSlug) { + thread = await WorkspaceThread.get({ + slug: String(threadSlug), + user_id: user?.id || null, + }); + if (!thread) return response.sendStatus(404).end(); + } + + const chats = await WorkspaceChats.where({ + workspaceId: workspace.id, + user_id: user?.id || null, + thread_id: thread?.id || null, + }); + if (chats.length === 0) return response.sendStatus(400).end(); + + const meta = { + workspaceName: workspace.name, + threadName: thread?.name || null, + }; + + return sendChatHistoryFile(response, chats, meta, type); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + const { + dockerModelRunnerUtilsEndpoints, + } = require("./utils/dockerModelRunnerUtils"); + dockerModelRunnerUtilsEndpoints(app); + + const { lemonadeUtilsEndpoints } = require("./utils/lemonadeUtilsEndpoints"); + lemonadeUtilsEndpoints(app); +} + +function getGitVersion() { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") return "--"; + try { + return require("child_process") + .execSync("git rev-parse HEAD") + .toString() + .trim(); + } catch (e) { + console.error("getGitVersion", e.message); + return "--"; + } +} + +function byteToGigaByte(n) { + return n / Math.pow(10, 9); +} + +async function getDiskStorage() { + try { + const checkDiskSpace = require("check-disk-space").default; + const { free, size } = await checkDiskSpace("/"); + return { + current: Math.floor(byteToGigaByte(free)), + capacity: Math.floor(byteToGigaByte(size)), + }; + } catch { + return { + current: null, + capacity: null, + }; + } +} + +/** + * Returns the model tag based on the provider set in the environment. + * This information is used to identify the parent model for the system + * so that we can prioritize the correct model and types for future updates + * as well as build features in AnythingLLM directly for a specific model or capabilities. + * + * Disable with {@link https://github.com/Mintplex-Labs/anything-llm?tab=readme-ov-file#telemetry--privacy|Disable Telemetry} + * @returns {string} The model tag. + */ +function getModelTag() { + let model = null; + const provider = process.env.LLM_PROVIDER; + + switch (provider) { + case "openai": + model = process.env.OPEN_MODEL_PREF; + break; + case "anthropic": + model = process.env.ANTHROPIC_MODEL_PREF; + break; + case "lmstudio": + model = process.env.LMSTUDIO_MODEL_PREF; + break; + case "ollama": + model = process.env.OLLAMA_MODEL_PREF; + break; + case "groq": + model = process.env.GROQ_MODEL_PREF; + break; + case "togetherai": + model = process.env.TOGETHER_AI_MODEL_PREF; + break; + case "azure": + model = + process.env.AZURE_OPENAI_MODEL_PREF || process.env.OPEN_MODEL_PREF; + break; + case "koboldcpp": + model = process.env.KOBOLD_CPP_MODEL_PREF; + break; + case "localai": + model = process.env.LOCAL_AI_MODEL_PREF; + break; + case "openrouter": + model = process.env.OPENROUTER_MODEL_PREF; + break; + case "mistral": + model = process.env.MISTRAL_MODEL_PREF; + break; + case "generic-openai": + model = process.env.GENERIC_OPEN_AI_MODEL_PREF; + break; + case "perplexity": + model = process.env.PERPLEXITY_MODEL_PREF; + break; + case "textgenwebui": + model = "textgenwebui-default"; + break; + case "bedrock": + model = process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE; + break; + case "fireworksai": + model = process.env.FIREWORKS_AI_LLM_MODEL_PREF; + break; + case "deepseek": + model = process.env.DEEPSEEK_MODEL_PREF; + break; + case "litellm": + model = process.env.LITE_LLM_MODEL_PREF; + break; + case "apipie": + model = process.env.APIPIE_LLM_MODEL_PREF; + break; + case "xai": + model = process.env.XAI_LLM_MODEL_PREF; + break; + case "novita": + model = process.env.NOVITA_LLM_MODEL_PREF; + break; + case "nvidia-nim": + model = process.env.NVIDIA_NIM_LLM_MODEL_PREF; + break; + case "ppio": + model = process.env.PPIO_MODEL_PREF; + break; + case "gemini": + model = process.env.GEMINI_LLM_MODEL_PREF; + break; + case "moonshotai": + model = process.env.MOONSHOT_AI_MODEL_PREF; + break; + case "zai": + model = process.env.ZAI_MODEL_PREF; + break; + case "giteeai": + model = process.env.GITEE_AI_MODEL_PREF; + break; + case "cohere": + model = process.env.COHERE_MODEL_PREF; + break; + case "docker-model-runner": + model = process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_PREF; + break; + case "privatemode": + model = process.env.PRIVATEMODE_LLM_MODEL_PREF; + break; + case "sambanova": + model = process.env.SAMBANOVA_LLM_MODEL_PREF; + break; + case "lemonade": + model = process.env.LEMONADE_LLM_MODEL_PREF; + break; + case "minimax": + model = process.env.MINIMAX_MODEL_PREF; + break; + case "cerebras": + model = process.env.CEREBRAS_MODEL_PREF; + break; + default: + model = "--"; + break; + } + return model; +} + +/** + * Returns the deployment version. + * - Dev: reads from package.json + * - Prod: reads from ENV + * expected format: major.minor.patch + * @returns {string|null} The deployment version. + */ +function getDeploymentVersion() { + if (process.env.NODE_ENV === "development") + return require("../../package.json").version; + if (process.env.DEPLOYMENT_VERSION) return process.env.DEPLOYMENT_VERSION; + return null; +} + +/** + * Returns the user agent for the AnythingLLM deployment. + * @returns {string} The user agent. + */ +function getAnythingLLMUserAgent() { + const version = getDeploymentVersion() || "unknown"; + return `AnythingLLM/${version}`; +} + +module.exports = { + utilEndpoints, + getGitVersion, + getModelTag, + getAnythingLLMUserAgent, + getDeploymentVersion, +}; diff --git a/server/endpoints/utils/dockerModelRunnerUtils.js b/server/endpoints/utils/dockerModelRunnerUtils.js new file mode 100644 index 0000000..cdfe2bb --- /dev/null +++ b/server/endpoints/utils/dockerModelRunnerUtils.js @@ -0,0 +1,94 @@ +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { reqBody } = require("../../utils/http"); +const { safeJsonParse, decodeHtmlEntities } = require("../../utils/http"); + +function dockerModelRunnerUtilsEndpoints(app) { + if (!app) return; + const { + parseDockerModelRunnerEndpoint, + } = require("../../utils/AiProviders/dockerModelRunner"); + + app.post( + "/utils/dmr/download-model", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { modelId, basePath = "" } = reqBody(request); + const dmrUrl = new URL( + parseDockerModelRunnerEndpoint( + basePath ?? process.env.DOCKER_MODEL_RUNNER_BASE_PATH, + "dmr" + ) + ); + dmrUrl.pathname = "/models/create"; + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const dmrResponse = await fetch(dmrUrl.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ from: String(modelId) }), + }); + if (!dmrResponse.ok) + throw new Error( + dmrResponse.statusText || + "An error occurred while downloading the model" + ); + const reader = dmrResponse.body.getReader(); + let done = false; + while (!done) { + const { value, done: readerDone } = await reader.read(); + if (readerDone) done = true; + + const chunk = new TextDecoder("utf-8").decode(value); + const lines = chunk.split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + const decodedLine = decodeHtmlEntities(line); + const data = safeJsonParse(decodedLine); + if (!data) continue; + + if (data.type === "error") { + throw new Error( + data.message || "An error occurred while downloading the model" + ); + } else if (data.type === "success") { + response.write( + `data: ${JSON.stringify({ type: "success", percentage: 100, message: "Model downloaded successfully" })}\n\n` + ); + done = true; + } else if (data.type === "progress") { + const percentage = + data.total > 0 + ? Math.round((data.pulled / data.total) * 100) + : 0; + response.write( + `data: ${JSON.stringify({ type: "progress", percentage, message: data.message })}\n\n` + ); + } + } + } + } catch (e) { + console.error(e); + response.write( + `data: ${JSON.stringify({ type: "error", message: e.message })}\n\n` + ); + } finally { + response.end(); + } + } + ); +} + +module.exports = { + dockerModelRunnerUtilsEndpoints, +}; diff --git a/server/endpoints/utils/googleAgentSkillEndpoints.js b/server/endpoints/utils/googleAgentSkillEndpoints.js new file mode 100644 index 0000000..c7b1186 --- /dev/null +++ b/server/endpoints/utils/googleAgentSkillEndpoints.js @@ -0,0 +1,70 @@ +const { + isSingleUserMode, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { GmailBridge } = require("../../utils/agents/aibitat/plugins/gmail/lib"); +const { + GoogleCalendarBridge, +} = require("../../utils/agents/aibitat/plugins/google-calendar/lib"); + +function googleAgentSkillEndpoints(app) { + if (!app) return; + + app.get( + "/admin/agent-skills/gmail/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const config = await GmailBridge.getConfig(); + + const hasDeploymentId = !!config.deploymentId; + const hasApiKey = !!config.apiKey; + const isConfigured = hasDeploymentId && hasApiKey; + + const safeConfig = { + deploymentId: config.deploymentId || "", + apiKey: hasApiKey ? "********" : "", + }; + + return response.status(200).json({ + success: true, + isConfigured, + config: safeConfig, + }); + } catch (e) { + console.error("Gmail status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.get( + "/admin/agent-skills/google-calendar/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const config = await GoogleCalendarBridge.getConfig(); + + const hasDeploymentId = !!config.deploymentId; + const hasApiKey = !!config.apiKey; + const isConfigured = hasDeploymentId && hasApiKey; + + const safeConfig = { + deploymentId: config.deploymentId || "", + apiKey: hasApiKey ? "********" : "", + }; + + return response.status(200).json({ + success: true, + isConfigured, + config: safeConfig, + }); + } catch (e) { + console.error("Google Calendar status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); +} + +module.exports = { googleAgentSkillEndpoints }; diff --git a/server/endpoints/utils/lemonadeUtilsEndpoints.js b/server/endpoints/utils/lemonadeUtilsEndpoints.js new file mode 100644 index 0000000..5b4023d --- /dev/null +++ b/server/endpoints/utils/lemonadeUtilsEndpoints.js @@ -0,0 +1,170 @@ +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { + flexUserRoleValid, + ROLES, +} = require("../../utils/middleware/multiUserProtected"); +const { reqBody } = require("../../utils/http"); +const { safeJsonParse, decodeHtmlEntities } = require("../../utils/http"); +const { + parseLemonadeServerEndpoint, +} = require("../../utils/AiProviders/lemonade"); + +function lemonadeUtilsEndpoints(app) { + if (!app) return; + + app.post( + "/utils/lemonade/download-model", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { modelId, basePath = "" } = reqBody(request); + const lemonadeUrl = new URL( + parseLemonadeServerEndpoint( + basePath ?? process.env.LEMONADE_LLM_BASE_PATH, + "base" + ) + ); + lemonadeUrl.pathname += "api/v1/pull"; + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const lemonadeResponse = await fetch(lemonadeUrl.toString(), { + method: "POST", + headers: { + ...(!!process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_name: String(modelId), + stream: true, + }), + }); + if (!lemonadeResponse.ok) + throw new Error( + lemonadeResponse.statusText || + "An error occurred while downloading the model" + ); + const reader = lemonadeResponse.body.getReader(); + let done = false; + let currentEvent = null; + + while (!done) { + const { value, done: readerDone } = await reader.read(); + if (readerDone) done = true; + + const chunk = new TextDecoder("utf-8").decode(value); + const lines = chunk.split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + + if (line.startsWith("event:")) { + currentEvent = line.replace("event:", "").trim(); + continue; + } + + if (line.startsWith("data:")) { + const jsonStr = line.replace("data:", "").trim(); + const decodedLine = decodeHtmlEntities(jsonStr); + const data = safeJsonParse(decodedLine); + if (!data) continue; + + if (currentEvent === "error") { + throw new Error( + data.message || + "An error occurred while downloading the model" + ); + } else if (currentEvent === "complete") { + response.write( + `data: ${JSON.stringify({ type: "success", percentage: 100, message: "Model downloaded successfully" })}\n\n` + ); + done = true; + } else if (currentEvent === "progress") { + const percentage = data.percent ?? 0; + const message = data.file + ? `Downloading ${data.file}` + : "Downloading model..."; + response.write( + `data: ${JSON.stringify({ type: "progress", percentage, message })}\n\n` + ); + } + + currentEvent = null; + } + } + } + } catch (e) { + console.error(e); + response.write( + `data: ${JSON.stringify({ type: "error", message: e.message })}\n\n` + ); + } finally { + response.end(); + } + } + ); + + app.post( + "/utils/lemonade/delete-model", + [validatedRequest, flexUserRoleValid([ROLES.admin])], + async (request, response) => { + try { + const { modelId, basePath = "" } = reqBody(request); + if (!modelId) { + return response.status(400).json({ + success: false, + error: "modelId is required", + }); + } + + const lemonadeUrl = new URL( + parseLemonadeServerEndpoint( + basePath ?? process.env.LEMONADE_LLM_BASE_PATH, + "base" + ) + ); + lemonadeUrl.pathname += "api/v1/delete"; + + const lemonadeResponse = await fetch(lemonadeUrl.toString(), { + method: "POST", + headers: { + ...(!!process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_name: String(modelId), + }), + }); + + const data = await lemonadeResponse.json(); + if (!lemonadeResponse.ok || data.status === "error") { + return response.status(lemonadeResponse.status || 500).json({ + success: false, + error: data.message || "An error occurred while deleting the model", + }); + } + + return response.status(200).json({ + success: true, + message: data.message || `Deleted model: ${modelId}`, + }); + } catch (e) { + console.error(e); + return response.status(500).json({ + success: false, + error: e.message || "An error occurred while deleting the model", + }); + } + } + ); +} + +module.exports = { + lemonadeUtilsEndpoints, +}; diff --git a/server/endpoints/utils/outlookAgentUtils.js b/server/endpoints/utils/outlookAgentUtils.js new file mode 100644 index 0000000..5d4dae6 --- /dev/null +++ b/server/endpoints/utils/outlookAgentUtils.js @@ -0,0 +1,190 @@ +const { reqBody } = require("../../utils/http"); +const { + isSingleUserMode, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); + +/** + * Constructs the OAuth redirect URI from the request headers. + * @param {Object} request - Express request object + * @returns {string} The redirect URI for OAuth callback + */ +function getOutlookRedirectUri(request) { + const protocol = request.headers["x-forwarded-proto"] || request.protocol; + const host = request.headers["x-forwarded-host"] || request.get("host"); + return `${protocol}://${host}/api/agent-skills/outlook/auth-callback`; +} + +function outlookAgentEndpoints(app) { + if (!app) return; + + app.post( + "/admin/agent-skills/outlook/auth-url", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { clientId, tenantId, clientSecret, authType } = reqBody(request); + + if (!clientId || !clientSecret) { + return response.status(400).json({ + success: false, + error: "Client ID and Client Secret are required.", + }); + } + + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { AUTH_TYPES } = outlookLib; + const validAuthType = Object.values(AUTH_TYPES).includes(authType) + ? authType + : AUTH_TYPES.common; + + if (validAuthType === AUTH_TYPES.organization && !tenantId) { + return response.status(400).json({ + success: false, + error: + "Tenant ID is required for organization-only authentication.", + }); + } + + const existingConfig = await outlookLib.OutlookBridge.getConfig(); + const configUpdate = { + ...existingConfig, + clientId: clientId.trim(), + tenantId: tenantId?.trim() || "", + authType: validAuthType, + }; + + if (!/^\*+$/.test(clientSecret)) + configUpdate.clientSecret = clientSecret.trim(); + + // If auth type changed, clear tokens as they won't work with different authority + if ( + existingConfig.authType && + existingConfig.authType !== validAuthType + ) { + delete configUpdate.accessToken; + delete configUpdate.refreshToken; + delete configUpdate.tokenExpiry; + } + + await outlookLib.OutlookBridge.updateConfig(configUpdate); + outlookLib.reset(); + + const redirectUri = getOutlookRedirectUri(request); + const result = await outlookLib.getAuthUrl(redirectUri); + if (!result.success) { + return response + .status(400) + .json({ success: false, error: result.error }); + } + + return response.status(200).json({ success: true, url: result.url }); + } catch (e) { + console.error("Outlook auth URL error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.get( + "/agent-skills/outlook/auth-callback", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { code, error, error_description } = request.query; + + if (error) { + console.error("Outlook OAuth error:", error, error_description); + return response.redirect( + `/?outlook_auth=error&message=${encodeURIComponent(error_description || error)}` + ); + } + + if (!code) { + return response.redirect( + "/?outlook_auth=error&message=No authorization code received" + ); + } + + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const redirectUri = getOutlookRedirectUri(request); + const result = await outlookLib.exchangeCodeForToken(code, redirectUri); + + const frontendUrl = + process.env.NODE_ENV === "development" ? "http://localhost:3000" : ""; + + if (!result.success) { + return response.redirect( + `${frontendUrl}/settings/agents?outlook_auth=error&message=${encodeURIComponent(result.error)}` + ); + } + + return response.redirect( + `${frontendUrl}/settings/agents?outlook_auth=success` + ); + } catch (e) { + console.error("Outlook OAuth callback error:", e); + response.redirect( + `/?outlook_auth=error&message=${encodeURIComponent(e.message)}` + ); + } + } + ); + + app.get( + "/admin/agent-skills/outlook/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { AUTH_TYPES, normalizeTokenExpiry } = outlookLib; + const config = await outlookLib.OutlookBridge.getConfig(); + const isConfigured = await outlookLib.OutlookBridge.isToolAvailable(); + + const authType = config.authType || AUTH_TYPES.common; + let hasCredentials = !!(config.clientId && config.clientSecret); + if (authType === AUTH_TYPES.organization) { + hasCredentials = hasCredentials && !!config.tenantId; + } + + const safeConfig = { + clientId: config.clientId || "", + tenantId: config.tenantId || "", + clientSecret: config.clientSecret ? "********" : "", + authType: config.authType || AUTH_TYPES.common, + }; + + return response.status(200).json({ + success: true, + isConfigured, + hasCredentials, + isAuthenticated: !!config.accessToken, + tokenExpiry: normalizeTokenExpiry(config.tokenExpiry), + config: safeConfig, + }); + } catch (e) { + console.error("Outlook status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.post( + "/admin/agent-skills/outlook/revoke", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { SystemSettings } = require("../../models/systemSettings"); + await SystemSettings.delete({ label: "outlook_agent_config" }); + outlookLib.reset(); + return response.status(200).json({ success: true }); + } catch (e) { + console.error("Outlook revoke error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); +} + +module.exports = { outlookAgentEndpoints }; diff --git a/server/endpoints/webPush.js b/server/endpoints/webPush.js new file mode 100644 index 0000000..5f40623 --- /dev/null +++ b/server/endpoints/webPush.js @@ -0,0 +1,27 @@ +const { reqBody } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { pushNotificationService } = require("../utils/PushNotifications"); + +function webPushEndpoints(app) { + if (!app) return; + + app.post( + "/web-push/subscribe", + [validatedRequest], + async (request, response) => { + const subscription = reqBody(request); + await pushNotificationService.registerSubscription( + response.locals.user, + subscription + ); + response.status(201).json({}); + } + ); + + app.get("/web-push/pubkey", [validatedRequest], (_request, response) => { + const publicKey = pushNotificationService.publicVapidKey; + response.status(200).json({ publicKey }); + }); +} + +module.exports = { webPushEndpoints }; diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js new file mode 100644 index 0000000..427751b --- /dev/null +++ b/server/endpoints/workspaceThreads.js @@ -0,0 +1,267 @@ +const { + multiUserMode, + userFromSession, + reqBody, + safeJsonParse, +} = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { EventLogs } = require("../models/eventLogs"); +const { WorkspaceThread } = require("../models/workspaceThread"); +const { + validWorkspaceSlug, + validWorkspaceAndThreadSlug, +} = require("../utils/middleware/validWorkspace"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { convertToChatHistory } = require("../utils/helpers/chat/responses"); +const { getModelTag } = require("./utils"); + +function workspaceThreadEndpoints(app) { + if (!app) return; + + app.post( + "/workspace/:slug/thread/new", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const { thread, message } = await WorkspaceThread.new( + workspace, + user?.id + ); + await Telemetry.sendTelemetry( + "workspace_thread_created", + { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }, + user?.id + ); + + await EventLogs.logEvent( + "workspace_thread_created", + { + workspaceName: workspace?.name || "Unknown Workspace", + }, + user?.id + ); + response.status(200).json({ thread, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/threads", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const threads = await WorkspaceThread.where({ + workspace_id: workspace.id, + user_id: user?.id || null, + }); + + const defaultThreadChatCount = await WorkspaceChats.count({ + workspaceId: workspace.id, + user_id: user?.id || null, + thread_id: null, + api_session_id: null, + include: true, + }); + + response.status(200).json({ threads, defaultThreadChatCount }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/thread/:threadSlug", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (_, response) => { + try { + const thread = response.locals.thread; + await WorkspaceThread.delete({ id: thread.id }); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/thread-bulk-delete", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { slugs = [] } = reqBody(request); + if (slugs.length === 0) return response.sendStatus(200).end(); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + await WorkspaceThread.delete({ + slug: { in: slugs }, + user_id: user?.id ?? null, + workspace_id: workspace.id, + }); + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/thread/:threadSlug/chats", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + const history = await WorkspaceChats.where( + { + workspaceId: workspace.id, + user_id: user?.id || null, + thread_id: thread.id, + api_session_id: null, // Do not include API session chats. + include: true, + }, + null, + { id: "asc" } + ); + + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/thread/:threadSlug/update", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const data = reqBody(request); + const currentThread = response.locals.thread; + const { thread, message } = await WorkspaceThread.update( + currentThread, + data + ); + response.status(200).json({ thread, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/thread/:threadSlug/delete-edited-chats", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const { startingId } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + + await WorkspaceChats.delete({ + workspaceId: Number(workspace.id), + thread_id: Number(thread.id), + user_id: user?.id, + id: { gte: Number(startingId) }, + }); + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/thread/:threadSlug/update-chat", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + validWorkspaceAndThreadSlug, + ], + async (request, response) => { + try { + const { chatId, newText = null, role = "assistant" } = reqBody(request); + if (!newText || !String(newText).trim()) + throw new Error("Cannot save empty edit"); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = response.locals.thread; + const existingChat = await WorkspaceChats.get({ + workspaceId: workspace.id, + thread_id: thread.id, + user_id: user?.id, + id: Number(chatId), + }); + if (!existingChat) throw new Error("Invalid chat."); + + if (role === "user") { + await WorkspaceChats._update(existingChat.id, { + prompt: String(newText), + }); + } else { + const chatResponse = safeJsonParse(existingChat.response, null); + if (!chatResponse) throw new Error("Failed to parse chat response"); + await WorkspaceChats._update(existingChat.id, { + response: JSON.stringify({ + ...chatResponse, + text: String(newText), + }), + }); + } + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { workspaceThreadEndpoints }; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js new file mode 100644 index 0000000..0a2d05d --- /dev/null +++ b/server/endpoints/workspaces.js @@ -0,0 +1,1176 @@ +const path = require("path"); +const fs = require("fs"); +const { + reqBody, + multiUserMode, + userFromSession, + safeJsonParse, +} = require("../utils/http"); +const { normalizePath, isWithin } = require("../utils/files"); +const { Workspace } = require("../models/workspace"); +const { Document } = require("../models/documents"); +const { DocumentVectors } = require("../models/vectors"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { getVectorDbClass, stripThinkingFromText } = require("../utils/helpers"); +const { handleFileUpload, handlePfpUpload } = require("../utils/files/multer"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { EventLogs } = require("../models/eventLogs"); +const { + WorkspaceSuggestedMessages, +} = require("../models/workspacesSuggestedMessages"); +const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace"); +const { convertToChatHistory } = require("../utils/helpers/chat/responses"); +const { CollectorApi } = require("../utils/collectorApi"); +const { + determineWorkspacePfpFilepath, + fetchPfp, +} = require("../utils/files/pfp"); +const { getTTSProvider } = require("../utils/TextToSpeech"); +const { WorkspaceThread } = require("../models/workspaceThread"); + +const truncate = require("truncate"); +const { purgeDocument } = require("../utils/files/purgeDocument"); +const { getModelTag } = require("./utils"); +const { searchWorkspaceAndThreads } = require("../utils/helpers/search"); +const { workspaceParsedFilesEndpoints } = require("./workspacesParsedFiles"); +const { + workspaceDeletionProtection, +} = require("../utils/middleware/workspaceDeletionProtection"); + +function workspaceEndpoints(app) { + if (!app) return; + const responseCache = new Map(); + + app.post( + "/workspace/new", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { name = null } = reqBody(request); + const { workspace, message } = await Workspace.new(name, user?.id); + await Telemetry.sendTelemetry( + "workspace_created", + { + multiUserMode: multiUserMode(response), + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }, + user?.id + ); + + await EventLogs.logEvent( + "workspace_created", + { + workspaceName: workspace?.name || "Unknown Workspace", + }, + user?.id + ); + response.status(200).json({ workspace, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/update", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { slug = null } = request.params; + const data = reqBody(request); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + await Workspace.trackChange(currWorkspace, data, user); + const { workspace, message } = await Workspace.update( + currWorkspace.id, + data + ); + response.status(200).json({ workspace, message }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/upload", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handleFileUpload, + ], + async function (request, response) { + try { + const Collector = new CollectorApi(); + const { originalname } = request.file; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`, + }) + .end(); + return; + } + + const { success, reason } = + await Collector.processDocument(originalname); + if (!success) { + response.status(500).json({ success: false, error: reason }).end(); + return; + } + + Collector.log( + `Document ${originalname} uploaded processed and successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("document_uploaded"); + await EventLogs.logEvent( + "document_uploaded", + { + documentName: originalname, + }, + response.locals?.user?.id + ); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/upload-link", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const Collector = new CollectorApi(); + const { link = "" } = reqBody(request); + const processingOnline = await Collector.online(); + + if (!processingOnline) { + response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Link ${link} will not be processed automatically.`, + }) + .end(); + return; + } + + const { success, reason } = await Collector.processLink(link); + if (!success) { + response.status(500).json({ success: false, error: reason }).end(); + return; + } + + Collector.log( + `Link ${link} uploaded processed and successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("link_uploaded"); + await EventLogs.logEvent( + "link_uploaded", + { link }, + response.locals?.user?.id + ); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/update-embeddings", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { slug = null } = request.params; + const { adds = [], deletes = [] } = reqBody(request); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + await Document.removeDocuments( + currWorkspace, + deletes, + response.locals?.user?.id + ); + + const { + isNativeEmbedder, + embedFiles, + } = require("../utils/EmbeddingWorkerManager"); + + if (isNativeEmbedder() && adds.length > 0) { + await embedFiles( + currWorkspace.slug, + adds, + currWorkspace.id, + response.locals?.user?.id ?? null + ); + const updatedWorkspace = await Workspace.get({ + id: currWorkspace.id, + }); + response + .status(200) + .json({ workspace: updatedWorkspace, message: null }); + return; + } + + const { failedToEmbed = [], errors = [] } = await Document.addDocuments( + currWorkspace, + adds, + response.locals?.user?.id + ); + const updatedWorkspace = await Workspace.get({ id: currWorkspace.id }); + response.status(200).json({ + workspace: updatedWorkspace, + message: + failedToEmbed.length > 0 + ? `${failedToEmbed.length} documents failed to add.\n\n${errors + .map((msg) => `${msg}`) + .join("\n\n")}` + : null, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + workspaceDeletionProtection, + ], + async (request, response) => { + try { + const { slug = "" } = request.params; + const user = await userFromSession(request, response); + const VectorDb = getVectorDbClass(); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + await WorkspaceChats.delete({ workspaceId: Number(workspace.id) }); + await DocumentVectors.deleteForWorkspace(workspace.id); + await Document.delete({ workspaceId: Number(workspace.id) }); + await Workspace.delete({ id: Number(workspace.id) }); + + await EventLogs.logEvent( + "workspace_deleted", + { + workspaceName: workspace?.name || "Unknown Workspace", + }, + response.locals?.user?.id + ); + + try { + await VectorDb["delete-namespace"]({ namespace: slug }); + } catch (e) { + console.error(e.message); + } + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/reset-vector-db", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { slug = "" } = request.params; + const user = await userFromSession(request, response); + const VectorDb = getVectorDbClass(); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + await DocumentVectors.deleteForWorkspace(workspace.id); + await Document.delete({ workspaceId: Number(workspace.id) }); + + await EventLogs.logEvent( + "workspace_vectors_reset", + { + workspaceName: workspace?.name || "Unknown Workspace", + }, + response.locals?.user?.id + ); + + try { + await VectorDb["delete-namespace"]({ namespace: slug }); + } catch (e) { + console.error(e.message); + } + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspaces", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspaces = multiUserMode(response) + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + + response.status(200).json({ workspaces }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspace/:slug", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { slug } = request.params; + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + response.status(200).json({ workspace }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/chats", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { slug } = request.params; + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + const history = multiUserMode(response) + ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id) + : await WorkspaceChats.forWorkspace(workspace.id); + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/delete-chats", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { chatIds = [] } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + if (!workspace || !Array.isArray(chatIds)) { + response.sendStatus(400).end(); + return; + } + + // This works for both workspace and threads. + // we simplify this by just looking at workspace<>user overlap + // since they are all on the same table. + await WorkspaceChats.delete({ + id: { in: chatIds.map((id) => Number(id)) }, + user_id: user?.id ?? null, + workspaceId: workspace.id, + }); + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/delete-edited-chats", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { startingId } = reqBody(request); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + await WorkspaceChats.delete({ + workspaceId: workspace.id, + thread_id: null, + user_id: user?.id, + id: { gte: Number(startingId) }, + }); + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/update-chat", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { chatId, newText = null, role = "assistant" } = reqBody(request); + if (!newText || !String(newText).trim()) + throw new Error("Cannot save empty edit"); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const existingChat = await WorkspaceChats.get({ + workspaceId: workspace.id, + thread_id: null, + user_id: user?.id, + id: Number(chatId), + }); + if (!existingChat) throw new Error("Invalid chat."); + + if (role === "user") { + await WorkspaceChats._update(existingChat.id, { + prompt: String(newText), + }); + } else { + const chatResponse = safeJsonParse(existingChat.response, null); + if (!chatResponse) throw new Error("Failed to parse chat response"); + await WorkspaceChats._update(existingChat.id, { + response: JSON.stringify({ + ...chatResponse, + text: String(newText), + }), + }); + } + + response.sendStatus(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/chat-feedback/:chatId", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { chatId } = request.params; + const { feedback = null } = reqBody(request); + const user = await userFromSession(request, response); + const existingChat = await WorkspaceChats.get({ + id: Number(chatId), + workspaceId: response.locals.workspace.id, + user_id: user?.id, + }); + + if (!existingChat) return response.status(404).json({ success: false }); + await WorkspaceChats.updateFeedbackScore(chatId, feedback); + return response.status(200).json({ success: true }); + } catch (error) { + console.error("Error updating chat feedback:", error); + response.status(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/suggested-messages", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const { slug } = request.params; + const suggestedMessages = + await WorkspaceSuggestedMessages.getMessages(slug); + response.status(200).json({ success: true, suggestedMessages }); + } catch (error) { + console.error("Error fetching suggested messages:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/workspace/:slug/suggested-messages", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { messages = [] } = reqBody(request); + const { slug } = request.params; + if (!Array.isArray(messages)) { + return response.status(400).json({ + success: false, + message: "Invalid message format. Expected an array of messages.", + }); + } + + await WorkspaceSuggestedMessages.saveAll(messages, slug); + return response.status(200).json({ + success: true, + message: "Suggested messages saved successfully.", + }); + } catch (error) { + console.error("Error processing the suggested messages:", error); + response.status(500).json({ + success: true, + message: "Error saving the suggested messages.", + }); + } + } + ); + + app.post( + "/workspace/:slug/update-pin", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const { docPath, pinStatus = false } = reqBody(request); + const workspace = response.locals.workspace; + + const document = await Document.get({ + workspaceId: workspace.id, + docpath: docPath, + }); + if (!document) return response.sendStatus(404).end(); + + await Document.update(document.id, { pinned: pinStatus }); + return response.status(200).end(); + } catch (error) { + console.error("Error processing the pin status update:", error); + return response.status(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/tts/:chatId", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async function (request, response) { + try { + const { chatId } = request.params; + const workspace = response.locals.workspace; + const user = await userFromSession(request, response); + const cacheKey = `${workspace.slug}:${chatId}`; + const wsChat = await WorkspaceChats.get({ + id: Number(chatId), + workspaceId: workspace.id, + user_id: user?.id, + }); + + if (!wsChat) return response.sendStatus(404); + const cachedResponse = responseCache.get(cacheKey); + if (cachedResponse) { + response.writeHead(200, { + "Content-Type": cachedResponse.mime || "audio/mpeg", + }); + response.end(cachedResponse.buffer); + return; + } + + const text = safeJsonParse(wsChat.response, null)?.text; + if (!text) return response.sendStatus(204).end(); + + const TTSProvider = getTTSProvider(); + const buffer = await TTSProvider.ttsBuffer(text); + if (buffer === null) return response.sendStatus(204).end(); + + responseCache.set(cacheKey, { buffer, mime: "audio/mpeg" }); + response.writeHead(200, { + "Content-Type": "audio/mpeg", + }); + response.end(buffer); + return; + } catch (error) { + console.error("Error processing the TTS request:", error); + response.status(500).json({ message: "TTS could not be completed" }); + } + } + ); + + app.get( + "/workspace/:slug/pfp", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async function (request, response) { + try { + const { slug } = request.params; + const cachedResponse = responseCache.get(slug); + + if (cachedResponse) { + response.writeHead(200, { + "Content-Type": cachedResponse.mime || "image/png", + }); + response.end(cachedResponse.buffer); + return; + } + + const pfpPath = await determineWorkspacePfpFilepath(slug); + + if (!pfpPath) { + response.sendStatus(204).end(); + return; + } + + const { found, buffer, mime } = fetchPfp(pfpPath); + if (!found) { + response.sendStatus(204).end(); + return; + } + + responseCache.set(slug, { buffer, mime }); + + response.writeHead(200, { + "Content-Type": mime || "image/png", + }); + response.end(buffer); + return; + } catch (error) { + console.error("Error processing the logo request:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/workspace/:slug/upload-pfp", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handlePfpUpload, + ], + async function (request, response) { + try { + const { slug } = request.params; + const uploadedFileName = request.randomFileName; + if (!uploadedFileName) { + return response.status(400).json({ message: "File upload failed." }); + } + + const workspaceRecord = await Workspace.get({ + slug, + }); + + const oldPfpFilename = workspaceRecord.pfpFilename; + if (oldPfpFilename) { + const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const oldPfpPath = path.join( + storagePath, + normalizePath(workspaceRecord.pfpFilename) + ); + if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath))) + throw new Error("Invalid path name"); + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { workspace, message } = await Workspace._update( + workspaceRecord.id, + { + pfpFilename: uploadedFileName, + } + ); + + return response.status(workspace ? 200 : 500).json({ + message: workspace + ? "Profile picture uploaded successfully." + : message, + }); + } catch (error) { + console.error("Error processing the profile picture upload:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.delete( + "/workspace/:slug/remove-pfp", + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async function (request, response) { + try { + const { slug } = request.params; + const workspaceRecord = await Workspace.get({ + slug, + }); + const oldPfpFilename = workspaceRecord.pfpFilename; + + if (oldPfpFilename) { + const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const oldPfpPath = path.join( + storagePath, + normalizePath(oldPfpFilename) + ); + if (!isWithin(path.resolve(storagePath), path.resolve(oldPfpPath))) + throw new Error("Invalid path name"); + if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath); + } + + const { workspace, message } = await Workspace._update( + workspaceRecord.id, + { + pfpFilename: null, + } + ); + + // Clear the cache + responseCache.delete(slug); + + return response.status(workspace ? 200 : 500).json({ + message: workspace + ? "Profile picture removed successfully." + : message, + }); + } catch (error) { + console.error("Error processing the profile picture removal:", error); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.post( + "/workspace/:slug/thread/fork", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const { chatId, threadSlug } = reqBody(request); + if (!chatId) + return response.status(400).json({ message: "chatId is required" }); + + // Get threadId we are branching from if that request body is sent + // and is a valid thread slug. + const threadId = !!threadSlug + ? ( + await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + }) + )?.id ?? null + : null; + const chatsToFork = await WorkspaceChats.where( + { + workspaceId: workspace.id, + user_id: user?.id, + include: true, // only duplicate visible chats + thread_id: threadId, + api_session_id: null, // Do not include API session chats. + id: { lte: Number(chatId) }, + }, + null, + { id: "asc" } + ); + + const { thread: newThread, message: threadError } = + await WorkspaceThread.new(workspace, user?.id); + if (threadError) + return response.status(500).json({ error: threadError }); + + let lastMessageText = ""; + const chatsData = chatsToFork.map((chat) => { + const chatResponse = safeJsonParse(chat.response, {}); + if (chatResponse?.text) + lastMessageText = stripThinkingFromText(chatResponse.text); + + return { + workspaceId: workspace.id, + prompt: chat.prompt, + response: JSON.stringify(chatResponse), + user_id: user?.id, + thread_id: newThread.id, + }; + }); + await WorkspaceChats.bulkCreate(chatsData); + await WorkspaceThread.update(newThread, { + name: !!lastMessageText + ? truncate(lastMessageText, 22) + : "Forked Thread", + }); + + await EventLogs.logEvent( + "thread_forked", + { + workspaceName: workspace?.name || "Unknown Workspace", + threadName: newThread.name, + }, + user?.id + ); + response.status(200).json({ newThreadSlug: newThread.slug }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ message: "Internal server error" }); + } + } + ); + + app.put( + "/workspace/workspace-chats/:id", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { id } = request.params; + const user = await userFromSession(request, response); + const validChat = await WorkspaceChats.get({ + id: Number(id), + user_id: user?.id ?? null, + }); + if (!validChat) + return response + .status(404) + .json({ success: false, error: "Chat not found." }); + + await WorkspaceChats._update(validChat.id, { include: false }); + response.json({ success: true, error: null }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ success: false, error: "Server error" }); + } + } + ); + + /** Handles the uploading and embedding in one-call by uploading via drag-and-drop in chat container. */ + app.post( + "/workspace/:slug/upload-and-embed", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handleFileUpload, + ], + async function (request, response) { + try { + const { slug = null } = request.params; + const user = await userFromSession(request, response); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + const Collector = new CollectorApi(); + const { originalname } = request.file; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + response + .status(500) + .json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be processed automatically.`, + }) + .end(); + return; + } + + const { success, reason, documents } = + await Collector.processDocument(originalname); + if (!success || documents?.length === 0) { + response.status(500).json({ success: false, error: reason }).end(); + return; + } + + Collector.log( + `Document ${originalname} uploaded processed and successfully. It is now available in documents.` + ); + await Telemetry.sendTelemetry("document_uploaded"); + await EventLogs.logEvent( + "document_uploaded", + { + documentName: originalname, + }, + response.locals?.user?.id + ); + + const document = documents[0]; + const { failedToEmbed = [], errors = [] } = await Document.addDocuments( + currWorkspace, + [document.location], + response.locals?.user?.id + ); + + if (failedToEmbed.length > 0) + return response + .status(200) + .json({ success: false, error: errors?.[0], document: null }); + + response.status(200).json({ + success: true, + error: null, + document: { id: document.id, location: document.location }, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/remove-and-unembed", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + handleFileUpload, + ], + async function (request, response) { + try { + const { slug = null } = request.params; + const body = reqBody(request); + const user = await userFromSession(request, response); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, { slug }) + : await Workspace.get({ slug }); + + if (!currWorkspace || !body.documentLocation) + return response.sendStatus(400).end(); + + // Will delete the document from the entire system + wil unembed it. + await purgeDocument(body.documentLocation); + response.status(200).end(); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/workspace/:slug/prompt-history", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (_, response) => { + try { + response.status(200).json({ + history: await Workspace.promptHistory({ + workspaceId: response.locals.workspace.id, + }), + }); + } catch (error) { + console.error("Error fetching prompt history:", error); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/prompt-history", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (_, response) => { + try { + response.status(200).json({ + success: await Workspace.deleteAllPromptHistory({ + workspaceId: response.locals.workspace.id, + }), + }); + } catch (error) { + console.error("Error clearing prompt history:", error); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/prompt-history/:id", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const { id } = request.params; + response.status(200).json({ + success: await Workspace.deletePromptHistory({ + workspaceId: response.locals.workspace.id, + id: Number(id), + }), + }); + } catch (error) { + console.error("Error deleting prompt history:", error); + response.sendStatus(500).end(); + } + } + ); + + /** + * Searches for workspaces and threads by thread name or workspace name. + * Only returns assets owned by the user (if multi-user mode is enabled). + */ + app.post( + "/workspace/search", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { searchTerm } = reqBody(request); + const searchResults = await searchWorkspaceAndThreads( + searchTerm, + response.locals?.user + ); + response.status(200).json(searchResults); + } catch (error) { + console.error("Error searching for workspaces:", error); + response.sendStatus(500).end(); + } + } + ); + + // SSE endpoint for embedding progress + app.get( + "/workspace/:slug/embed-progress", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const workspace = response.locals.workspace; + const { + addSSEConnection, + removeSSEConnection, + } = require("../utils/EmbeddingWorkerManager"); + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + addSSEConnection(workspace.slug, response); + request.on("close", () => { + removeSSEConnection(workspace.slug, response); + }); + } catch (e) { + console.error(e.message, e); + response.status(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/embed-queue", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const workspace = response.locals.workspace; + const { filename } = reqBody(request); + if (!filename) { + response + .status(400) + .json({ success: false, error: "Missing filename" }); + return; + } + + const { removeQueuedFile } = require("../utils/EmbeddingWorkerManager"); + const sent = removeQueuedFile(workspace.slug, filename); + response.status(200).json({ success: sent }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.get( + "/workspace/:slug/is-agent-command-available", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (_, response) => { + try { + response.status(200).json({ + showAgentCommand: await Workspace.isAgentCommandAvailable( + response.locals.workspace + ), + }); + } catch (error) { + console.error("Error checking if agent command is available:", error); + response.status(500).json({ showAgentCommand: true }); + } + } + ); + + // Parsed Files in separate endpoint just to keep the workspace endpoints clean + workspaceParsedFilesEndpoints(app); +} + +module.exports = { workspaceEndpoints }; diff --git a/server/endpoints/workspacesParsedFiles.js b/server/endpoints/workspacesParsedFiles.js new file mode 100644 index 0000000..2c693f9 --- /dev/null +++ b/server/endpoints/workspacesParsedFiles.js @@ -0,0 +1,206 @@ +const { reqBody, multiUserMode, userFromSession } = require("../utils/http"); +const { handleFileUpload } = require("../utils/files/multer"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { Telemetry } = require("../models/telemetry"); +const { + flexUserRoleValid, + ROLES, +} = require("../utils/middleware/multiUserProtected"); +const { EventLogs } = require("../models/eventLogs"); +const { validWorkspaceSlug } = require("../utils/middleware/validWorkspace"); +const { CollectorApi } = require("../utils/collectorApi"); +const { WorkspaceThread } = require("../models/workspaceThread"); +const { WorkspaceParsedFiles } = require("../models/workspaceParsedFiles"); + +function workspaceParsedFilesEndpoints(app) { + if (!app) return; + + app.get( + "/workspace/:slug/parsed-files", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const threadSlug = request.query.threadSlug || null; + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const thread = threadSlug + ? await WorkspaceThread.get({ slug: String(threadSlug) }) + : null; + const { files, contextWindow, currentContextTokenCount } = + await WorkspaceParsedFiles.getContextMetadataAndLimits( + workspace, + thread || null, + multiUserMode(response) ? user : null + ); + + return response + .status(200) + .json({ files, contextWindow, currentContextTokenCount }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/delete-parsed-files", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async function (request, response) { + try { + const { fileIds = [] } = reqBody(request); + if (!fileIds.length) return response.sendStatus(400).end(); + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const success = await WorkspaceParsedFiles.delete({ + id: { + in: fileIds.map((id) => parseInt(id)), + }, + ...(user ? { userId: user.id } : {}), + workspaceId: workspace.id, + }); + return response.status(success ? 200 : 403).end(); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/embed-parsed-file/:fileId", + [ + validatedRequest, + // Embed is still an admin/manager only feature + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async function (request, response) { + const { fileId = null } = request.params; + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + + if (!fileId) return response.sendStatus(400).end(); + const { success, error, document } = + await WorkspaceParsedFiles.moveToDocumentsAndEmbed( + user, + fileId, + workspace + ); + + if (!success) { + return response.status(500).json({ + success: false, + error: error || "Failed to embed file", + }); + } + + await Telemetry.sendTelemetry("document_embedded"); + await EventLogs.logEvent( + "document_embedded", + { + documentName: document?.name || "unknown", + workspaceId: workspace.id, + }, + user?.id + ); + + return response.status(200).json({ + success: true, + error: null, + document, + }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); + + app.post( + "/workspace/:slug/parse", + [ + validatedRequest, + flexUserRoleValid([ROLES.all]), + handleFileUpload, + validWorkspaceSlug, + ], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const Collector = new CollectorApi(); + const { originalname } = request.file; + const processingOnline = await Collector.online(); + + if (!processingOnline) { + return response.status(500).json({ + success: false, + error: `Document processing API is not online. Document ${originalname} will not be parsed.`, + }); + } + + const { success, reason, documents } = + await Collector.parseDocument(originalname); + if (!success || !documents?.[0]) { + return response.status(500).json({ + success: false, + error: reason || "No document returned from collector", + }); + } + + // Get thread ID if we have a slug + const { threadSlug = null } = reqBody(request); + const thread = threadSlug + ? await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + user_id: user?.id || null, + }) + : null; + const files = await Promise.all( + documents.map(async (doc) => { + const metadata = { ...doc }; + // Strip out pageContent + delete metadata.pageContent; + const filename = `${originalname}-${doc.id}.json`; + const { file, error: dbError } = await WorkspaceParsedFiles.create({ + filename, + workspaceId: workspace.id, + userId: user?.id || null, + threadId: thread?.id || null, + metadata: JSON.stringify(metadata), + tokenCountEstimate: doc.token_count_estimate || 0, + }); + + if (dbError) throw new Error(dbError); + return file; + }) + ); + + Collector.log(`Document ${originalname} parsed successfully.`); + await EventLogs.logEvent( + "document_uploaded_to_chat", + { + documentName: originalname, + workspace: workspace.slug, + thread: thread?.name || null, + }, + user?.id + ); + + return response.status(200).json({ + success: true, + error: null, + files, + }); + } catch (e) { + console.error(e.message, e); + return response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { workspaceParsedFilesEndpoints }; diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs new file mode 100644 index 0000000..876f5c1 --- /dev/null +++ b/server/eslint.config.mjs @@ -0,0 +1,38 @@ +import js from "@eslint/js"; +import globals from "globals"; +import { defineConfig } from "eslint/config"; +import pluginPrettier from "eslint-plugin-prettier"; +import configPrettier from "eslint-config-prettier"; +import unusedImports from "eslint-plugin-unused-imports"; + +export default defineConfig([ + { ignores: ["__tests__/**", "**/syncStaticLists.mjs"] }, + { + files: ["**/*.{js,mjs,cjs}"], + plugins: { js, prettier: pluginPrettier, "unused-imports": unusedImports }, + extends: ["js/recommended"], + languageOptions: { globals: { ...globals.node, ...globals.browser } }, + rules: { + ...configPrettier.rules, + "prettier/prettier": "error", + "no-case-declarations": "off", + "no-prototype-builtins": "off", + "no-async-promise-executor": "off", + "no-extra-boolean-cast": "off", + "no-empty": "off", + "no-unused-private-class-members": "warn", + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + }, + }, + { files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } }, +]); diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..db2b6d1 --- /dev/null +++ b/server/index.js @@ -0,0 +1,179 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); + +require("./utils/logger")(); +require("./utils/boot/patchSdkTimeouts")(); +const express = require("express"); +const bodyParser = require("body-parser"); +const cors = require("cors"); +const path = require("path"); +const { reqBody } = require("./utils/http"); +const { systemEndpoints } = require("./endpoints/system"); +const { workspaceEndpoints } = require("./endpoints/workspaces"); +const { chatEndpoints } = require("./endpoints/chat"); +const { embeddedEndpoints } = require("./endpoints/embed"); +const { embedManagementEndpoints } = require("./endpoints/embedManagement"); +const { getVectorDbClass } = require("./utils/helpers"); +const { adminEndpoints } = require("./endpoints/admin"); +const { modelRouterEndpoints } = require("./endpoints/modelRouter"); +const { inviteEndpoints } = require("./endpoints/invite"); +const { utilEndpoints } = require("./endpoints/utils"); +const { developerEndpoints } = require("./endpoints/api"); +const { extensionEndpoints } = require("./endpoints/extensions"); +const { bootHTTP, bootSSL } = require("./utils/boot"); +const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads"); +const { documentEndpoints } = require("./endpoints/document"); +const { agentWebsocket } = require("./endpoints/agentWebsocket"); +const { + agentSkillWhitelistEndpoints, +} = require("./endpoints/agentSkillWhitelist"); +const { agentFileServerEndpoints } = require("./endpoints/agentFileServer"); +const { experimentalEndpoints } = require("./endpoints/experimental"); +const { browserExtensionEndpoints } = require("./endpoints/browserExtension"); +const { communityHubEndpoints } = require("./endpoints/communityHub"); +const { agentFlowEndpoints } = require("./endpoints/agentFlows"); +const { mcpServersEndpoints } = require("./endpoints/mcpServers"); +const { mobileEndpoints } = require("./endpoints/mobile"); +const { webPushEndpoints } = require("./endpoints/webPush"); +const { telegramEndpoints } = require("./endpoints/telegram"); +const { scheduledJobEndpoints } = require("./endpoints/scheduledJobs"); +const { + outlookAgentEndpoints, +} = require("./endpoints/utils/outlookAgentUtils"); +const { + googleAgentSkillEndpoints, +} = require("./endpoints/utils/googleAgentSkillEndpoints"); +const { memoryEndpoints } = require("./endpoints/memory"); +const { httpLogger } = require("./middleware/httpLogger"); +const app = express(); +const apiRouter = express.Router(); +const FILE_LIMIT = "3GB"; + +// Only log HTTP requests in development mode and if the ENABLE_HTTP_LOGGER environment variable is set to true +if ( + process.env.NODE_ENV === "development" && + !!process.env.ENABLE_HTTP_LOGGER +) { + app.use( + httpLogger({ + enableTimestamps: !!process.env.ENABLE_HTTP_LOGGER_TIMESTAMPS, + }) + ); +} +app.use(cors({ origin: true })); +app.use(bodyParser.text({ limit: FILE_LIMIT })); +app.use(bodyParser.json({ limit: FILE_LIMIT })); +app.use( + bodyParser.urlencoded({ + limit: FILE_LIMIT, + extended: true, + }) +); + +if (!!process.env.ENABLE_HTTPS) { + bootSSL(app, process.env.SERVER_PORT || 3001); +} else { + require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode. +} + +app.use("/api", apiRouter); +systemEndpoints(apiRouter); +extensionEndpoints(apiRouter); +workspaceEndpoints(apiRouter); +workspaceThreadEndpoints(apiRouter); +chatEndpoints(apiRouter); +adminEndpoints(apiRouter); +modelRouterEndpoints(apiRouter); +inviteEndpoints(apiRouter); +embedManagementEndpoints(apiRouter); +utilEndpoints(apiRouter); +documentEndpoints(apiRouter); +agentWebsocket(apiRouter); +agentSkillWhitelistEndpoints(apiRouter); +agentFileServerEndpoints(apiRouter); +experimentalEndpoints(apiRouter); +developerEndpoints(app, apiRouter); +communityHubEndpoints(apiRouter); +agentFlowEndpoints(apiRouter); +mcpServersEndpoints(apiRouter); +mobileEndpoints(apiRouter); +webPushEndpoints(apiRouter); +telegramEndpoints(apiRouter); +scheduledJobEndpoints(apiRouter); +outlookAgentEndpoints(apiRouter); +googleAgentSkillEndpoints(apiRouter); +memoryEndpoints(apiRouter); +// Externally facing embedder endpoints +embeddedEndpoints(apiRouter); + +// Externally facing browser extension endpoints +browserExtensionEndpoints(apiRouter); + +if (process.env.NODE_ENV !== "development") { + const { MetaGenerator } = require("./utils/boot/MetaGenerator"); + const IndexPage = new MetaGenerator(); + + app.use( + express.static(path.resolve(__dirname, "public"), { + extensions: ["js"], + setHeaders: (res) => { + // Disable I-framing of entire site UI + res.removeHeader("X-Powered-By"); + res.setHeader("X-Frame-Options", "DENY"); + }, + }) + ); + + app.get("/robots.txt", function (_, response) { + response.type("text/plain"); + response.send("User-agent: *\nDisallow: /").end(); + }); + + app.get("/manifest.json", async function (_, response) { + IndexPage.generateManifest(response); + return; + }); + + app.use("/", function (_, response) { + IndexPage.generate(response); + return; + }); +} else { + // Debug route for development connections to vectorDBs + apiRouter.post("/v/:command", async (request, response) => { + try { + const VectorDb = getVectorDbClass(); + const { command } = request.params; + if (!Object.getOwnPropertyNames(VectorDb).includes(command)) { + response.status(500).json({ + message: "invalid interface command", + commands: Object.getOwnPropertyNames(VectorDb), + }); + return; + } + + try { + const body = reqBody(request); + const resBody = await VectorDb[command](body); + response.status(200).json({ ...resBody }); + } catch (e) { + // console.error(e) + console.error(JSON.stringify(e)); + response.status(500).json({ error: e.message }); + } + return; + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + }); +} + +app.all("*", function (_, response) { + response.sendStatus(404); +}); + +// In non-https mode we need to boot at the end since the server has not yet +// started and is `.listen`ing. +if (!process.env.ENABLE_HTTPS) bootHTTP(app, process.env.SERVER_PORT || 3001); diff --git a/server/jobs/cleanup-generated-files.js b/server/jobs/cleanup-generated-files.js new file mode 100644 index 0000000..64aa7d4 --- /dev/null +++ b/server/jobs/cleanup-generated-files.js @@ -0,0 +1,160 @@ +const { log, conclude } = require("./helpers/index.js"); +const { WorkspaceChats } = require("../models/workspaceChats.js"); +const { ScheduledJobRun } = require("../models/scheduledJobRun.js"); +const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib.js"); +const { safeJsonParse } = require("../utils/http/index.js"); + +(async () => { + try { + const fs = require("fs"); + const path = require("path"); + const storageDirectory = await createFilesLib.getOutputDirectory(); + if (!fs.existsSync(storageDirectory)) return; + + const files = fs.readdirSync(storageDirectory); + if (files.length === 0) return; + + // Get all storage filenames referenced in active (include: true) chats + const activeFileRefs = await getActiveStorageFilenames(); + const filesToDelete = []; + for (const filename of files) { + const fullPath = path.join(storageDirectory, filename); + const stat = fs.statSync(fullPath); + + // Skip files/folders that don't match our naming pattern and add to deletion list + if (!filename.match(/^[a-z]+-[a-f0-9-]{36}(\.\w+)?$/i)) { + filesToDelete.push({ path: fullPath, isDirectory: stat.isDirectory() }); + continue; + } + + // If file/folder is not referenced in any active chat, add to deletion list + if (!activeFileRefs.has(filename)) + filesToDelete.push({ path: fullPath, isDirectory: stat.isDirectory() }); + } + + if (filesToDelete.length === 0) return; + + log(`Found ${filesToDelete.length} orphaned files/folders to delete.`); + let deletedCount = 0; + let failedCount = 0; + for (const { path: itemPath, isDirectory } of filesToDelete) { + try { + if (isDirectory) fs.rmSync(itemPath, { recursive: true }); + else fs.unlinkSync(itemPath); + deletedCount++; + } catch (error) { + failedCount++; + log(`Failed to delete ${itemPath}: ${error.message}`); + } + } + + log( + `Cleanup complete: deleted ${deletedCount} items, ${failedCount} failures.` + ); + } catch (error) { + console.error(error); + log(`Error during cleanup: ${error.message}`); + } finally { + conclude(); + } +})(); + +/** + * Retrieves all storage filenames referenced in active (include: true) workspace chats. + * Searches through the outputs array in chat responses. + * Uses pagination to avoid loading all chats into memory at once. + * @param {number} batchSize - Number of chats to process per batch (default: 50) + * @returns {Promise>} + */ +async function getActiveStorageFilenames(batchSize = 50) { + const [workspaceChats, scheduledJobRuns] = await Promise.all([ + workspaceChatGeneratedFilenames(batchSize), + scheduledJobRunGeneratedFilenames(batchSize), + ]); + + return new Set([...workspaceChats, ...scheduledJobRuns]); +} + +async function workspaceChatGeneratedFilenames(batchSize = 50) { + const storageFilenames = new Set(); + try { + let offset = 0; + let hasMore = true; + + while (hasMore) { + const chats = await WorkspaceChats.where( + { include: true }, + batchSize, + { id: "asc" }, + offset + ); + + if (chats.length === 0) { + hasMore = false; + break; + } + + for (const chat of chats) { + try { + const response = safeJsonParse(chat.response, { outputs: [] }); + for (const output of response.outputs) { + if (!output || !output.payload || !output.payload.storageFilename) + continue; + storageFilenames.add(output.payload.storageFilename); + } + } catch { + continue; + } + } + + offset += chats.length; + hasMore = chats.length === batchSize; + } + } catch (error) { + console.error("[workspaceChatGeneratedFilenames] Error:", error.message); + } + + return storageFilenames; +} + +async function scheduledJobRunGeneratedFilenames(batchSize = 50) { + const storageFilenames = new Set(); + try { + let offset = 0; + let hasMore = true; + + while (hasMore) { + const runs = await ScheduledJobRun.where( + { status: "completed" }, + batchSize, + { id: "asc" }, + {}, + offset + ); + + if (runs.length === 0) { + hasMore = false; + break; + } + + for (const run of runs) { + try { + const response = safeJsonParse(run.result, { outputs: [] }); + for (const output of response.outputs) { + if (!output?.payload?.storageFilename) continue; + storageFilenames.add(output.payload.storageFilename); + } + } catch { + continue; + } + } + + offset += runs.length; + hasMore = runs.length === batchSize; + } + } catch (error) { + console.error("[scheduledJobRunGeneratedFilenames] Error:", error.message); + } + + return storageFilenames; +} diff --git a/server/jobs/cleanup-orphan-documents.js b/server/jobs/cleanup-orphan-documents.js new file mode 100644 index 0000000..b5b3e2a --- /dev/null +++ b/server/jobs/cleanup-orphan-documents.js @@ -0,0 +1,79 @@ +const fs = require("fs"); +const path = require("path"); +const { default: slugify } = require("slugify"); +const { log, conclude } = require("./helpers/index.js"); +const { WorkspaceParsedFiles } = require("../models/workspaceParsedFiles.js"); +const { directUploadsPath } = require("../utils/files"); + +async function batchDeleteFiles(filesToDelete, batchSize = 500) { + let deletedCount = 0; + let failedCount = 0; + + for (let i = 0; i < filesToDelete.length; i += batchSize) { + const batch = filesToDelete.slice(i, i + batchSize); + + try { + await Promise.all( + batch.map((filePath) => { + if (!fs.existsSync(filePath)) return; + if (fs.lstatSync(filePath).isDirectory()) + fs.rmSync(filePath, { recursive: true }); + else fs.unlinkSync(filePath); + }) + ); + deletedCount += batch.length; + + log( + `Deleted batch ${Math.floor(i / batchSize) + 1}: ${batch.length} files` + ); + } catch { + // If batch fails, try individual files sync + for (const filePath of batch) { + try { + fs.unlinkSync(filePath); + deletedCount++; + } catch (fileErr) { + failedCount++; + log(`Failed to delete ${filePath}: ${fileErr.message}`); + } + } + } + } + + return { deletedCount, failedCount }; +} + +(async () => { + try { + const filesToDelete = []; + const knownFiles = await WorkspaceParsedFiles.where({}, null, null, { + filename: true, + }) + // Slugify the filename to match the direct uploads naming convention otherwise + // files with spaces will not result in a match and will be pruned when attached to a thread. + // This could then result in files showing "Attached" but the model not seeing them during chat. + .then((files) => new Set(files.map((f) => slugify(f.filename)))); + + if (!fs.existsSync(directUploadsPath)) + return log("No direct uploads path found - exiting."); + const filesInDirectUploadsPath = fs.readdirSync(directUploadsPath); + if (filesInDirectUploadsPath.length === 0) return; + + for (let i = 0; i < filesInDirectUploadsPath.length; i++) { + const file = filesInDirectUploadsPath[i]; + if (knownFiles.has(file)) continue; + filesToDelete.push(path.resolve(directUploadsPath, file)); + } + + if (filesToDelete.length === 0) return; // No orphaned files to delete + log(`Found ${filesToDelete.length} orphaned files to delete`); + const { deletedCount, failedCount } = await batchDeleteFiles(filesToDelete); + log(`Deleted ${deletedCount} orphaned files`); + if (failedCount > 0) log(`Failed to delete ${failedCount} files`); + } catch (e) { + console.error(e); + log(`errored with ${e.message}`); + } finally { + conclude(); + } +})(); diff --git a/server/jobs/embedding-worker.js b/server/jobs/embedding-worker.js new file mode 100644 index 0000000..29f45ff --- /dev/null +++ b/server/jobs/embedding-worker.js @@ -0,0 +1,199 @@ +/** + * Embedding Worker + * + * Runs the full document-embedding loop in an isolated child process so that + * OOM from the native embedding model only kills this worker, not the main server. + * + * Spawned on-demand by EmbeddingWorkerManager via BackgroundService/Bree. + * Processes files sequentially and accepts additional files mid-run. + * + * IPC protocol (receives from parent): + * { type: "embed", files, workspaceSlug, workspaceId, userId } + * { type: "add_files", files } + * + * IPC protocol (sends to parent): + * { type: "batch_starting", workspaceSlug, filenames, totalDocs } + * { type: "doc_starting", workspaceSlug, filename, docIndex, totalDocs } + * { type: "chunk_progress", workspaceSlug, filename, chunksProcessed, totalChunks } + * { type: "doc_complete", workspaceSlug, filename, docIndex, totalDocs } + * { type: "doc_failed", workspaceSlug, filename, error } + * { type: "all_complete", workspaceSlug, embedded, failed } + */ + +const { v4: uuidv4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const { getVectorDbClass } = require("../utils/helpers"); +const { fileData } = require("../utils/files"); +const { Telemetry } = require("../models/telemetry"); + +const queue = []; +const cancelled = new Set(); +let processing = false; +let workspaceSlug = null; +let workspaceId = null; +let userId = null; + +function emit(event) { + try { + process.send({ ...event, silent: true }); + } catch { + // Parent may have disconnected + } +} + +async function processQueue() { + if (processing || queue.length === 0) return; + processing = true; + + const VectorDb = getVectorDbClass(); + const batch = [...queue]; + queue.length = 0; + + emit({ + type: "batch_starting", + workspaceSlug, + userId, + filenames: batch, + totalDocs: batch.length, + }); + + Telemetry.sendTelemetry("documents_embedded_in_workspace").catch(() => {}); + const embedded = []; + const failedToEmbed = []; + + for (const [index, filePath] of batch.entries()) { + if (cancelled.has(filePath)) { + cancelled.delete(filePath); + continue; + } + + const docProgress = { + workspaceSlug, + userId, + filename: filePath, + docIndex: index, + totalDocs: batch.length, + }; + + const data = await fileData(filePath); + if (!data) { + emit({ + type: "doc_failed", + ...docProgress, + error: "Failed to load file data", + }); + failedToEmbed.push(filePath); + continue; + } + + const docId = uuidv4(); + const { pageContent: _pageContent, ...metadata } = data; + const newDoc = { + docId, + filename: filePath.split(/[/\\]/).pop(), + docpath: filePath, + workspaceId, + metadata: JSON.stringify(metadata), + }; + + emit({ + type: "doc_starting", + ...docProgress, + }); + + // Set context so NativeEmbedder can send chunk_progress IPC messages + // enriched with workspace/file info (read via process.send in embedChunks). + global.__embeddingProgress = { workspaceSlug, filename: filePath, userId }; + + const { vectorized, error } = await VectorDb.addDocumentToNamespace( + workspaceSlug, + { ...data, docId }, + filePath + ); + + if (!vectorized) { + console.error("Failed to vectorize", metadata?.title || newDoc.filename); + failedToEmbed.push(metadata?.title || newDoc.filename); + emit({ + type: "doc_failed", + ...docProgress, + error: error || "Unknown error", + }); + continue; + } + + try { + await prisma.workspace_documents.create({ data: newDoc }); + embedded.push(filePath); + emit({ + type: "doc_complete", + ...docProgress, + }); + } catch (err) { + console.error(err.message); + emit({ + type: "doc_failed", + ...docProgress, + error: "Failed to save document record", + }); + } + } + + processing = false; + + // If new files were added while we were processing, recurse. + if (queue.length > 0) { + await processQueue(); + return; + } + + emit({ + type: "all_complete", + workspaceSlug, + userId, + totalDocs: batch.length, + embedded: embedded.length, + failed: failedToEmbed.length, + embeddedFiles: embedded, + failedFiles: failedToEmbed, + }); + process.exit(0); +} + +process.on("message", async (msg) => { + if (!msg || !msg.type) return; + + if (msg.type === "embed") { + workspaceSlug = msg.workspaceSlug; + workspaceId = msg.workspaceId; + userId = msg.userId; + queue.push(...msg.files); + processQueue().catch((err) => { + console.error("[embedding-worker] Fatal error:", err); + process.exit(1); + }); + } + + if (msg.type === "add_files") { + queue.push(...msg.files); + // If we're not currently processing (worker is idle between batches), + // kick off processing immediately. + if (!processing) { + processQueue().catch((err) => { + console.error("[embedding-worker] Fatal error:", err); + process.exit(1); + }); + } + } + + if (msg.type === "remove_file") { + const idx = queue.indexOf(msg.filename); + if (idx !== -1) queue.splice(idx, 1); + else cancelled.add(msg.filename); + emit({ + type: "file_removed", + workspaceSlug, + filename: msg.filename, + }); + } +}); diff --git a/server/jobs/extract-memories.js b/server/jobs/extract-memories.js new file mode 100644 index 0000000..3927d53 --- /dev/null +++ b/server/jobs/extract-memories.js @@ -0,0 +1,192 @@ +const { log, conclude } = require("./helpers/index.js"); +const { SystemSettings } = require("../models/systemSettings.js"); +const { Memory } = require("../models/memory.js"); +const { WorkspaceChats } = require("../models/workspaceChats.js"); +const { Workspace } = require("../models/workspace.js"); +const truncate = require("truncate"); +const { + groupByUserWorkspace, + loadLatestChats, + resolveLLM, + buildObserverUserMessage, + runObserver, + buildReflectorUserMessage, + runReflector, +} = require("./helpers/memory-extraction-utils.js"); + +// 20 minutes default; 0 disables the idle check. +const IDLE_THRESHOLD_MS = Number( + process.env.MEMORY_IDLE_THRESHOLD_MS ?? 20 * 60 * 1000 +); + +// Minimum chats required before processing memories for a user/workspace pair. +const MIN_CHATS_TO_PROCESS = 5; + +(async () => { + try { + if (!(await SystemSettings.autoMemoriesEnabled())) { + log("Automatic memory extraction is disabled. Exiting."); + return; + } + + // Discover (user, workspace) groups with unprocessed visible chats. + // include:true filters out chats deleted via /reset — they must never be summarized. + const allUnprocessed = await WorkspaceChats.where( + { memoryProcessed: null, include: true }, + null, + { createdAt: "asc" } + ); + if (allUnprocessed.length === 0) { + log("No unprocessed chats found. Exiting."); + return; + } + + const groups = groupByUserWorkspace(allUnprocessed); + log(`Found ${groups.size} user/workspace pair(s) with unprocessed chats.`); + + for (const group of groups.values()) await processGroup(group); + + log("Memory extraction complete."); + } catch (e) { + console.error(e); + log(`errored with ${e.message}`); + } finally { + conclude(); + } +})(); + +/** + * Process a single (user, workspace) group via two-phase extraction: + * Phase 1 (Observer) — extract candidate facts from conversations + * Phase 2 (Reflector) — classify scope, deduplicate, filter against existing memories + * Then apply the final memories and mark all chats as processed. + * @param {object[]} groupChats - chats for this group, sorted asc. + */ +async function processGroup(groupChats) { + const { user_id: userId, workspaceId } = groupChats[0]; + const tag = `user ${userId}, workspace ${workspaceId}`; + + if (groupChats.length < MIN_CHATS_TO_PROCESS) { + log( + `${tag} has only ${groupChats.length} chat(s). Need at least ${MIN_CHATS_TO_PROCESS}. Skipping.` + ); + return; + } + + if (isGroupActive(groupChats)) { + log(`${tag} is still active. Skipping.`); + return; + } + + const unprocessedIds = groupChats.map((c) => c.id); + try { + const workspace = await Workspace.get({ id: workspaceId }); + if (!workspace) { + log(`Workspace ${workspaceId} not found. Marking processed.`); + return; + } + + const chats = await loadLatestChats(userId, workspaceId); + if (chats.length === 0) { + log(`No summarizable chats for ${tag}. Marking processed.`); + return; + } + + const llm = resolveLLM(workspace); + if (!llm) { + log(`No LLM configured for workspace ${workspaceId}. Marking processed.`); + return; + } + + const workspaceMemories = await Memory.forUserWorkspace( + userId, + workspaceId + ); + const globalMemories = await Memory.globalForUser(userId); + + const globalSlots = Memory.GLOBAL_LIMIT - globalMemories.length; + if ( + globalSlots <= 0 && + workspaceMemories.length >= Memory.WORKSPACE_LIMIT + ) { + log(`${tag} is at max capacity. Skipping.`); + return; + } + + log( + `Running Observer for ${tag} using ${llm.provider}/${llm.model} on ${chats.length} chat(s).` + ); + const observerMessage = buildObserverUserMessage(chats); + const { candidates, rawText: observerRaw } = await runObserver({ + ...llm, + userMessage: observerMessage, + }); + + if (candidates === null || candidates.length === 0) { + log(`Observer produced no candidates for ${tag}.`); + if (observerRaw) + log(`Observer raw response:\n${truncate(observerRaw, 2000)}`); + if (candidates === null) + log( + `(Tool handler was never called — model may not have produced a tool call.)` + ); + return; + } + log(`Observer produced ${candidates.length} candidate(s) for ${tag}:`); + for (const c of candidates) + log(` [${c.confidence}] "${c.content}" — ${c.reasoning}`); + + log(`Running Reflector for ${tag} with ${candidates.length} candidate(s).`); + const reflectorMessage = buildReflectorUserMessage( + candidates, + workspaceMemories, + globalMemories, + globalSlots + ); + const { memories: finalMemories, rawText: reflectorRaw } = + await runReflector({ + ...llm, + userMessage: reflectorMessage, + }); + + if (finalMemories === null || finalMemories.length === 0) { + log(`Reflector produced no actionable memories for ${tag}.`); + if (reflectorRaw) + log(`Reflector raw response:\n${truncate(reflectorRaw, 2000)}`); + if (finalMemories === null) + log( + `(Tool handler was never called — model may not have produced a tool call.)` + ); + return; + } + + log(`Reflector approved ${finalMemories.length} memory/ies for ${tag}:`); + for (const m of finalMemories) + log( + ` [${m.scope}/${m.action}${m.action === "update" ? `#${m.updateId}` : ""}] "${m.content}" — ${m.reasoning}` + ); + + const result = await Memory.applyExtractedMemories( + userId, + workspaceId, + finalMemories, + globalSlots + ); + log( + `Applied ${result.workspaceCount} workspace + ${result.globalCount} global + ${result.updatedCount} updated memories for ${tag} in "${workspace.name}". Reviewed ${chats.length} chat(s).` + ); + } catch (error) { + log(`Error processing ${tag}: ${error.message}`); + } finally { + await WorkspaceChats.markMemoryProcessed(unprocessedIds); + } +} + +/** + * True if the user has chatted too recently for extraction to be safe. + * Chats are sorted asc, so the last element is the most recent. + */ +function isGroupActive(groupChats) { + const last = groupChats[groupChats.length - 1]; + return Date.now() - new Date(last.createdAt).getTime() < IDLE_THRESHOLD_MS; +} diff --git a/server/jobs/handle-telegram-chat.js b/server/jobs/handle-telegram-chat.js new file mode 100644 index 0000000..beccb95 --- /dev/null +++ b/server/jobs/handle-telegram-chat.js @@ -0,0 +1,67 @@ +// Suppress deprecated content-type warning when sending files via the Telegram bot API. +// https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#file-options-metadata +process.env.NTBA_FIX_350 = 1; +const TelegramBot = require("node-telegram-bot-api"); +const { log, conclude } = require("./helpers/index.js"); +const { Workspace } = require("../models/workspace"); +const { WorkspaceThread } = require("../models/workspaceThread"); +const { streamResponse } = require("../utils/telegramBot/chat/stream"); + +process.on("message", async (payload) => { + // Ignore tool approval responses - these are handled by http-socket plugin + if (payload?.type === "toolApprovalResponse") return; + + const { + botToken, + chatId, + workspaceSlug, + threadSlug, + message, + attachments = [], + voiceResponse = false, + } = payload; + + try { + const bot = new TelegramBot(botToken, { polling: false }); + const ctx = { + bot, + log: (text, ...args) => + log(args.length ? `${text} ${args.join(" ")}` : text), + }; + + const workspace = await Workspace.get({ slug: workspaceSlug }); + if (!workspace) { + await bot.sendMessage( + chatId, + "No workspace configured. Use /switch to select one." + ); + conclude(); + return; + } + + const thread = threadSlug + ? await WorkspaceThread.get({ slug: threadSlug }) + : null; + + await streamResponse({ + ctx, + chatId, + workspace, + thread, + message, + attachments, + voiceResponse, + }); + } catch (error) { + log(`Telegram chat error: ${error.message}`); + try { + const bot = new TelegramBot(botToken, { polling: false }); + await bot.sendMessage( + chatId, + "Sorry, something went wrong. Please try again." + ); + } catch {} + } finally { + conclude(); + } +}); diff --git a/server/jobs/helpers/index.js b/server/jobs/helpers/index.js new file mode 100644 index 0000000..4de9ef9 --- /dev/null +++ b/server/jobs/helpers/index.js @@ -0,0 +1,54 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const { parentPort } = require("node:worker_threads"); +const documentsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage/documents`) + : path.resolve(process.env.STORAGE_DIR, `documents`); + +function log(stringContent = "") { + if (parentPort) + parentPort.postMessage(`\x1b[33m[${process.pid}]\x1b[0m: ${stringContent}`); // running as worker + else + process.send( + `\x1b[33m[${process.ppid}:${process.pid}]\x1b[0m: ${stringContent}` + ); // running as child_process +} + +function conclude() { + if (parentPort) parentPort.postMessage("done"); + else process.exit(0); +} + +function updateSourceDocument(docPath = null, jsonContent = {}) { + const destinationFilePath = path.resolve(documentsPath, docPath); + fs.writeFileSync(destinationFilePath, JSON.stringify(jsonContent, null, 4), { + encoding: "utf-8", + }); +} + +/** + * Strips thought/thinking tags from text (e.g., ...) + * Useful for cleaning LLM responses before sending notifications. + * @param {string} text - The text to strip thoughts from. + * @returns {string} - The text with thought tags and their content removed. + */ +const THOUGHT_KEYWORDS = ["thought", "thinking", "think", "thought_chain"]; +const THOUGHT_REGEX_COMPLETE = new RegExp( + THOUGHT_KEYWORDS.map( + (keyword) => + `<${keyword}\\s*(?:[^>]*?)?\\s*>[\\s\\S]*?<\\/${keyword}\\s*(?:[^>]*?)?>` + ).join("|"), + "gi" +); + +function stripThinkingFromText(text = "") { + return text.replace(THOUGHT_REGEX_COMPLETE, "").trim(); +} + +module.exports = { + log, + conclude, + updateSourceDocument, + stripThinkingFromText, +}; diff --git a/server/jobs/helpers/memory-extraction-utils.js b/server/jobs/helpers/memory-extraction-utils.js new file mode 100644 index 0000000..7a1a390 --- /dev/null +++ b/server/jobs/helpers/memory-extraction-utils.js @@ -0,0 +1,366 @@ +const { WorkspaceChats } = require("../../models/workspaceChats.js"); +const { safeJsonParse } = require("../../utils/http/index.js"); +const { getBaseLLMProviderModel } = require("../../utils/helpers/index.js"); +const AIbitat = require("../../utils/agents/aibitat/index.js"); +const truncate = require("truncate"); + +// Cap per-group chat review so a long-dormant user can't trigger a 500-chat summarization. +const CHATS_PER_RUN_LIMIT = 20; + +// Per-message cap — we keep 20 chats of context but truncate each prompt/response +// so a single huge message doesn't blow the LLM's context window. +const MAX_CHARS_PER_MESSAGE = 1500; + +// Hard ceiling on candidates the Observer can return per run. +const MAX_CANDIDATES_PER_RUN = 3; + +const OBSERVER_SYSTEM_PROMPT = `You are a memory observer for a personalized AI assistant. Your job is to identify facts about the user that are worth remembering for future conversations. + +You will be shown recent conversations between a user and an AI assistant. Extract candidate observations — factual statements about the user that would help personalize future responses. + +RULES: +- Maximum ${MAX_CANDIDATES_PER_RUN} observations per batch. +- Each observation must be a single, concise factual statement (1 sentence max). +- Assign a confidence level: "high" for explicitly stated facts, "medium" for strongly implied facts, "low" for inferences. +- If the user shares their name, that is ALWAYS worth extracting. +- If nothing useful is found, return an empty list — but look carefully first. + +WHAT TO EXTRACT: +- The user's name, role, company, or team +- What the user is working on or their goals +- Preferences the user has stated (communication style, tools, formats) +- Expertise areas or technical background +- Constraints or requirements the user mentioned + +WHAT TO SKIP: +- The assistant's opinions or assessments about the user +- Emotional states or motivational commentary +- Generic politeness ("thanks", "sounds good", willingness to continue) +- Information that only the assistant stated, not the user + +When finished you MUST call the submit-observations tool.`; + +const REFLECTOR_SYSTEM_PROMPT = `You are a memory reflector for a personalized AI assistant. You review candidate observations and decide which ones should actually be saved as memories. + +You will be shown: +1. Candidate observations from recent conversations (with confidence levels) +2. All existing GLOBAL memories for this user +3. All existing WORKSPACE memories for this workspace + +Your job: +1. CLASSIFY SCOPE — For each candidate, ask: "Would knowing this help the assistant in a completely different workspace/project?" + - YES → GLOBAL (e.g., "User's name is Tim", "User prefers concise responses", "User is a senior engineer") + - NO → WORKSPACE (e.g., "Working on a math conjectures project", "Prefers visual aids for this project") + +2. DEDUPLICATE — Drop any candidate that overlaps with an existing memory, even if worded differently. Do NOT create near-duplicates. + +3. CONSOLIDATE — If a candidate updates or refines an existing WORKSPACE memory, return it as an "update" action with the ID of the memory to update, rather than creating a new one. GLOBAL memories are never updated — only new ones are appended. + +4. FILTER — Drop "low" confidence candidates unless they are clearly a user identity fact (name, role, company). Drop "medium" confidence candidates unless they are strong persistent preferences. + +When finished you MUST call the save-memories tool. An empty list is correct if all candidates were duplicates or low-value. That is a good outcome.`; + +/** + * Group chats by (user_id, workspaceId). + * @param {object[]} chats + * @returns {Map} + */ +function groupByUserWorkspace(chats) { + const groups = new Map(); + for (const chat of chats) { + const key = `${chat.user_id}:${chat.workspaceId}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(chat); + } + return groups; +} + +/** + * Requery at summarize time so we reflect the current state (new chats, deletions). + * Drop chats that died mid-stream / are pending, then reverse to chronological order. + * @returns {Promise} + */ +async function loadLatestChats(userId, workspaceId) { + const latest = await WorkspaceChats.where( + { user_id: userId, workspaceId, include: true }, + CHATS_PER_RUN_LIMIT, + { createdAt: "desc" } + ); + return latest + .filter((c) => { + const parsed = safeJsonParse(c.response); + return typeof parsed?.text === "string" && parsed.text.length > 0; + }) + .reverse(); +} + +/** + * Pick a provider/model: workspace chat → workspace agent → system default. + * @returns {{provider: string, model: string}|null} + */ +function resolveLLM(workspace) { + if (workspace.chatProvider && workspace.chatModel) + return { provider: workspace.chatProvider, model: workspace.chatModel }; + if (workspace.agentProvider && workspace.agentModel) + return { provider: workspace.agentProvider, model: workspace.agentModel }; + const provider = process.env.LLM_PROVIDER; + const model = provider ? getBaseLLMProviderModel({ provider }) : null; + if (provider && model) return { provider, model }; + return null; +} + +// ── Phase 1: Observer ──────────────────────────────────────────────── + +function buildObserverUserMessage(chats) { + const formattedChats = chats + .map((chat) => { + const lines = [`User: ${truncate(chat.prompt, MAX_CHARS_PER_MESSAGE)}`]; + const parsed = safeJsonParse(chat.response); + if (parsed?.text) + lines.push( + `Assistant: ${truncate(parsed.text, MAX_CHARS_PER_MESSAGE)}` + ); + return lines.join("\n"); + }) + .join("\n\n"); + + return `Recent conversations to analyze:\n${formattedChats}`; +} + +/** + * Phase 1: Observer agent — extracts candidate facts from conversations. + * Returns {candidates, rawText} where candidates is an array or null, + * and rawText is whatever the model said (for debugging when the tool isn't called). + */ +async function runObserver({ provider, model, userMessage }) { + let candidates = null; + let rawText = ""; + const aibitat = new AIbitat({ provider, model, maxRounds: 3 }); + + aibitat.onMessage((msg) => { + if (msg.from === "OBSERVER" && msg.content) rawText = msg.content; + }); + + aibitat + .function({ + name: "submit-observations", + description: + "Submit candidate observations extracted from the conversations. An empty list is correct if nothing worth remembering was found.", + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + observations: { + type: "array", + maxItems: MAX_CANDIDATES_PER_RUN, + items: { + type: "object", + properties: { + content: { + type: "string", + description: + "A single factual statement about the user (1 sentence max).", + }, + confidence: { + type: "string", + enum: ["high", "medium", "low"], + description: + "high = explicitly stated, medium = strongly implied, low = inferred.", + }, + reasoning: { + type: "string", + description: + "Brief justification for why this is worth remembering.", + }, + }, + required: ["content", "confidence", "reasoning"], + additionalProperties: false, + }, + description: `Candidate observations, max ${MAX_CANDIDATES_PER_RUN} items. Empty list is fine.`, + }, + }, + required: ["observations"], + additionalProperties: false, + }, + handler: function (args) { + const { observations } = args; + candidates = Array.isArray(observations) + ? observations + .filter( + (o) => + typeof o === "object" && + o !== null && + typeof o.content === "string" && + o.content.trim().length > 0 && + ["high", "medium", "low"].includes(o.confidence) + ) + .slice(0, MAX_CANDIDATES_PER_RUN) + : null; + aibitat.skipHandleExecution = true; + return "Observations submitted."; + }, + }) + .agent("USER", { role: "Provides conversations for observation." }) + .agent("OBSERVER", { + role: OBSERVER_SYSTEM_PROMPT, + functions: ["submit-observations"], + }); + + await aibitat.start({ + from: "USER", + to: "OBSERVER", + content: userMessage, + }); + + return { candidates, rawText }; +} + +// ── Phase 2: Reflector ─────────────────────────────────────────────── + +function buildReflectorUserMessage( + candidates, + workspaceMemories, + globalMemories, + globalSlots +) { + const formatMemoryList = (memories) => { + if (memories.length === 0) return "None."; + return memories.map((m) => `- [ID: ${m.id}] ${m.content}`).join("\n"); + }; + + const formatCandidates = (obs) => + obs + .map( + (o, i) => + `${i + 1}. "${o.content}" (confidence: ${o.confidence}) — ${o.reasoning}` + ) + .join("\n"); + + const sections = []; + + sections.push( + `Candidate observations from the Observer:\n${formatCandidates(candidates)}` + ); + + sections.push( + `Existing GLOBAL memories:\n${formatMemoryList(globalMemories)}` + ); + if (globalSlots > 0) sections.push(`Available GLOBAL slots: ${globalSlots}`); + else sections.push(`GLOBAL is full — do not return any GLOBAL memories.`); + + sections.push( + `Existing WORKSPACE memories:\n${formatMemoryList(workspaceMemories)}` + ); + + return sections.join("\n\n"); +} + +/** + * Phase 2: Reflector agent — classifies scope, deduplicates, and filters + * candidates against existing memories. + * Returns {memories, rawText} where memories is an array or null. + */ +async function runReflector({ provider, model, userMessage }) { + let finalMemories = null; + let rawText = ""; + const aibitat = new AIbitat({ provider, model, maxRounds: 3 }); + + aibitat.onMessage((msg) => { + if (msg.from === "REFLECTOR" && msg.content) rawText = msg.content; + }); + + aibitat + .function({ + name: "save-memories", + description: + "Save the final list of memories after classification, deduplication, and filtering. An empty list is correct if all candidates were filtered out.", + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + memories: { + type: "array", + maxItems: MAX_CANDIDATES_PER_RUN, + items: { + type: "object", + properties: { + content: { + type: "string", + description: "The memory statement (1 sentence max).", + }, + scope: { + type: "string", + enum: ["WORKSPACE", "GLOBAL"], + description: + "GLOBAL for facts useful across all workspaces, WORKSPACE for project-specific facts.", + }, + action: { + type: "string", + enum: ["create", "update", "skip"], + description: + "create = new memory, update = revise an existing workspace memory, skip = do not save.", + }, + updateId: { + type: "number", + description: + "The ID of the existing WORKSPACE memory to update. Required when action is 'update'.", + }, + reasoning: { + type: "string", + description: + "Brief justification for the scope classification and action decision.", + }, + }, + required: ["content", "scope", "action", "reasoning"], + additionalProperties: false, + }, + description: `Final memories to save, max ${MAX_CANDIDATES_PER_RUN} items. Empty list is fine.`, + }, + }, + required: ["memories"], + additionalProperties: false, + }, + handler: function (args) { + const { memories } = args; + finalMemories = Array.isArray(memories) + ? memories + .filter( + (m) => + typeof m === "object" && + m !== null && + typeof m.content === "string" && + m.content.trim().length > 0 && + ["WORKSPACE", "GLOBAL"].includes(m.scope) && + ["create", "update", "skip"].includes(m.action) + ) + .filter((m) => m.action !== "skip") + .slice(0, MAX_CANDIDATES_PER_RUN) + : null; + aibitat.skipHandleExecution = true; + return "Memories saved."; + }, + }) + .agent("USER", { role: "Provides candidate observations for reflection." }) + .agent("REFLECTOR", { + role: REFLECTOR_SYSTEM_PROMPT, + functions: ["save-memories"], + }); + + await aibitat.start({ + from: "USER", + to: "REFLECTOR", + content: userMessage, + }); + + return { memories: finalMemories, rawText }; +} + +module.exports = { + groupByUserWorkspace, + loadLatestChats, + resolveLLM, + buildObserverUserMessage, + runObserver, + buildReflectorUserMessage, + runReflector, +}; diff --git a/server/jobs/helpers/scheduled-job-helper.js b/server/jobs/helpers/scheduled-job-helper.js new file mode 100644 index 0000000..41d925c --- /dev/null +++ b/server/jobs/helpers/scheduled-job-helper.js @@ -0,0 +1,111 @@ +const { safeJsonParse } = require("../../utils/http"); +const { stripThinkingFromText } = require("./index.js"); + +/** + * Maximum time in milliseconds a scheduled job can run before being terminated. + * @type {number} + */ +const SCHEDULED_JOB_TIMEOUT_MS = + Number(process.env.SCHEDULED_JOB_TIMEOUT_MS) || 5 * 60 * 1000; + +/** + * Create a callback function for the agent action. + * This is for intercepting messages from the agent and storing the results or thoughts, executions, traces, etc. + * @returns {object} + */ +function agentActionCb() { + const thoughts = []; + const toolCalls = []; + + // Use a container object so the reference is preserved when values are updated + const state = { + textResponse: "", + metrics: {}, + }; + + const handler = { + send(jsonStr) { + const data = safeJsonParse(jsonStr, null); + if (!data) return; + + if (data.type === "statusResponse" && data.content) { + thoughts.push(data.content); + return; + } + + if (data.type === "reportStreamEvent" && data.content) { + const inner = data.content; + if (inner.type === "textResponseChunk" && inner.content) + state.textResponse += inner.content; + if (inner.type === "fullTextResponse" && inner.content) + state.textResponse = inner.content; + if (inner.type === "usageMetrics" && inner.metrics) + state.metrics = inner.metrics; + return; + } + + // Final message from agent (onMessage event) + if (data.content && data.from && data.from !== "USER") { + if (!state.textResponse) state.textResponse = data.content; + } + }, + close() {}, + }; + + return { + /** Handler to intercept messages from the agent */ + handler, + /** Thoughts from the agent @type {string[]} */ + thoughts, + /** Tool calls from the agent @type {object[]} */ + toolCalls, + /** State container for textResponse and metrics - access via state.textResponse and state.metrics */ + state, + }; +} + +function truncateNotificationBody(bodyText = "") { + if (!bodyText) return "Job completed"; + if (bodyText.length <= 100) return bodyText; + return bodyText.slice(0, 100) + (bodyText.length > 100 ? "..." : ""); +} + +/** + * Send a web push notification to the primary user. + * @param {object} job - The scheduled job object. + * @param {string} runId - The ID of the scheduled job run. + * @param {string} textResponse - The text response from the agent. + * @param {function} logFn - The function to log the error. + * @returns {Promise} + */ +async function sendWebPushNotification(job, runId, textResponse, logFn) { + try { + const { + pushNotificationService, + } = require("../../utils/PushNotifications/index.js"); + await pushNotificationService.loadSubscriptions(); + + // Strip thinking tags from the text response and then truncate to 100 characters + // if the response is longer than 100 characters. + let notificationBody = stripThinkingFromText(textResponse); + notificationBody = truncateNotificationBody(notificationBody); + await pushNotificationService.sendNotification({ + to: "primary", + payload: { + title: `${job.name} completed`, + body: notificationBody, + data: { + onClickUrl: `/settings/scheduled-jobs/${job.id}/runs/${runId}`, + }, + }, + }); + } catch (pushError) { + logFn(`Failed to send push notification: ${pushError.message}`); + } +} + +module.exports = { + sendWebPushNotification, + SCHEDULED_JOB_TIMEOUT_MS, + agentActionCb, +}; diff --git a/server/jobs/run-scheduled-job.js b/server/jobs/run-scheduled-job.js new file mode 100644 index 0000000..0ddcdab --- /dev/null +++ b/server/jobs/run-scheduled-job.js @@ -0,0 +1,157 @@ +const { log, conclude } = require("./helpers/index.js"); +const { v4: uuidv4 } = require("uuid"); +const { safeJsonParse } = require("../utils/http"); +const { + agentActionCb, + SCHEDULED_JOB_TIMEOUT_MS, + sendWebPushNotification, +} = require("./helpers/scheduled-job-helper.js"); +const { ScheduledJob } = require("../models/scheduledJob.js"); +const { ScheduledJobRun } = require("../models/scheduledJobRun.js"); + +/** Status of the scheduled job run @type {'success' | 'failed' | 'timed_out' | 'not_found' | 'killed' | undefined} */ +let status; +let runId = null; + +process.on("SIGTERM", async () => { + status = "killed"; + log("Received SIGTERM, marking job as killed by user"); + if (runId) await ScheduledJobRun.kill(runId); + conclude(); +}); + +process.on("message", async (payload) => { + const { jobId, runId: payloadRunId } = payload; + runId = payloadRunId; + let timeoutId = null; + let errorMessage = null; + + // The run row was created by the parent process (BackgroundService) in + // status `queued` (it may have been waiting in p-queue). The worker + // transitions it to `running` here so `startedAt` reflects actual execution + // start, then runs to a terminal state. If the job has been deleted between + // enqueue and now, fail the row. + try { + if (!jobId || !runId) return; + + const job = await ScheduledJob.get({ id: Number(jobId) }); + if (!job) { + log(`Scheduled job ${jobId} not found`); + status = "not_found"; + return; + } + + // Transition queued -> running. If this returns false, the row was + // already moved to a terminal state (e.g. parent failed it because it + // thought the worker had died). Bail out without touching it further. + const transitioned = await ScheduledJobRun.markRunning(runId); + if (!transitioned) { + log( + `Scheduled job "${job.name}" (id=${job.id}) is no longer queued, skipping` + ); + return; + } + + log( + `Starting scheduled job: "${job.name}" (id=${job.id}) with timeout ${SCHEDULED_JOB_TIMEOUT_MS}ms` + ); + await ScheduledJob.updateRunTimestamps(job.id); + const { handler, thoughts, toolCalls, state } = agentActionCb(); + + const { EphemeralAgentHandler } = require("../utils/agents/ephemeral.js"); + const agentHandler = await new EphemeralAgentHandler({ + uuid: uuidv4(), + prompt: job.prompt, + }).init(); + + // Tool overrides control which tools the agent can use: + // - Array with items: only those specific tools are loaded + // - Empty array: no tools are loaded + const toolOverrides = safeJsonParse(job.tools, []); + await agentHandler.createAIbitat({ + handler, + toolOverrides, + }); + + // Auto-approve all tool invocations when running a scheduled job + agentHandler.aibitat.requestToolApproval = async () => { + log("Tool approval requested for scheduled job, auto-approving"); + return { + approved: true, + message: "Auto-approved by scheduled job runner.", + }; + }; + + // Capture tool results for the execution trace + agentHandler.aibitat.onToolCallResult( + ({ toolName, arguments: args, result }) => { + toolCalls.push({ + toolName, + arguments: args, + result, + timestamp: Date.now(), + }); + } + ); + + const startTime = Date.now(); + await Promise.race([ + agentHandler.startAgentCluster(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("SCHEDULED_JOB_TIMEOUT")), + SCHEDULED_JOB_TIMEOUT_MS + ); + }), + ]).finally(() => { + if (!timeoutId) return; + clearTimeout(timeoutId); + timeoutId = null; + }); + const duration = Date.now() - startTime; + + // Get outputs from aibitat which include proper type info (e.g., PptxFileDownload, ExcelFileDownload) + // for correct re-rendering when porting to workspace chat + const outputs = agentHandler.getPendingOutputs(); + + status = "success"; + await ScheduledJobRun.complete(runId, { + result: { + text: state.textResponse, + thoughts, + toolCalls, + outputs, + metrics: state.metrics, + duration, + }, + }); + log(`Scheduled job "${job.name}" completed in ${duration}ms)`); + await sendWebPushNotification(job, runId, state.textResponse, log); + } catch (error) { + if (error.message === "SCHEDULED_JOB_TIMEOUT") { + status = "timed_out"; + log("Scheduled job timed out"); + } else { + status = "failed"; + log(`Scheduled job error: ${error.message}`); + errorMessage = error.message; + } + } finally { + switch (status) { + case "not_found": + await ScheduledJobRun.failIfNotTerminal(runId, "Job no longer exists"); + break; + case "timed_out": + await ScheduledJobRun.timeout(runId); + break; + case "failed": + await ScheduledJobRun.fail(runId, { error: errorMessage }); + break; + default: // Do nothing by default (success, killed, other) + break; + } + + if (timeoutId) clearTimeout(timeoutId); + conclude(); + } +}); diff --git a/server/jobs/sync-watched-documents.js b/server/jobs/sync-watched-documents.js new file mode 100644 index 0000000..f4464eb --- /dev/null +++ b/server/jobs/sync-watched-documents.js @@ -0,0 +1,209 @@ +const { Document } = require("../models/documents.js"); +const { DocumentSyncQueue } = require("../models/documentSyncQueue.js"); +const { CollectorApi } = require("../utils/collectorApi"); +const { fileData } = require("../utils/files"); +const { log, conclude, updateSourceDocument } = require("./helpers/index.js"); +const { getVectorDbClass } = require("../utils/helpers/index.js"); +const { DocumentSyncRun } = require("../models/documentSyncRun.js"); + +(async () => { + try { + const queuesToProcess = await DocumentSyncQueue.staleDocumentQueues(); + if (queuesToProcess.length === 0) { + log("No outstanding documents to sync. Exiting."); + return; + } + + const collector = new CollectorApi(); + if (!(await collector.online())) { + log("Could not reach collector API. Exiting."); + return; + } + + log( + `${queuesToProcess.length} watched documents have been found to be stale and will be updated now.` + ); + for (const queue of queuesToProcess) { + let newContent = null; + const document = queue.workspaceDoc; + const workspace = document.workspace; + const { metadata, type, source } = + Document.parseDocumentTypeAndSource(document); + + if (!metadata || !DocumentSyncQueue.validFileTypes.includes(type)) { + // Document is either broken, invalid, or not supported so drop it from future queues. + log( + `Document ${document.filename} has no metadata, is broken, or invalid and has been removed from all future runs.` + ); + await DocumentSyncQueue.unwatch(document); + continue; + } + + if (["link", "youtube"].includes(type)) { + const response = await collector.forwardExtensionRequest({ + endpoint: "/ext/resync-source-document", + method: "POST", + body: JSON.stringify({ + type, + options: { link: source }, + }), + }); + newContent = response?.content; + } + + if (["confluence", "github", "gitlab", "drupalwiki"].includes(type)) { + const response = await collector.forwardExtensionRequest({ + endpoint: "/ext/resync-source-document", + method: "POST", + body: JSON.stringify({ + type, + options: { chunkSource: metadata.chunkSource }, + }), + }); + newContent = response?.content; + } + + if (!newContent) { + // Check if the last "x" runs were all failures (not exits!). If so - remove the job entirely since it is broken. + const failedRunCount = ( + await DocumentSyncRun.where( + { queueId: queue.id }, + DocumentSyncQueue.maxRepeatFailures, + { createdAt: "desc" } + ) + ).filter( + (run) => run.status === DocumentSyncRun.statuses.failed + ).length; + if (failedRunCount >= DocumentSyncQueue.maxRepeatFailures) { + log( + `Document ${document.filename} has failed to refresh ${failedRunCount} times continuously and will now be removed from the watched document set.` + ); + await DocumentSyncQueue.unwatch(document); + continue; + } + + log( + `Failed to get a new content response from collector for source ${source}. Skipping, but will retry next worker interval. Attempt ${failedRunCount === 0 ? 1 : failedRunCount}/${DocumentSyncQueue.maxRepeatFailures}` + ); + await DocumentSyncQueue.saveRun( + queue.id, + DocumentSyncRun.statuses.failed, + { + filename: document.filename, + workspacesModified: [], + reason: "No content found.", + } + ); + continue; + } + + const currentDocumentData = await fileData(document.docpath); + if (currentDocumentData.pageContent === newContent) { + const nextSync = DocumentSyncQueue.calcNextSync(queue); + log( + `Source ${source} is unchanged and will be skipped. Next sync will be ${nextSync.toLocaleString()}.` + ); + await DocumentSyncQueue._update(queue.id, { + lastSyncedAt: new Date().toISOString(), + nextSyncAt: nextSync.toISOString(), + }); + await DocumentSyncQueue.saveRun( + queue.id, + DocumentSyncRun.statuses.exited, + { + filename: document.filename, + workspacesModified: [], + reason: "Content unchanged.", + } + ); + continue; + } + + // update the defined document and workspace vectorDB with the latest information + // it will skip cache and create a new vectorCache file. + const vectorDatabase = getVectorDbClass(); + await vectorDatabase.deleteDocumentFromNamespace( + workspace.slug, + document.docId + ); + await vectorDatabase.addDocumentToNamespace( + workspace.slug, + { + ...currentDocumentData, + pageContent: newContent, + docId: document.docId, + }, + document.docpath, + true + ); + updateSourceDocument(document.docpath, { + ...currentDocumentData, + pageContent: newContent, + docId: document.docId, + published: new Date().toLocaleString(), + // Todo: Update word count and token_estimate? + }); + log( + `Workspace "${workspace.name}" vectors of ${source} updated. Document and vector cache updated.` + ); + + // Now we can bloom the results to all matching documents in all other workspaces + const workspacesModified = [workspace.slug]; + const moreReferences = await Document.where( + { + id: { not: document.id }, + filename: document.filename, + }, + null, + null, + { workspace: true } + ); + + if (moreReferences.length !== 0) { + log( + `${source} is referenced in ${moreReferences.length} other workspaces. Updating those workspaces as well...` + ); + for (const additionalDocumentRef of moreReferences) { + const additionalWorkspace = additionalDocumentRef.workspace; + workspacesModified.push(additionalWorkspace.slug); + + await vectorDatabase.deleteDocumentFromNamespace( + additionalWorkspace.slug, + additionalDocumentRef.docId + ); + await vectorDatabase.addDocumentToNamespace( + additionalWorkspace.slug, + { + ...currentDocumentData, + pageContent: newContent, + docId: additionalDocumentRef.docId, + }, + additionalDocumentRef.docpath + ); + log( + `Workspace "${additionalWorkspace.name}" vectors for ${source} was also updated with the new content from cache.` + ); + } + } + + const nextRefresh = DocumentSyncQueue.calcNextSync(queue); + log( + `${source} has been refreshed in all workspaces it is currently referenced in. Next refresh will be ${nextRefresh.toLocaleString()}.` + ); + await DocumentSyncQueue._update(queue.id, { + lastSyncedAt: new Date().toISOString(), + nextSyncAt: nextRefresh.toISOString(), + }); + await DocumentSyncQueue.saveRun( + queue.id, + DocumentSyncRun.statuses.success, + { filename: document.filename, workspacesModified } + ); + } + } catch (e) { + console.error(e); + log(`errored with ${e.message}`); + } finally { + conclude(); + } +})(); diff --git a/server/jsconfig.json b/server/jsconfig.json new file mode 100644 index 0000000..e3ab1d2 --- /dev/null +++ b/server/jsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020" + }, + "include": [ + "./endpoints/**/*", + "./models/**/*", + "./utils/**/*", + "./swagger/**/*", + "index.js" + ], + "exclude": ["node_modules", "storage"] +} diff --git a/server/middleware/httpLogger.js b/server/middleware/httpLogger.js new file mode 100644 index 0000000..627505f --- /dev/null +++ b/server/middleware/httpLogger.js @@ -0,0 +1,23 @@ +const httpLogger = + ({ enableTimestamps = false }) => + (req, res, next) => { + // Capture the original res.end to log response status + const originalEnd = res.end; + + res.end = function (chunk, encoding) { + // Log the request method, status code, and path + const statusColor = res.statusCode >= 400 ? "\x1b[31m" : "\x1b[32m"; // Red for errors, green for success + console.log( + `\x1b[32m[HTTP]\x1b[0m ${statusColor}${res.statusCode}\x1b[0m ${req.method} -> ${req.path} ${enableTimestamps ? `@ ${new Date().toLocaleTimeString("en-US", { hour12: true })}` : ""}`.trim() + ); + + // Call the original end method + return originalEnd.call(this, chunk, encoding); + }; + + next(); + }; + +module.exports = { + httpLogger, +}; diff --git a/server/models/agentSkillWhitelist.js b/server/models/agentSkillWhitelist.js new file mode 100644 index 0000000..2c2c158 --- /dev/null +++ b/server/models/agentSkillWhitelist.js @@ -0,0 +1,100 @@ +const prisma = require("../utils/prisma"); +const { safeJsonParse } = require("../utils/http"); + +const AgentSkillWhitelist = { + SINGLE_USER_LABEL: "whitelisted_agent_skills", + + /** + * Get the label for storing whitelist in system_settings + * @param {number|null} userId - User ID in multi-user mode, null for single-user + * @returns {string} + */ + _getLabel: function (userId = null) { + if (userId) return `user_${userId}_whitelisted_agent_skills`; + return this.SINGLE_USER_LABEL; + }, + + /** + * Get the whitelisted skills for a user or the system + * @param {number|null} userId - User ID in multi-user mode, null for single-user + * @returns {Promise} Array of whitelisted skill names + */ + get: async function (userId = null) { + try { + const label = this._getLabel(userId); + const setting = await prisma.system_settings.findFirst({ + where: { label }, + }); + return safeJsonParse(setting?.value, []); + } catch (error) { + console.error("AgentSkillWhitelist.get error:", error.message); + return []; + } + }, + + /** + * Add a skill to the whitelist + * @param {string} skillName - The skill name to whitelist + * @param {number|null} userId - User ID in multi-user mode, null for single-user + * @returns {Promise<{success: boolean, error: string|null}>} + */ + add: async function (skillName, userId = null) { + try { + if (!skillName || typeof skillName !== "string") { + return { success: false, error: "Invalid skill name" }; + } + + const label = this._getLabel(userId); + const currentList = await this.get(userId); + + if (currentList.includes(skillName)) { + return { success: true, error: null }; + } + + const newList = [...currentList, skillName]; + + await prisma.system_settings.upsert({ + where: { label }, + update: { value: JSON.stringify(newList) }, + create: { label, value: JSON.stringify(newList) }, + }); + + return { success: true, error: null }; + } catch (error) { + console.error("AgentSkillWhitelist.add error:", error.message); + return { success: false, error: error.message }; + } + }, + + /** + * Check if a skill is whitelisted + * @param {string} skillName - The skill name to check + * @param {number|null} userId - User ID in multi-user mode, null for single-user + * @returns {Promise} + */ + isWhitelisted: async function (skillName, userId = null) { + const whitelist = await this.get(userId); + return whitelist.includes(skillName); + }, + + /** + * Clear the single-user whitelist (used when switching to multi-user mode) + * @returns {Promise<{success: boolean, error: string|null}>} + */ + clearSingleUserWhitelist: async function () { + try { + await prisma.system_settings.deleteMany({ + where: { label: this.SINGLE_USER_LABEL }, + }); + return { success: true, error: null }; + } catch (error) { + console.error( + "AgentSkillWhitelist.clearSingleUserWhitelist error:", + error.message + ); + return { success: false, error: error.message }; + } + }, +}; + +module.exports = { AgentSkillWhitelist }; diff --git a/server/models/apiKeys.js b/server/models/apiKeys.js new file mode 100644 index 0000000..856c73f --- /dev/null +++ b/server/models/apiKeys.js @@ -0,0 +1,99 @@ +const prisma = require("../utils/prisma"); + +const ApiKey = { + tablename: "api_keys", + writable: ["name"], + + makeSecret: () => { + const uuidAPIKey = require("uuid-apikey"); + return uuidAPIKey.create().apiKey; + }, + + create: async function (createdByUserId = null, name = null) { + try { + const normalizedName = + typeof name === "string" && name.trim().length > 0 ? name.trim() : null; + const apiKey = await prisma.api_keys.create({ + data: { + name: normalizedName, + secret: this.makeSecret(), + createdBy: createdByUserId, + }, + }); + + return { apiKey, error: null }; + } catch (error) { + console.error("FAILED TO CREATE API KEY.", error.message); + return { apiKey: null, error: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const apiKey = await prisma.api_keys.findFirst({ where: clause }); + return apiKey; + } catch (error) { + console.error("FAILED TO GET API KEY.", error.message); + return null; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.api_keys.count({ where: clause }); + return count; + } catch (error) { + console.error("FAILED TO COUNT API KEYS.", error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.api_keys.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE API KEY.", error.message); + return false; + } + }, + + where: async function (clause = {}, limit) { + try { + const apiKeys = await prisma.api_keys.findMany({ + where: clause, + take: limit, + }); + return apiKeys; + } catch (error) { + console.error("FAILED TO GET API KEYS.", error.message); + return []; + } + }, + + whereWithUser: async function (clause = {}, limit) { + try { + const { User } = require("./user"); + const apiKeys = await this.where(clause, limit); + + for (const apiKey of apiKeys) { + if (!apiKey.createdBy) continue; + const user = await User.get({ id: apiKey.createdBy }); + if (!user) continue; + + apiKey.createdBy = { + id: user.id, + username: user.username, + role: user.role, + }; + } + + return apiKeys; + } catch (error) { + console.error("FAILED TO GET API KEYS WITH USER.", error.message); + return []; + } + }, +}; + +module.exports = { ApiKey }; diff --git a/server/models/browserExtensionApiKey.js b/server/models/browserExtensionApiKey.js new file mode 100644 index 0000000..915804f --- /dev/null +++ b/server/models/browserExtensionApiKey.js @@ -0,0 +1,190 @@ +const prisma = require("../utils/prisma"); +const { SystemSettings } = require("./systemSettings"); +const { ROLES } = require("../utils/middleware/multiUserProtected"); + +const BrowserExtensionApiKey = { + /** + * Creates a new secret for a browser extension API key. + * @returns {string} brx-*** API key to use with extension + */ + makeSecret: () => { + const uuidAPIKey = require("uuid-apikey"); + return `brx-${uuidAPIKey.create().apiKey}`; + }, + + /** + * Creates a new api key for the browser Extension + * @param {number|null} userId - User id to associate creation of key with. + * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|null, error:string|null}>} + */ + create: async function (userId = null) { + try { + const apiKey = await prisma.browser_extension_api_keys.create({ + data: { + key: this.makeSecret(), + user_id: userId, + }, + }); + return { apiKey, error: null }; + } catch (error) { + console.error("Failed to create browser extension API key", error); + return { apiKey: null, error: error.message }; + } + }, + + /** + * Validated existing API key + * @param {string} key + * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>} + */ + validate: async function (key) { + if (!key.startsWith("brx-")) return false; + const apiKey = await prisma.browser_extension_api_keys.findUnique({ + where: { key: key.toString() }, + include: { user: true }, + }); + if (!apiKey) return false; + + const multiUserMode = await SystemSettings.isMultiUserMode(); + if (!multiUserMode) return apiKey; // In single-user mode, all keys are valid + + // In multi-user mode, check if the key is associated with a user + return apiKey.user_id ? apiKey : false; + }, + + /** + * Fetches browser api key by params. + * @param {object} clause - Prisma props for search + * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>} + */ + get: async function (clause = {}) { + try { + const apiKey = await prisma.browser_extension_api_keys.findFirst({ + where: clause, + }); + return apiKey; + } catch (error) { + console.error("FAILED TO GET BROWSER EXTENSION API KEY.", error.message); + return null; + } + }, + + /** + * Deletes browser api key by db id. + * @param {number} id - database id of browser key + * @returns {Promise<{success: boolean, error:string|null}>} + */ + delete: async function (id) { + try { + await prisma.browser_extension_api_keys.delete({ + where: { id: parseInt(id) }, + }); + return { success: true, error: null }; + } catch (error) { + console.error("Failed to delete browser extension API key", error); + return { success: false, error: error.message }; + } + }, + + /** + * Deletes all browser extension API keys for a user. + * Should be called when a user is deleted to revoke all their keys. + * @param {number} userId - The user ID whose keys should be deleted + * @returns {Promise<{success: boolean, error: string|null}>} + */ + deleteAllForUser: async function (userId) { + try { + if (!userId) return { success: false, error: "User ID is required" }; + await prisma.browser_extension_api_keys.deleteMany({ + where: { user_id: parseInt(userId) }, + }); + return { success: true, error: null }; + } catch (error) { + console.error( + "Failed to delete browser extension API keys for user", + error + ); + return { success: false, error: error.message }; + } + }, + + /** + * Gets browser keys by params + * @param {object} clause + * @param {number|null} limit + * @param {object|null} orderBy + * @returns {Promise} + */ + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const apiKeys = await prisma.browser_extension_api_keys.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + include: { user: true }, + }); + return apiKeys; + } catch (error) { + console.error("FAILED TO GET BROWSER EXTENSION API KEYS.", error.message); + return []; + } + }, + + /** + * Get browser API keys for user + * @param {import("@prisma/client").users} user + * @param {object} clause + * @param {number|null} limit + * @param {object|null} orderBy + * @returns {Promise} + */ + whereWithUser: async function ( + user, + clause = {}, + limit = null, + orderBy = null + ) { + // Admin can view and use any keys + if ([ROLES.admin].includes(user.role)) + return await this.where(clause, limit, orderBy); + + try { + const apiKeys = await prisma.browser_extension_api_keys.findMany({ + where: { + ...clause, + user_id: user.id, + }, + include: { user: true }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return apiKeys; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Updates owner of all DB ids to new admin. + * @param {number} userId + * @returns {Promise} + */ + migrateApiKeysToMultiUser: async function (userId) { + try { + await prisma.browser_extension_api_keys.updateMany({ + where: { + user_id: null, + }, + data: { + user_id: userId, + }, + }); + console.log("Successfully migrated API keys to multi-user mode"); + } catch (error) { + console.error("Error migrating API keys to multi-user mode:", error); + } + }, +}; + +module.exports = { BrowserExtensionApiKey }; diff --git a/server/models/cacheData.js b/server/models/cacheData.js new file mode 100644 index 0000000..43c281d --- /dev/null +++ b/server/models/cacheData.js @@ -0,0 +1,69 @@ +const prisma = require("../utils/prisma"); + +const CacheData = { + new: async function (inputs = {}) { + try { + const cache = await prisma.cache_data.create({ + data: inputs, + }); + return { cache, message: null }; + } catch (error) { + console.error(error.message); + return { cache: null, message: error.message }; + } + }, + + get: async function (clause = {}, limit = null, orderBy = null) { + try { + const cache = await prisma.cache_data.findFirst({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return cache || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.cache_data.deleteMany({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const caches = await prisma.cache_data.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return caches; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.cache_data.count({ + where: clause, + }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, +}; + +module.exports = { CacheData }; diff --git a/server/models/communityHub.js b/server/models/communityHub.js new file mode 100644 index 0000000..e9e6695 --- /dev/null +++ b/server/models/communityHub.js @@ -0,0 +1,213 @@ +const ImportedPlugin = require("../utils/agents/imported"); + +/** + * An interface to the AnythingLLM Community Hub external API. + */ +const CommunityHub = { + importPrefix: "allm-community-id", + apiBase: + process.env.NODE_ENV === "development" + ? "http://127.0.0.1:5001/anythingllm-hub/us-central1/external/v1" + : "https://hub.external.anythingllm.com/v1", + supportedStaticItemTypes: ["system-prompt", "agent-flow", "slash-command"], + + /** + * Validate an import ID and return the entity type and ID. + * @param {string} importId - The import ID to validate. + * @returns {{entityType: string | null, entityId: string | null}} + */ + validateImportId: function (importId) { + if ( + !importId || + !importId.startsWith(this.importPrefix) || + importId.split(":").length !== 3 + ) + return { entityType: null, entityId: null }; + const [_, entityType, entityId] = importId.split(":"); + if (!entityType || !entityId) return { entityType: null, entityId: null }; + return { + entityType: String(entityType).trim(), + entityId: String(entityId).trim(), + }; + }, + + /** + * Fetch the explore items from the community hub that are publicly available. + * @returns {Promise<{agentSkills: {items: [], hasMore: boolean, totalCount: number}, systemPrompts: {items: [], hasMore: boolean, totalCount: number}, slashCommands: {items: [], hasMore: boolean, totalCount: number}}>} + */ + fetchExploreItems: async function () { + return await fetch(`${this.apiBase}/explore`, { + method: "GET", + }) + .then((response) => response.json()) + .catch((error) => { + console.error("Error fetching explore items:", error); + return { + agentSkills: { + items: [], + hasMore: false, + totalCount: 0, + }, + systemPrompts: { + items: [], + hasMore: false, + totalCount: 0, + }, + slashCommands: { + items: [], + hasMore: false, + totalCount: 0, + }, + }; + }); + }, + + /** + * Fetch a bundle item from the community hub. + * Bundle items are entities that require a downloadURL to be fetched from the community hub. + * so we can unzip and import them to the AnythingLLM instance. + * @param {string} importId - The import ID of the item. + * @returns {Promise<{url: string | null, item: object | null, error: string | null}>} + */ + getBundleItem: async function (importId) { + const { entityType, entityId } = this.validateImportId(importId); + if (!entityType || !entityId) + return { item: null, error: "Invalid import ID" }; + + const { SystemSettings } = require("./systemSettings"); + const { connectionKey } = await SystemSettings.hubSettings(); + const { url, item, error } = await fetch( + `${this.apiBase}/${entityType}/${entityId}/pull`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(connectionKey + ? { Authorization: `Bearer ${connectionKey}` } + : {}), + }, + } + ) + .then((response) => response.json()) + .catch((error) => { + console.error( + `Error fetching bundle item for import ID ${importId}:`, + error + ); + return { url: null, item: null, error: error.message }; + }); + return { url, item, error }; + }, + + /** + * Apply an item to the AnythingLLM instance. Used for simple items like slash commands and system prompts. + * @param {object} item - The item to apply. + * @param {object} options - Additional options for applying the item. + * @param {object|null} options.currentUser - The current user object. + * @returns {Promise<{success: boolean, error: string | null}>} + */ + applyItem: async function (item, options = {}) { + if (!item) return { success: false, error: "Item is required" }; + + if (item.itemType === "system-prompt") { + if (!options?.workspaceSlug) + return { success: false, error: "Workspace slug is required" }; + + const { Workspace } = require("./workspace"); + const workspace = await Workspace.get({ + slug: String(options.workspaceSlug), + }); + if (!workspace) return { success: false, error: "Workspace not found" }; + await Workspace.update(workspace.id, { openAiPrompt: item.prompt }); + return { success: true, error: null }; + } + + if (item.itemType === "slash-command") { + const { SlashCommandPresets } = require("./slashCommandsPresets"); + await SlashCommandPresets.create(options?.currentUser?.id, { + command: SlashCommandPresets.formatCommand(String(item.command)), + prompt: String(item.prompt), + description: String(item.description), + }); + return { success: true, error: null }; + } + + return { + success: false, + error: "Unsupported item type. Nothing to apply.", + }; + }, + + /** + * Import a bundle item to the AnythingLLM instance by downloading the zip file and importing it. + * or whatever the item type requires. + * @param {{url: string, item: object}} params + * @returns {Promise<{success: boolean, error: string | null}>} + */ + importBundleItem: async function ({ url, item }) { + if (item.itemType === "agent-skill") { + const { success, error } = + await ImportedPlugin.importCommunityItemFromUrl(url, item); + return { success, error }; + } + + return { + success: false, + error: "Unsupported item type. Nothing to import.", + }; + }, + + fetchUserItems: async function (connectionKey) { + if (!connectionKey) return { createdByMe: {}, teamItems: [] }; + + return await fetch(`${this.apiBase}/items`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${connectionKey}`, + }, + }) + .then((response) => response.json()) + .catch((error) => { + console.error("Error fetching user items:", error); + return { createdByMe: {}, teamItems: [] }; + }); + }, + + /** + * Create a new item in the community hub - Only supports STATIC items for now. + * @param {string} itemType - The type of item to create + * @param {object} data - The item data + * @param {string} connectionKey - The hub connection key + * @returns {Promise<{success: boolean, error: string | null}>} + */ + createStaticItem: async function (itemType, data, connectionKey) { + if (!connectionKey) + return { success: false, error: "Connection key is required" }; + if (!this.supportedStaticItemTypes.includes(itemType)) + return { success: false, error: "Unsupported item type" }; + + // If the item has special considerations or preprocessing, we can delegate that below before sending the request. + // eg: Agent flow files and such. + + return await fetch(`${this.apiBase}/${itemType}/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${connectionKey}`, + }, + body: JSON.stringify(data), + }) + .then((response) => response.json()) + .then((result) => { + if (!!result.error) throw new Error(result.error || "Unknown error"); + return { success: true, error: null, itemId: result.item.id }; + }) + .catch((error) => { + console.error(`Error creating ${itemType}:`, error); + return { success: false, error: error.message }; + }); + }, +}; + +module.exports = { CommunityHub }; diff --git a/server/models/documentSyncQueue.js b/server/models/documentSyncQueue.js new file mode 100644 index 0000000..1828978 --- /dev/null +++ b/server/models/documentSyncQueue.js @@ -0,0 +1,272 @@ +const { BackgroundService } = require("../utils/BackgroundWorkers"); +const prisma = require("../utils/prisma"); +const { SystemSettings } = require("./systemSettings"); +const { Telemetry } = require("./telemetry"); + +/** + * @typedef {('link'|'youtube'|'confluence'|'github'|'gitlab')} validFileType + */ + +const DocumentSyncQueue = { + featureKey: "experimental_live_file_sync", + // update the validFileTypes and .canWatch properties when adding elements here. + validFileTypes: [ + "link", + "youtube", + "confluence", + "github", + "gitlab", + "drupalwiki", + ], + /** + * The default time (in milliseconds) a watched document waits before it is + * considered "stale" and re-synced by the background worker. + * + * Defaults to 7 days but can be overridden via the + * `DOCUMENT_SYNC_STALE_AFTER_MS` environment variable. A minimum of 1 hour is + * enforced to avoid overloading embedders by re-syncing documents too + * frequently. Invalid or non-positive values fall back to the default. + * @returns {number} - the stale-after time in milliseconds + */ + get defaultStaleAfter() { + const DEFAULT_STALE_AFTER = 604800000; // 7 days in MS + const MIN_STALE_AFTER = 3600000; // 1 hour in MS + const envValue = Number(process.env.DOCUMENT_SYNC_STALE_AFTER_MS); + if (isNaN(envValue) || envValue <= 0) return DEFAULT_STALE_AFTER; + return Math.max(envValue, MIN_STALE_AFTER); + }, + maxRepeatFailures: 5, // How many times a run can fail in a row before pruning. + writable: [], + + bootWorkers: function () { + new BackgroundService().boot(); + }, + + killWorkers: function () { + new BackgroundService().stop(); + }, + + /** Check is the Document Sync/Watch feature is enabled and can be used. */ + enabled: async function () { + return ( + (await SystemSettings.get({ label: this.featureKey }))?.value === + "enabled" + ); + }, + + /** + * @param {import("@prisma/client").document_sync_queues} queueRecord - queue record to calculate for + */ + calcNextSync: function (queueRecord) { + return new Date(Number(new Date()) + queueRecord.staleAfterMs); + }, + + /** + * Check if the document can be watched based on the metadata fields + * @param {object} metadata - metadata to check + * @param {string} metadata.title - title of the document + * @param {string} metadata.chunkSource - chunk source of the document + * @returns {boolean} - true if the document can be watched, false otherwise + */ + canWatch: function ({ title, chunkSource = null } = {}) { + if (!chunkSource) return false; + + if (chunkSource.startsWith("link://") && title.endsWith(".html")) + return true; // If is web-link material (prior to feature most chunkSources were links://) + if (chunkSource.startsWith("youtube://")) return true; // If is a youtube link + if (chunkSource.startsWith("confluence://")) return true; // If is a confluence document link + if (chunkSource.startsWith("github://")) return true; // If is a GitHub file reference + if (chunkSource.startsWith("gitlab://")) return true; // If is a GitLab file reference + if (chunkSource.startsWith("drupalwiki://")) return true; // If is a DrupalWiki document link + return false; + }, + + /** + * Creates Queue record and updates document watch status to true on Document record + * @param {import("@prisma/client").workspace_documents} document - document record to watch, must have `id` + */ + watch: async function (document = null) { + if (!document) return false; + try { + const { Document } = require("./documents"); + + // Get all documents that are watched and share the same unique filename. If this value is + // non-zero then we exit early so that we do not have duplicated watch queues for the same file + // across many workspaces. + const workspaceDocIds = ( + await Document.where({ filename: document.filename, watched: true }) + ).map((rec) => rec.id); + const hasRecords = + (await this.count({ workspaceDocId: { in: workspaceDocIds } })) > 0; + if (hasRecords) + throw new Error( + `Cannot watch this document again - it already has a queue set.` + ); + + const queue = await prisma.document_sync_queues.create({ + data: { + workspaceDocId: document.id, + staleAfterMs: this.defaultStaleAfter, + nextSyncAt: new Date(Number(new Date()) + this.defaultStaleAfter), + }, + }); + await Document._updateAll( + { filename: document.filename }, + { watched: true } + ); + return queue || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + /** + * Deletes Queue record and updates document watch status to false on Document record + * @param {import("@prisma/client").workspace_documents} document - document record to unwatch, must have `id` + */ + unwatch: async function (document = null) { + if (!document) return false; + try { + const { Document } = require("./documents"); + + // We could have been given a document to unwatch which is a clone of one that is already being watched but by another workspaceDocument id. + // so in this instance we need to delete any queues related to this document by any WorkspaceDocumentId it is referenced by. + const workspaceDocIds = ( + await Document.where({ filename: document.filename, watched: true }) + ).map((rec) => rec.id); + await this.delete({ workspaceDocId: { in: workspaceDocIds } }); + await Document._updateAll( + { filename: document.filename }, + { watched: false } + ); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + _update: async function (id = null, data = {}) { + if (!id) throw new Error("No id provided for update"); + + try { + await prisma.document_sync_queues.update({ + where: { id }, + data, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + get: async function (clause = {}) { + try { + const queue = await prisma.document_sync_queues.findFirst({ + where: clause, + }); + return queue || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = {} + ) { + try { + const results = await prisma.document_sync_queues.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}, limit = null) { + try { + const count = await prisma.document_sync_queues.count({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + }); + return count; + } catch (error) { + console.error("FAILED TO COUNT DOCUMENTS.", error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.document_sync_queues.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Gets the "stale" queues where the queue's nextSyncAt is less than the current time + * @returns {Promise<( + * import("@prisma/client").document_sync_queues & + * { workspaceDoc: import("@prisma/client").workspace_documents & + * { workspace: import("@prisma/client").workspaces } + * })[]}>} + */ + staleDocumentQueues: async function () { + const queues = await this.where( + { + nextSyncAt: { + lte: new Date().toISOString(), + }, + }, + null, + null, + { + workspaceDoc: { + include: { + workspace: true, + }, + }, + } + ); + return queues; + }, + + saveRun: async function (queueId = null, status = null, result = {}) { + const { DocumentSyncRun } = require("./documentSyncRun"); + return DocumentSyncRun.save(queueId, status, result); + }, + + /** + * Updates document to be watched/unwatched & creates or deletes any queue records and updated Document record `watched` status + * @param {import("@prisma/client").workspace_documents} documentRecord + * @param {boolean} watchStatus - indicate if queue record should be created or not. + * @returns + */ + toggleWatchStatus: async function (documentRecord, watchStatus = false) { + if (!watchStatus) { + await Telemetry.sendTelemetry("document_unwatched"); + await this.unwatch(documentRecord); + return; + } + + await this.watch(documentRecord); + await Telemetry.sendTelemetry("document_watched"); + return; + }, +}; + +module.exports = { DocumentSyncQueue }; diff --git a/server/models/documentSyncRun.js b/server/models/documentSyncRun.js new file mode 100644 index 0000000..94fcf3f --- /dev/null +++ b/server/models/documentSyncRun.js @@ -0,0 +1,88 @@ +const prisma = require("../utils/prisma"); +const DocumentSyncRun = { + statuses: { + unknown: "unknown", + exited: "exited", + failed: "failed", + success: "success", + }, + + save: async function (queueId = null, status = null, result = {}) { + try { + if (!this.statuses.hasOwnProperty(status)) + throw new Error( + `DocumentSyncRun status ${status} is not a valid status.` + ); + + const run = await prisma.document_sync_executions.create({ + data: { + queueId: Number(queueId), + status: String(status), + result: JSON.stringify(result), + }, + }); + return run || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + get: async function (clause = {}) { + try { + const queue = await prisma.document_sync_executions.findFirst({ + where: clause, + }); + return queue || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = {} + ) { + try { + const results = await prisma.document_sync_executions.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}, limit = null, orderBy = {}) { + try { + const count = await prisma.document_sync_executions.count({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return count; + } catch (error) { + console.error("FAILED TO COUNT DOCUMENTS.", error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.document_sync_executions.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, +}; + +module.exports = { DocumentSyncRun }; diff --git a/server/models/documents.js b/server/models/documents.js new file mode 100644 index 0000000..27e28ea --- /dev/null +++ b/server/models/documents.js @@ -0,0 +1,364 @@ +const { v4: uuidv4 } = require("uuid"); +const { getVectorDbClass } = require("../utils/helpers"); +const prisma = require("../utils/prisma"); +const { Telemetry } = require("./telemetry"); +const { EventLogs } = require("./eventLogs"); +const { safeJsonParse } = require("../utils/http"); +const { getModelTag } = require("../endpoints/utils"); + +const Document = { + writable: ["pinned", "watched", "lastUpdatedAt"], + /** + * @param {import("@prisma/client").workspace_documents} document - Document PrismaRecord + * @returns {{ + * metadata: (null|object), + * type: import("./documentSyncQueue.js").validFileType, + * source: string + * }} + */ + parseDocumentTypeAndSource: function (document) { + const metadata = safeJsonParse(document.metadata, null); + if (!metadata) return { metadata: null, type: null, source: null }; + + // Parse the correct type of source and its original source path. + const idx = metadata.chunkSource.indexOf("://"); + const [type, source] = [ + metadata.chunkSource.slice(0, idx), + metadata.chunkSource.slice(idx + 3), + ]; + return { metadata, type, source: this._stripSource(source, type) }; + }, + + forWorkspace: async function (workspaceId = null) { + if (!workspaceId) return []; + return await prisma.workspace_documents.findMany({ + where: { workspaceId }, + }); + }, + + delete: async function (clause = {}) { + try { + await prisma.workspace_documents.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + get: async function (clause = {}) { + try { + const document = await prisma.workspace_documents.findFirst({ + where: clause, + }); + return document || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = null, + select = null + ) { + try { + const results = await prisma.workspace_documents.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), + ...(select !== null ? { select: { ...select } } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + addDocuments: async function (workspace, additions = [], userId = null) { + const VectorDb = getVectorDbClass(); + if (additions.length === 0) return { failed: [], embedded: [] }; + const { fileData } = require("../utils/files"); + const { emitProgress } = require("../utils/EmbeddingWorkerManager"); + const embedded = []; + const failedToEmbed = []; + const errors = new Set(); + + emitProgress(workspace.slug, { + type: "batch_starting", + workspaceSlug: workspace.slug, + userId, + filenames: additions, + totalDocs: additions.length, + }); + + for (const [index, path] of additions.entries()) { + const docProgress = { + workspaceSlug: workspace.slug, + userId, + filename: path, + docIndex: index, + totalDocs: additions.length, + }; + + const data = await fileData(path); + if (!data) { + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: "Failed to load file data", + }); + continue; + } + + const docId = uuidv4(); + const { pageContent: _pageContent, ...metadata } = data; + const newDoc = { + docId, + filename: path.split(/[/\\]/).pop(), + docpath: path, + workspaceId: workspace.id, + metadata: JSON.stringify(metadata), + }; + + emitProgress(workspace.slug, { type: "doc_starting", ...docProgress }); + + global.__embeddingProgress = { + workspaceSlug: workspace.slug, + filename: path, + userId, + }; + + const { vectorized, error } = await VectorDb.addDocumentToNamespace( + workspace.slug, + { ...data, docId }, + path + ); + + if (!vectorized) { + console.error( + "Failed to vectorize", + metadata?.title || newDoc.filename + ); + failedToEmbed.push(metadata?.title || newDoc.filename); + errors.add(error); + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: error || "Unknown error", + }); + continue; + } + + try { + await prisma.workspace_documents.create({ data: newDoc }); + embedded.push(path); + emitProgress(workspace.slug, { + type: "doc_complete", + ...docProgress, + }); + } catch (error) { + console.error(error.message); + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: "Failed to save document record", + }); + } + } + + global.__embeddingProgress = null; + + emitProgress(workspace.slug, { + type: "all_complete", + workspaceSlug: workspace.slug, + userId, + totalDocs: additions.length, + embedded: embedded.length, + failed: failedToEmbed.length, + }); + + await Telemetry.sendTelemetry("documents_embedded_in_workspace", { + LLMSelection: process.env.LLM_PROVIDER || "openai", + Embedder: process.env.EMBEDDING_ENGINE || "inherit", + VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", + LLMModel: getModelTag(), + }); + await EventLogs.logEvent( + "workspace_documents_added", + { + workspaceName: workspace?.name || "Unknown Workspace", + numberOfDocumentsAdded: additions.length, + }, + userId + ); + return { failedToEmbed, errors: Array.from(errors), embedded }; + }, + + removeDocuments: async function (workspace, removals = [], userId = null) { + const VectorDb = getVectorDbClass(); + if (removals.length === 0) return; + + for (const path of removals) { + const document = await this.get({ + docpath: path, + workspaceId: workspace.id, + }); + if (!document) continue; + await VectorDb.deleteDocumentFromNamespace( + workspace.slug, + document.docId + ); + + try { + await prisma.workspace_documents.delete({ + where: { id: document.id, workspaceId: workspace.id }, + }); + await prisma.document_vectors.deleteMany({ + where: { docId: document.docId }, + }); + } catch (error) { + console.error(error.message); + } + } + + await EventLogs.logEvent( + "workspace_documents_removed", + { + workspaceName: workspace?.name || "Unknown Workspace", + numberOfDocuments: removals.length, + }, + userId + ); + return true; + }, + + count: async function (clause = {}, limit = null) { + try { + const count = await prisma.workspace_documents.count({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + }); + return count; + } catch (error) { + console.error("FAILED TO COUNT DOCUMENTS.", error.message); + return 0; + } + }, + update: async function (id = null, data = {}) { + if (!id) throw new Error("No workspace document id provided for update"); + + const validKeys = Object.keys(data).filter((key) => + this.writable.includes(key) + ); + if (validKeys.length === 0) + return { document: { id }, message: "No valid fields to update!" }; + + try { + const document = await prisma.workspace_documents.update({ + where: { id }, + data, + }); + return { document, message: null }; + } catch (error) { + console.error(error.message); + return { document: null, message: error.message }; + } + }, + _updateAll: async function (clause = {}, data = {}) { + try { + await prisma.workspace_documents.updateMany({ + where: clause, + data, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + content: async function (docId) { + if (!docId) throw new Error("No workspace docId provided!"); + const document = await this.get({ docId: String(docId) }); + if (!document) throw new Error(`Could not find a document by id ${docId}`); + + const { fileData } = require("../utils/files"); + const data = await fileData(document.docpath); + return { title: data.title, content: data.pageContent }; + }, + contentByDocPath: async function (docPath) { + const { fileData } = require("../utils/files"); + const data = await fileData(docPath); + return { title: data.title, content: data.pageContent }; + }, + + // Some data sources have encoded params in them we don't want to log - so strip those details. + _stripSource: function (sourceString, type) { + if (["confluence", "github"].includes(type)) { + const _src = new URL(sourceString); + _src.search = ""; // remove all search params that are encoded for resync. + return _src.toString(); + } + + return sourceString; + }, + + /** + * Functions for the backend API endpoints - not to be used by the frontend or elsewhere. + * @namespace api + */ + api: { + /** + * Process a document upload from the API and upsert it into the database. This + * functionality should only be used by the backend /v1/documents/upload endpoints for post-upload embedding. + * @param {string} wsSlugs - The slugs of the workspaces to embed the document into, will be comma-separated list of workspace slugs + * @param {string} docLocation - The location/path of the document that was uploaded + * @returns {Promise} - True if the document was uploaded successfully, false otherwise + */ + uploadToWorkspace: async function (wsSlugs = "", docLocation = null) { + if (!docLocation) + return console.log( + "No document location provided for embedding", + docLocation + ); + + const slugs = wsSlugs + .split(",") + .map((slug) => String(slug)?.trim()?.toLowerCase()); + if (slugs.length === 0) + return console.log(`No workspaces provided got: ${wsSlugs}`); + + const { Workspace } = require("./workspace"); + const workspaces = await Workspace.where({ slug: { in: slugs } }); + if (workspaces.length === 0) + return console.log("No valid workspaces found for slugs: ", slugs); + + // Upsert the document into each workspace - do this sequentially + // because the document may be large and we don't want to overwhelm the embedder, plus on the first + // upsert we will then have the cache of the document - making n+1 embeds faster. If we parallelize this + // we will have to do a lot of extra work to ensure that the document is not embedded more than once. + for (const workspace of workspaces) { + const { failedToEmbed = [], errors = [] } = await Document.addDocuments( + workspace, + [docLocation] + ); + if (failedToEmbed.length > 0) + return console.log( + `Failed to embed document into workspace ${workspace.slug}`, + errors + ); + console.log(`Document embedded into workspace ${workspace.slug}...`); + } + + return true; + }, + }, +}; + +module.exports = { Document }; diff --git a/server/models/embedChats.js b/server/models/embedChats.js new file mode 100644 index 0000000..0d45660 --- /dev/null +++ b/server/models/embedChats.js @@ -0,0 +1,199 @@ +const { safeJsonParse } = require("../utils/http"); +const prisma = require("../utils/prisma"); + +/** + * @typedef {Object} EmbedChat + * @property {number} id + * @property {number} embed_id + * @property {string} prompt + * @property {string} response + * @property {string} connection_information + * @property {string} session_id + * @property {boolean} include + */ + +const EmbedChats = { + new: async function ({ + embedId, + prompt, + response = {}, + connection_information = {}, + sessionId, + }) { + try { + const chat = await prisma.embed_chats.create({ + data: { + prompt, + embed_id: Number(embedId), + response: JSON.stringify(response), + connection_information: JSON.stringify(connection_information), + session_id: String(sessionId), + }, + }); + return { chat, message: null }; + } catch (error) { + console.error(error.message); + return { chat: null, message: error.message }; + } + }, + + /** + * Loops through each chat and filters out the sources from the response object. + * We do this when returning /history of an embed to the frontend to prevent inadvertent leaking + * of private sources the user may not have intended to share with users. + * @param {EmbedChat[]} chats + * @returns {EmbedChat[]} Returns a new array of chats with the sources filtered out of responses + */ + filterSources: function (chats) { + return chats.map((chat) => { + const { response, ...rest } = chat; + const { sources: _sources, ...responseRest } = safeJsonParse(response); + return { ...rest, response: JSON.stringify(responseRest) }; + }); + }, + + /** + * Fetches chats for a given embed and session id. + * @param {number} embedId the id of the embed to fetch chats for + * @param {string} sessionId the id of the session to fetch chats for + * @param {number|null} limit the maximum number of chats to fetch + * @param {string|null} orderBy the order to fetch chats in + * @param {boolean} filterSources whether to filter out the sources from the response (default: false) + * @returns {Promise} Returns an array of chats for the given embed and session + */ + forEmbedByUser: async function ( + embedId = null, + sessionId = null, + limit = null, + orderBy = null, + filterSources = false + ) { + if (!embedId || !sessionId) return []; + + try { + const chats = await prisma.embed_chats.findMany({ + where: { + embed_id: Number(embedId), + session_id: String(sessionId), + include: true, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }), + }); + return filterSources ? this.filterSources(chats) : chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + markHistoryInvalid: async function (embedId = null, sessionId = null) { + if (!embedId || !sessionId) return []; + + try { + await prisma.embed_chats.updateMany({ + where: { + embed_id: Number(embedId), + session_id: String(sessionId), + }, + data: { + include: false, + }, + }); + return; + } catch (error) { + console.error(error.message); + } + }, + + get: async function (clause = {}, limit = null, orderBy = null) { + try { + const chat = await prisma.embed_chats.findFirst({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return chat || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.embed_chats.deleteMany({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + offset = null + ) { + try { + const chats = await prisma.embed_chats.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(offset !== null ? { skip: offset } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithEmbedAndWorkspace: async function ( + clause = {}, + limit = null, + orderBy = null, + offset = null + ) { + try { + const chats = await prisma.embed_chats.findMany({ + where: clause, + include: { + embed_config: { + select: { + workspace: { + select: { + name: true, + }, + }, + }, + }, + }, + ...(limit !== null ? { take: limit } : {}), + ...(offset !== null ? { skip: offset } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.embed_chats.count({ + where: clause, + }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, +}; + +module.exports = { EmbedChats }; diff --git a/server/models/embedConfig.js b/server/models/embedConfig.js new file mode 100644 index 0000000..f0a170c --- /dev/null +++ b/server/models/embedConfig.js @@ -0,0 +1,264 @@ +const { v4 } = require("uuid"); +const prisma = require("../utils/prisma"); +/** + * Valid chat modes for embeds. + * - chat: Chat mode will use the workspace's chat mode. + * - query: Query mode will use the workspace's query mode. + * - automatic: Automatic mode is NOT supported for embeds. + */ +const VALID_CHAT_MODE = ["chat", "query"]; + +const EmbedConfig = { + writable: [ + // Used for generic updates so we can validate keys in request body + "enabled", + "allowlist_domains", + "allow_model_override", + "allow_temperature_override", + "allow_prompt_override", + "max_chats_per_day", + "max_chats_per_session", + "chat_mode", + "workspace_id", + "message_limit", + ], + + new: async function (data, creatorId = null) { + try { + const embed = await prisma.embed_configs.create({ + data: { + uuid: v4(), + enabled: true, + chat_mode: validatedCreationData(data?.chat_mode, "chat_mode"), + allowlist_domains: validatedCreationData( + data?.allowlist_domains, + "allowlist_domains" + ), + allow_model_override: validatedCreationData( + data?.allow_model_override, + "allow_model_override" + ), + allow_temperature_override: validatedCreationData( + data?.allow_temperature_override, + "allow_temperature_override" + ), + allow_prompt_override: validatedCreationData( + data?.allow_prompt_override, + "allow_prompt_override" + ), + max_chats_per_day: validatedCreationData( + data?.max_chats_per_day, + "max_chats_per_day" + ), + max_chats_per_session: validatedCreationData( + data?.max_chats_per_session, + "max_chats_per_session" + ), + message_limit: validatedCreationData( + data?.message_limit, + "message_limit" + ), + createdBy: creatorId != null ? Number(creatorId) : null, + workspace: { + connect: { id: Number(data.workspace_id) }, + }, + }, + }); + + // If the embed was created with no allowed-domains allowlist + // and the EMBED_REQUIRE_ALLOWLIST environment variable is not set, warn the user + // since this would mean the embed will accept requests from ANY origin. + // If the ENV is set, then it would just mean the embed wont respond to requests from ANY origin. + if ( + !embed.allowlist_domains && + !("EMBED_REQUIRE_ALLOWLIST" in process.env) + ) { + console.warn( + `[EmbedConfig] Embed ${embed.uuid} was created with no allowed-domains allowlist; it will accept requests from ANY origin. Set EMBED_REQUIRE_ALLOWLIST="true" to require an allowlist before an embed will respond.` + ); + } + return { embed, message: null }; + } catch (error) { + console.error(error.message); + return { embed: null, message: error.message }; + } + }, + + update: async function (embedId = null, data = {}) { + if (!embedId) throw new Error("No embed id provided for update"); + const validKeys = Object.keys(data).filter((key) => + this.writable.includes(key) + ); + if (validKeys.length === 0) + return { embed: { id: embedId }, message: "No valid fields to update!" }; + + const updates = {}; + validKeys.map((key) => { + updates[key] = validatedCreationData(data[key], key); + }); + + try { + await prisma.embed_configs.update({ + where: { id: Number(embedId) }, + data: updates, + }); + return { success: true, error: null }; + } catch (error) { + console.error(error.message); + return { success: false, error: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const embedConfig = await prisma.embed_configs.findFirst({ + where: clause, + }); + + return embedConfig || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + getWithWorkspace: async function (clause = {}) { + try { + const embedConfig = await prisma.embed_configs.findFirst({ + where: clause, + include: { + workspace: true, + }, + }); + + return embedConfig || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.embed_configs.delete({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const results = await prisma.embed_configs.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithWorkspace: async function ( + clause = {}, + limit = null, + orderBy = null + ) { + try { + const results = await prisma.embed_configs.findMany({ + where: clause, + include: { + workspace: true, + _count: { + select: { embed_chats: true }, + }, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + // Will return null if process should be skipped + // an empty array means the system will check. This + // prevents a bad parse from allowing all requests + parseAllowedHosts: function (embed) { + if (!embed.allowlist_domains) return null; + + try { + return JSON.parse(embed.allowlist_domains); + } catch { + console.error(`Failed to parse allowlist_domains for Embed ${embed.id}!`); + return []; + } + }, +}; + +const BOOLEAN_KEYS = [ + "allow_model_override", + "allow_temperature_override", + "allow_prompt_override", + "enabled", +]; + +const NUMBER_KEYS = [ + "max_chats_per_day", + "max_chats_per_session", + "workspace_id", + "message_limit", +]; + +// Helper to validate a data object strictly into the proper format +function validatedCreationData(value, field) { + if (field === "chat_mode") { + if (!value || !VALID_CHAT_MODE.includes(value)) return "query"; + return value; + } + + if (field === "allowlist_domains") { + try { + if (!value) return null; + return JSON.stringify( + // Iterate and force all domains to URL object + // and stringify the result. + value + .split(",") + .map((input) => { + let url = input; + if (!url.includes("http://") && !url.includes("https://")) + url = `https://${url}`; + try { + new URL(url); + return url; + } catch { + return null; + } + }) + .filter((u) => !!u) + ); + } catch { + return null; + } + } + + if (BOOLEAN_KEYS.includes(field)) { + return value === true || value === false ? value : false; + } + + if (NUMBER_KEYS.includes(field)) { + return isNaN(value) || Number(value) <= 0 ? null : Number(value); + } + + return null; +} + +module.exports = { EmbedConfig }; diff --git a/server/models/eventLogs.js b/server/models/eventLogs.js new file mode 100644 index 0000000..5124043 --- /dev/null +++ b/server/models/eventLogs.js @@ -0,0 +1,129 @@ +const prisma = require("../utils/prisma"); + +const EventLogs = { + logEvent: async function (event, metadata = {}, userId = null) { + try { + const eventLog = await prisma.event_logs.create({ + data: { + event, + metadata: metadata ? JSON.stringify(metadata) : null, + userId: userId ? Number(userId) : null, + occurredAt: new Date(), + }, + }); + console.log(`\x1b[32m[Event Logged]\x1b[0m - ${event}`); + return { eventLog, message: null }; + } catch (error) { + console.error( + `\x1b[31m[Event Logging Failed]\x1b[0m - ${event}`, + error.message + ); + return { eventLog: null, message: error.message }; + } + }, + + getByEvent: async function (event, limit = null, orderBy = null) { + try { + const logs = await prisma.event_logs.findMany({ + where: { event }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { occurredAt: "desc" } }), + }); + return logs; + } catch (error) { + console.error(error.message); + return []; + } + }, + + getByUserId: async function (userId, limit = null, orderBy = null) { + try { + const logs = await prisma.event_logs.findMany({ + where: { userId }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { occurredAt: "desc" } }), + }); + return logs; + } catch (error) { + console.error(error.message); + return []; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + offset = null + ) { + try { + const logs = await prisma.event_logs.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(offset !== null ? { skip: offset } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { occurredAt: "desc" } }), + }); + return logs; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithData: async function ( + clause = {}, + limit = null, + offset = null, + orderBy = null + ) { + const { User } = require("./user"); + + try { + const results = await this.where(clause, limit, orderBy, offset); + + for (const res of results) { + const user = res.userId ? await User.get({ id: res.userId }) : null; + res.user = user + ? { username: user.username } + : { username: "unknown user" }; + } + + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.event_logs.count({ + where: clause, + }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.event_logs.deleteMany({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, +}; + +module.exports = { EventLogs }; diff --git a/server/models/externalCommunicationConnector.js b/server/models/externalCommunicationConnector.js new file mode 100644 index 0000000..21c025f --- /dev/null +++ b/server/models/externalCommunicationConnector.js @@ -0,0 +1,110 @@ +const prisma = require("../utils/prisma"); +const { safeJsonParse } = require("../utils/http"); + +const ExternalCommunicationConnector = { + supportedTypes: ["telegram"], + + /** + * Get a connector by type. + * @param {'telegram'} type + * @returns {Promise<{id: number, type: string, config: object, active: boolean}|null>} + */ + get: async function (type) { + try { + const connector = + await prisma.external_communication_connectors.findUnique({ + where: { type }, + }); + if (!connector) return null; + return { + ...connector, + config: safeJsonParse(connector.config, {}), + }; + } catch (error) { + console.error("ExternalCommunicationConnector.get", error.message); + return null; + } + }, + + /** + * Create or update a connector's config and active state. + * @param {'telegram'} type + * @param {object} config + * @param {boolean} active + * @returns {Promise<{connector: object|null, error: string|null}>} + */ + upsert: async function (type, config = {}) { + if (!this.supportedTypes.includes(type)) + return { connector: null, error: `Unsupported connector type: ${type}` }; + + try { + let update = {}, + create = {}; + + if (config.hasOwnProperty("active")) { + update.active = Boolean(config.active); + create.active = Boolean(config.active); + delete config.active; + } + + update = Object.assign(update, { + config: JSON.stringify(config), + lastUpdatedAt: new Date(), + }); + create = Object.assign(create, { + config: JSON.stringify(config), + type: String(type), + }); + + const connector = await prisma.external_communication_connectors.upsert({ + where: { type: String(type) }, + update, + create, + }); + return { + connector: { + ...connector, + config: safeJsonParse(connector.config, {}), + }, + error: null, + }; + } catch (error) { + console.error("ExternalCommunicationConnector.upsert", error.message); + return { connector: null, error: error.message }; + } + }, + + /** + * Merge partial config updates into an existing connector. + * @param {'telegram'} type + * @param {object} configUpdates - Partial config to merge. + * @returns {Promise<{connector: object|null, error: string|null}>} + */ + updateConfig: async function (type, configUpdates = {}) { + const existing = await this.get(type); + if (!existing) + return { connector: null, error: `No ${type} connector found` }; + + const mergedConfig = { ...existing.config, ...configUpdates }; + return this.upsert(type, mergedConfig, existing.active); + }, + + /** + * Delete a connector entirely. + * @param {'telegram'} type + * @returns {Promise} + */ + delete: async function (type) { + try { + await prisma.external_communication_connectors.delete({ + where: { type }, + }); + return true; + } catch (error) { + console.error("ExternalCommunicationConnector.delete", error.message); + return false; + } + }, +}; + +module.exports = { ExternalCommunicationConnector }; diff --git a/server/models/invite.js b/server/models/invite.js new file mode 100644 index 0000000..bd2bca5 --- /dev/null +++ b/server/models/invite.js @@ -0,0 +1,149 @@ +const { safeJsonParse } = require("../utils/http"); +const prisma = require("../utils/prisma"); + +const Invite = { + makeCode: () => { + const uuidAPIKey = require("uuid-apikey"); + return uuidAPIKey.create().apiKey; + }, + + create: async function ({ createdByUserId = 0, workspaceIds = [] }) { + try { + const invite = await prisma.invites.create({ + data: { + code: this.makeCode(), + createdBy: createdByUserId, + workspaceIds: JSON.stringify(workspaceIds), + }, + }); + return { invite, error: null }; + } catch (error) { + console.error("FAILED TO CREATE INVITE.", error.message); + return { invite: null, error: error.message }; + } + }, + + deactivate: async function (inviteId = null) { + try { + const invite = await prisma.invites.findUnique({ + where: { id: Number(inviteId) }, + }); + if (!invite) return { success: false, error: "Invite not found" }; + + await prisma.invites.update({ + where: { id: Number(inviteId) }, + data: { status: "disabled" }, + }); + return { success: true, error: null }; + } catch (error) { + console.error(error.message); + return { success: false, error: "Failed to deactivate invite" }; + } + }, + + markClaimed: async function (inviteId = null, user) { + try { + const invite = await prisma.invites.update({ + where: { id: Number(inviteId) }, + data: { status: "claimed", claimedBy: user.id }, + }); + + try { + if (!!invite?.workspaceIds) { + const { Workspace } = require("./workspace"); + const { WorkspaceUser } = require("./workspaceUsers"); + const workspaceIds = (await Workspace.where({})).map( + (workspace) => workspace.id + ); + const ids = safeJsonParse(invite.workspaceIds) + .map((id) => Number(id)) + .filter((id) => workspaceIds.includes(id)); + if (ids.length !== 0) await WorkspaceUser.createMany(user.id, ids); + } + } catch (e) { + console.error( + "Could not add user to workspaces automatically", + e.message + ); + } + + return { success: true, error: null }; + } catch (error) { + console.error(error.message); + return { success: false, error: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const invite = await prisma.invites.findFirst({ where: clause }); + return invite || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.invites.count({ where: clause }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.invites.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit) { + try { + const invites = await prisma.invites.findMany({ + where: clause, + take: limit || undefined, + }); + return invites; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithUsers: async function (clause = {}, limit) { + const { User } = require("./user"); + try { + const invites = await this.where(clause, limit); + for (const invite of invites) { + if (invite.claimedBy) { + const acceptedUser = await User.get({ id: invite.claimedBy }); + invite.claimedBy = { + id: acceptedUser?.id, + username: acceptedUser?.username, + }; + } + + if (invite.createdBy) { + const createdUser = await User.get({ id: invite.createdBy }); + invite.createdBy = { + id: createdUser?.id, + username: createdUser?.username, + }; + } + } + return invites; + } catch (error) { + console.error(error.message); + return []; + } + }, +}; + +module.exports = { Invite }; diff --git a/server/models/memory.js b/server/models/memory.js new file mode 100644 index 0000000..25edfcb --- /dev/null +++ b/server/models/memory.js @@ -0,0 +1,451 @@ +const prisma = require("../utils/prisma"); + +/** + * @typedef {Object} Memory + * @property {number} id + * @property {number|null} userId + * @property {number|null} workspaceId + * @property {"workspace"|"global"} scope + * @property {string} content + * @property {Date|null} lastUsedAt + * @property {Date} createdAt + * @property {Date} updatedAt + */ + +function toInt(v) { + const n = Number(v); + if (!Number.isInteger(n)) + throw new Error(`Expected integer, got ${JSON.stringify(v)}`); + return n; +} + +const Memory = { + GLOBAL_LIMIT: 5, + WORKSPACE_LIMIT: 20, + MAX_INJECTED_WORKSPACE_LIMIT: 5, + VALID_SCOPES: ["workspace", "global"], + + validations: { + id: (v) => toInt(v), + userId: (v = null) => (v === null || v === undefined ? null : toInt(v)), + workspaceId: (v = null) => + v === null || v === undefined ? null : toInt(v), + scope: (v = "workspace") => { + if (!Memory.VALID_SCOPES.includes(v)) + throw new Error(`Invalid scope: ${JSON.stringify(v)}`); + return v; + }, + content: (v) => { + if (typeof v !== "string" || v.trim().length === 0) + throw new Error("Content must be a non-empty string"); + return v; + }, + }, + + /** + * List a user's workspace-scoped memories, newest first. + * @param {number|null} userId + * @param {number} workspaceId + * @returns {Promise} + */ + forUserWorkspace: async function (userId, workspaceId) { + try { + const memories = await prisma.memories.findMany({ + where: { + userId: this.validations.userId(userId), + workspaceId: this.validations.id(workspaceId), + scope: "workspace", + }, + orderBy: { createdAt: "desc" }, + }); + return memories; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * List a user's global-scoped memories, newest first. + * @param {number|null} userId + * @returns {Promise} + */ + globalForUser: async function (userId) { + try { + const memories = await prisma.memories.findMany({ + where: { + userId: this.validations.userId(userId), + scope: "global", + }, + orderBy: { createdAt: "desc" }, + }); + return memories; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Create a memory, enforcing per-scope limits (global: 5, workspace: 20). + * @param {Object} params + * @param {number|null} params.userId + * @param {number|null} [params.workspaceId] + * @param {"workspace"|"global"} [params.scope] + * @param {string} params.content + * @returns {Promise<{memory: Memory|null, message: string|null}>} + */ + create: async function ({ + userId, + workspaceId = null, + scope = "workspace", + content, + }) { + try { + const count = await this.countForScope(userId, workspaceId, scope); + const limit = + this.validations.scope(scope) === "global" + ? this.GLOBAL_LIMIT + : this.WORKSPACE_LIMIT; + if (count >= limit) + return { + memory: null, + message: `Maximum ${scope} memory limit (${limit}) reached.`, + }; + + const memory = await prisma.memories.create({ + data: { + userId: this.validations.userId(userId), + workspaceId: this.validations.workspaceId(workspaceId), + scope: this.validations.scope(scope), + content: this.validations.content(content), + }, + }); + return { memory, message: null }; + } catch (error) { + console.error(error.message); + return { memory: null, message: error.message }; + } + }, + + /** + * Update an existing memory's content. + * @param {number} id + * @param {{content: string}} fields + * @returns {Promise<{memory: Memory|null, message: string|null}>} + */ + update: async function (id, { content }) { + try { + const memory = await prisma.memories.update({ + where: { id: this.validations.id(id) }, + data: { + content: this.validations.content(content), + updatedAt: new Date(), + }, + }); + return { memory, message: null }; + } catch (error) { + console.error(error.message); + return { memory: null, message: error.message }; + } + }, + + /** + * Delete a memory by id. + * @param {number} id + * @returns {Promise} true on success, false on error + */ + delete: async function (id) { + try { + await prisma.memories.delete({ + where: { id: this.validations.id(id) }, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Promote a workspace-scoped memory to global scope. + * Enforces the global limit and clears its workspaceId. + * @param {number} id + * @returns {Promise<{memory: Memory|null, message: string|null}>} + */ + promoteToGlobal: async function (id) { + try { + const existing = await prisma.memories.findUnique({ + where: { id: this.validations.id(id) }, + }); + if (!existing) return { memory: null, message: "Memory not found." }; + if (existing.scope === "global") + return { memory: existing, message: "Memory is already global." }; + + const globalCount = await this.countForScope( + existing.userId, + null, + "global" + ); + if (globalCount >= this.GLOBAL_LIMIT) + return { + memory: null, + message: `Maximum global memory limit (${this.GLOBAL_LIMIT}) reached.`, + }; + + const memory = await prisma.memories.update({ + where: { id: this.validations.id(id) }, + data: { scope: "global", workspaceId: null, updatedAt: new Date() }, + }); + return { memory, message: null }; + } catch (error) { + console.error(error.message); + return { memory: null, message: error.message }; + } + }, + + /** + * Demote a global memory to workspace scope, assigning it to the target workspace. + * Enforces the per-workspace limit. + * @param {number} id + * @param {number} workspaceId + * @returns {Promise<{memory: Memory|null, message: string|null}>} + */ + demoteToWorkspace: async function (id, workspaceId) { + try { + const existing = await prisma.memories.findUnique({ + where: { id: this.validations.id(id) }, + }); + if (!existing) return { memory: null, message: "Memory not found." }; + if (existing.scope === "workspace") + return { + memory: existing, + message: "Memory is already workspace-scoped.", + }; + + const wsCount = await this.countForScope( + existing.userId, + workspaceId, + "workspace" + ); + if (wsCount >= this.WORKSPACE_LIMIT) + return { + memory: null, + message: `Maximum workspace memory limit (${this.WORKSPACE_LIMIT}) reached.`, + }; + + const memory = await prisma.memories.update({ + where: { id: this.validations.id(id) }, + data: { + scope: "workspace", + workspaceId: this.validations.id(workspaceId), + updatedAt: new Date(), + }, + }); + return { memory, message: null }; + } catch (error) { + console.error(error.message); + return { memory: null, message: error.message }; + } + }, + + /** + * Stamp a set of memories as just-used (for recency tracking / reranking). + * @param {number[]} [ids] + * @returns {Promise} + */ + updateLastUsed: async function (ids = []) { + if (!Array.isArray(ids) || ids.length === 0) return; + try { + await prisma.memories.updateMany({ + where: { id: { in: ids.map(this.validations.id) } }, + data: { lastUsedAt: new Date() }, + }); + } catch (error) { + console.error(error.message); + } + }, + + /** + * Count a user's memories in a given scope. For "workspace" scope the workspaceId is required. + * @param {number|null} userId + * @param {number|null} workspaceId + * @param {"workspace"|"global"} scope + * @returns {Promise} + */ + countForScope: async function (userId, workspaceId, scope) { + try { + const where = { + userId: this.validations.userId(userId), + scope: this.validations.scope(scope), + }; + if (scope === "workspace") + where.workspaceId = this.validations.workspaceId(workspaceId); + return await prisma.memories.count({ where }); + } catch (error) { + console.error(error.message); + return 0; + } + }, + + /** + * Replace all of a user's workspace-scoped memories for a workspace with the given set. + * Runs in a transaction so a failure mid-write does not leave a partial state. + * Caps the input at this.WORKSPACE_LIMIT. + * @param {number|null} userId + * @param {number} workspaceId + * @param {string[]} memories - memory contents to insert + * @returns {Promise} + */ + replaceWorkspaceMemories: async function (userId, workspaceId, memories) { + try { + const safeMemories = Array.isArray(memories) + ? memories.filter((m) => typeof m === "string" && m.trim().length > 0) + : []; + + await prisma.$transaction(async (tx) => { + await tx.memories.deleteMany({ + where: { + userId: this.validations.userId(userId), + workspaceId: this.validations.id(workspaceId), + scope: "workspace", + }, + }); + + for (const content of safeMemories.slice(0, this.WORKSPACE_LIMIT)) { + await tx.memories.create({ + data: { + userId: this.validations.userId(userId), + workspaceId: this.validations.id(workspaceId), + scope: "workspace", + content, + }, + }); + } + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Apply extracted memories from the two-phase Observer/Reflector pipeline. + * Each memory has an `action` field: + * - "create": insert a new memory (WORKSPACE or GLOBAL) + * - "update": revise an existing WORKSPACE memory by ID + * Skipped items are already filtered out before this method is called. + * @param {number|null} userId + * @param {number} workspaceId + * @param {{content: string, scope: "WORKSPACE"|"GLOBAL", action: "create"|"update", updateId?: number}[]} newMemories + * @param {number} globalSlots - how many global slots are available + * @returns {Promise<{workspaceCount: number, globalCount: number, updatedCount: number}>} + */ + applyExtractedMemories: async function ( + userId, + workspaceId, + newMemories, + globalSlots + ) { + const result = { workspaceCount: 0, globalCount: 0, updatedCount: 0 }; + try { + const safeMemories = Array.isArray(newMemories) + ? newMemories.filter( + (m) => + typeof m === "object" && + m !== null && + typeof m.content === "string" && + m.content.trim().length > 0 && + ["WORKSPACE", "GLOBAL"].includes(m.scope) && + ["create", "update"].includes(m.action) + ) + : []; + + const creates = safeMemories.filter((m) => m.action === "create"); + const updates = safeMemories.filter( + (m) => m.action === "update" && typeof m.updateId === "number" + ); + + const newWorkspace = creates + .filter((m) => m.scope === "WORKSPACE") + .slice(0, this.WORKSPACE_LIMIT); + const newGlobal = creates + .filter((m) => m.scope === "GLOBAL") + .slice(0, Math.max(0, globalSlots)); + + await prisma.$transaction(async (tx) => { + for (const { content } of newWorkspace) { + await tx.memories.create({ + data: { + userId: this.validations.userId(userId), + workspaceId: this.validations.id(workspaceId), + scope: "workspace", + content, + }, + }); + } + + for (const { content } of newGlobal) { + await tx.memories.create({ + data: { + userId: this.validations.userId(userId), + workspaceId: null, + scope: "global", + content, + }, + }); + } + + for (const { updateId, content } of updates) { + await tx.memories.update({ + where: { id: this.validations.id(updateId) }, + data: { content, updatedAt: new Date() }, + }); + } + }); + + result.workspaceCount = newWorkspace.length; + result.globalCount = newGlobal.length; + result.updatedCount = updates.length; + } catch (error) { + console.error(error.message); + } + return result; + }, + + /** + * Assign all unowned memories (userId null) to the admin account when the system enters multi-user mode. + * @param {number} adminUserId + * @returns {Promise} + */ + migrateToMultiUser: async function (adminUserId) { + try { + await prisma.memories.updateMany({ + where: { userId: null }, + data: { userId: this.validations.id(adminUserId) }, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Fetch the first memory matching the given where clause. + * @param {object} [clause] - Prisma where clause + * @returns {Promise} + */ + get: async function (clause = {}) { + try { + const memory = await prisma.memories.findFirst({ where: clause }); + return memory || null; + } catch (error) { + console.error(error.message); + return null; + } + }, +}; + +module.exports = { Memory }; diff --git a/server/models/mobileDevice.js b/server/models/mobileDevice.js new file mode 100644 index 0000000..5aabdf5 --- /dev/null +++ b/server/models/mobileDevice.js @@ -0,0 +1,251 @@ +const prisma = require("../utils/prisma"); +const { v4: uuidv4 } = require("uuid"); +const ip = require("ip"); + +/** + * @typedef {Object} TemporaryMobileDeviceRequest + * @property {number|null} userId - User id to associate creation of key with. + * @property {number} createdAt - Timestamp of when the token was created. + * @property {number} expiresAt - Timestamp of when the token expires. + */ + +/** + * Temporary map to store mobile device requests + * that are not yet approved. Generates a simple JWT + * that expires and is tied to the user (if provided) + * This token must be provided during /register event. + * @type {Map} + */ +const TemporaryMobileDeviceRequests = new Map(); + +const MobileDevice = { + platform: "server", + validDeviceOs: ["android"], + tablename: "desktop_mobile_devices", + writable: ["approved"], + validators: { + approved: (value) => { + if (typeof value !== "boolean") return "Must be a boolean"; + return null; + }, + }, + + /** + * Looks up and consumes a temporary token that was registered + * Will return null if the token is not found or expired. + * @param {string} token - The temporary token to lookup + * @returns {TemporaryMobileDeviceRequest|null} Temp token details + */ + tempToken: (token = null) => { + try { + if (!token || !TemporaryMobileDeviceRequests.has(token)) return null; + const tokenData = TemporaryMobileDeviceRequests.get(token); + if (tokenData.expiresAt < Date.now()) return null; + return tokenData; + } catch { + return null; + } finally { + TemporaryMobileDeviceRequests.delete(token); + } + }, + + /** + * Registers a temporary token for a mobile device request + * This is just using a random token to identify the request + * @security Note: If we use a JWT the QR code that encodes it becomes extremely complex + * and noisy as QR codes have byte limits that could be exceeded with JWTs. Since this is + * a temporary token that is only used to register a device and is short lived we can use UUIDs. + * @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode + * @returns {string} The temporary token + */ + registerTempToken: function (user = null) { + let tokenData = {}; + if (user) tokenData.userId = user.id; + else tokenData.userId = null; + + // Set short lived expiry to this mapping + const createdAt = Date.now(); + tokenData.createdAt = createdAt; + tokenData.expiresAt = createdAt + 3 * 60_000; + + const tempToken = uuidv4().split("-").slice(0, 3).join(""); + TemporaryMobileDeviceRequests.set(tempToken, tokenData); + + // Run this on register since there is no BG task to do this. + this.cleanupExpiredTokens(); + return tempToken; + }, + + /** + * Cleans up expired temporary registration tokens + * Should run quick since this mapping is wiped often + * and does not live past restarts. + */ + cleanupExpiredTokens: function () { + const now = Date.now(); + for (const [token, data] of TemporaryMobileDeviceRequests.entries()) { + if (data.expiresAt < now) TemporaryMobileDeviceRequests.delete(token); + } + }, + + /** + * Returns the connection URL for the mobile app to use to connect to the backend. + * Since you have to have a valid session to call /mobile/connect-info we can pre-register + * a temporary token for the user that is passed back to /mobile/register and can lookup + * who a device belongs to so we can scope it's access token. + * @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode + * @returns {string} + */ + connectionURL: function (user = null) { + let baseUrl = "/api/mobile"; + if (process.env.NODE_ENV === "production") baseUrl = "/api/mobile"; + else + baseUrl = `http://${ip.address()}:${process.env.SERVER_PORT || 3001}/api/mobile`; + + const tempToken = this.registerTempToken(user); + baseUrl = `${baseUrl}?t=${tempToken}`; + return baseUrl; + }, + + /** + * Creates a new device for the mobile app + * @param {object} params - The params to create the device with. + * @param {string} params.deviceOs - Device os to associate creation of key with. + * @param {string} params.deviceName - Device name to associate creation of key with. + * @param {number|null} params.userId - User id to associate creation of key with. + * @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>} + */ + create: async function ({ deviceOs, deviceName, userId = null }) { + try { + if (!deviceOs || !deviceName) + return { device: null, error: "Device OS and name are required" }; + if (!this.validDeviceOs.includes(deviceOs)) + return { device: null, error: `Invalid device OS - ${deviceOs}` }; + + const device = await prisma.desktop_mobile_devices.create({ + data: { + deviceName: String(deviceName), + deviceOs: String(deviceOs).toLowerCase(), + token: uuidv4(), + userId: userId ? Number(userId) : null, + }, + }); + return { device, error: null }; + } catch (error) { + console.error("Failed to create mobile device", error); + return { device: null, error: error.message }; + } + }, + + /** + * Validated existing API key + * @param {string} id - Device id (db id) + * @param {object} updates - Updates to apply to device + * @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>} + */ + update: async function (id, updates = {}) { + const device = await this.get({ id: parseInt(id) }); + if (!device) return { device: null, error: "Device not found" }; + + const validUpdates = {}; + for (const [key, value] of Object.entries(updates)) { + if (!this.writable.includes(key)) continue; + const validation = this.validators[key](value); + if (validation !== null) return { device: null, error: validation }; + validUpdates[key] = value; + } + // If no updates, return the device. + if (Object.keys(validUpdates).length === 0) return { device, error: null }; + + const updatedDevice = await prisma.desktop_mobile_devices.update({ + where: { id: device.id }, + data: validUpdates, + }); + return { device: updatedDevice, error: null }; + }, + + /** + * Fetches mobile device by params. + * @param {object} clause - Prisma props for search + * @returns {Promise} + */ + get: async function (clause = {}, include = null) { + try { + const device = await prisma.desktop_mobile_devices.findFirst({ + where: clause, + ...(include !== null ? { include } : {}), + }); + return device; + } catch (error) { + console.error("FAILED TO GET MOBILE DEVICE.", error); + return []; + } + }, + + /** + * Deletes mobile device by db id. + * @param {number} id - database id of mobile device + * @returns {Promise<{success: boolean, error:string|null}>} + */ + delete: async function (id) { + try { + await prisma.desktop_mobile_devices.delete({ + where: { id: parseInt(id) }, + }); + return { success: true, error: null }; + } catch (error) { + console.error("Failed to delete mobile device", error); + return { success: false, error: error.message }; + } + }, + + /** + * Gets mobile devices by params + * @param {object} clause + * @param {number|null} limit + * @param {object|null} orderBy + * @returns {Promise} + */ + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = null + ) { + try { + const devices = await prisma.desktop_mobile_devices.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), + }); + return devices; + } catch (error) { + console.error("FAILED TO GET MOBILE DEVICES.", error.message); + return []; + } + }, + + /** + * Migrates all mobile devices with null userId to the specified user. + * Called during multi-user mode enablement to assign orphaned devices to the new admin. + * @param {number} userId - The admin user ID to assign devices to + * @returns {Promise} + */ + migrateDevicesToMultiUser: async function (userId) { + try { + await prisma.desktop_mobile_devices.updateMany({ + where: { userId: null }, + data: { userId: userId }, + }); + console.log("Successfully migrated mobile devices to multi-user mode"); + } catch (error) { + console.error( + "Error migrating mobile devices to multi-user mode:", + error + ); + } + }, +}; + +module.exports = { MobileDevice }; diff --git a/server/models/modelRouter.js b/server/models/modelRouter.js new file mode 100644 index 0000000..2e4fb40 --- /dev/null +++ b/server/models/modelRouter.js @@ -0,0 +1,259 @@ +const { Prisma } = require("@prisma/client"); +const prisma = require("../utils/prisma"); +const { ModelRouterRule } = require("./modelRouterRule"); + +const ModelRouter = { + validations: { + name: (value) => { + if (!value || typeof value !== "string") return null; + return String(value).trim().slice(0, 255); + }, + description: (value) => { + if (!value || typeof value !== "string") return null; + return String(value).trim(); + }, + fallback_provider: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + fallback_model: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + cooldown_seconds: (value) => { + const num = Number(value); + if (isNaN(num) || num < 0 || num > 3600) return null; + return Math.round(num); + }, + }, + + create: async function (data = {}, creatorId = null) { + const name = this.validations.name(data.name); + if (!name) return { router: null, error: "Name is required." }; + + const fallback_provider = this.validations.fallback_provider( + data.fallback_provider + ); + const fallback_model = this.validations.fallback_model(data.fallback_model); + if (!fallback_provider || !fallback_model) + return { + router: null, + error: "Fallback provider and model are required.", + }; + + const { ModelRouterService } = require("../utils/router"); + const cooldown_seconds = + data.cooldown_seconds != null + ? this.validations.cooldown_seconds(data.cooldown_seconds) + : ModelRouterService.DEFAULT_STICKY_MS; + + try { + const router = await prisma.model_routers.create({ + data: { + name, + description: this.validations.description(data.description), + fallback_provider, + fallback_model, + cooldown_seconds: cooldown_seconds ?? 30, + created_by: creatorId ? Number(creatorId) : null, + }, + }); + return { router, error: null }; + } catch (error) { + console.error(error.message); + // P2002 is the unique constraint violation error code + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) + return { + router: null, + error: "A router with that name already exists.", + }; + return { router: null, error: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const router = await prisma.model_routers.findFirst({ + where: clause, + }); + return router || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + getWithRules: async function (clause = {}) { + try { + const router = await prisma.model_routers.findFirst({ + where: clause, + include: { + rules: { + orderBy: { priority: "asc" }, + }, + }, + }); + if (!router) return null; + return { ...router, rules: router.rules.map(ModelRouterRule._hydrate) }; + } catch (error) { + console.error(error.message); + return null; + } + }, + + getWithRulesAndCount: async function (clause = {}) { + try { + const router = await prisma.model_routers.findFirst({ + where: clause, + include: { + rules: { + orderBy: { priority: "asc" }, + }, + }, + }); + if (!router) return null; + const workspaceCount = await this.workspaceCount(router.id); + return { + ...router, + rules: router.rules.map(ModelRouterRule._hydrate), + workspaceCount, + }; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const results = await prisma.model_routers.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { createdAt: "asc" } }), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Get all model routers with their rule and workspace counts. + * @returns {Promise>} + */ + getAllWithCounts: async function () { + try { + const routers = await prisma.model_routers.findMany({ + orderBy: { createdAt: "asc" }, + include: { + _count: { + select: { + rules: true, + }, + }, + }, + }); + const results = await Promise.all( + routers.map(async ({ _count, ...router }) => ({ + ...router, + ruleCount: _count.rules, + workspaceCount: await this.workspaceCount(router.id), + })) + ); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + update: async function (id = null, data = {}) { + if (!id) throw new Error("No router id provided for update"); + + const updates = {}; + if (data.name !== undefined) { + const name = this.validations.name(data.name); + if (!name) return { router: null, error: "Name cannot be empty." }; + updates.name = name; + } + if (data.description !== undefined) + updates.description = this.validations.description(data.description); + if (data.fallback_provider !== undefined) { + const provider = this.validations.fallback_provider( + data.fallback_provider + ); + if (!provider) + return { router: null, error: "Fallback provider is required." }; + updates.fallback_provider = provider; + } + if (data.fallback_model !== undefined) { + const model = this.validations.fallback_model(data.fallback_model); + if (!model) return { router: null, error: "Fallback model is required." }; + updates.fallback_model = model; + } + if (data.cooldown_seconds !== undefined) { + const cooldown = this.validations.cooldown_seconds(data.cooldown_seconds); + if (cooldown === null) + return { + router: null, + error: "Cooldown must be a number between 0 and 3600.", + }; + updates.cooldown_seconds = cooldown; + } + + if (Object.keys(updates).length === 0) + return { router: { id }, error: "No valid fields to update." }; + + try { + const router = await prisma.model_routers.update({ + where: { id: Number(id) }, + data: updates, + }); + return { router, error: null }; + } catch (error) { + console.error(error.message); + // P2002 is the unique constraint violation error code + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) + return { + router: null, + error: "A router with that name already exists.", + }; + return { router: null, error: error.message }; + } + }, + + delete: async function (id = null) { + if (!id) return false; + + try { + await prisma.model_routers.delete({ + where: { id: Number(id) }, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + workspaceCount: async function (routerId) { + try { + const count = await prisma.workspaces.count({ + where: { router_id: Number(routerId) }, + }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, +}; + +module.exports = { ModelRouter }; diff --git a/server/models/modelRouterRule.js b/server/models/modelRouterRule.js new file mode 100644 index 0000000..d40f403 --- /dev/null +++ b/server/models/modelRouterRule.js @@ -0,0 +1,336 @@ +const { Prisma } = require("@prisma/client"); +const prisma = require("../utils/prisma"); +const { safeJsonParse } = require("../utils/http"); + +const VALID_TYPES = ["calculated", "llm"]; +const VALID_PROPERTIES = [ + "promptContent", + "conversationTokenCount", + "conversationMessageCount", + "currentHour", + "hasImageAttachment", +]; +const VALID_COMPARATORS = [ + "contains", + "matches", + "gt", + "gte", + "lt", + "lte", + "eq", + "neq", + "between", +]; +const VALID_CONDITION_LOGIC = ["AND"]; +const TITLE_REGEX = /^[a-z0-9_]+$/; + +const ModelRouterRule = { + VALID_TYPES, + VALID_PROPERTIES, + VALID_COMPARATORS, + VALID_CONDITION_LOGIC, + + create: async function (routerId, data = {}, creatorId = null) { + if (!routerId) return { rule: null, error: "Router ID is required." }; + + const title = this._validateTitle(data.title); + if (!title) + return { + rule: null, + error: "Title is required and must be lowercase with underscores only.", + }; + + const type = VALID_TYPES.includes(data.type) ? data.type : null; + if (!type) + return { + rule: null, + error: `Type must be one of: ${VALID_TYPES.join(", ")}`, + }; + + let conditionLogic = null; + let serializedConditions = null; + + if (type === "calculated") { + const validated = this._validateConditions( + data.condition_logic, + data.conditions + ); + if (validated.error) return { rule: null, error: validated.error }; + conditionLogic = validated.condition_logic; + serializedConditions = validated.serialized; + } else if (type === "llm") { + if (!data.description || !data.description.trim()) + return { + rule: null, + error: + "Description is required for LLM rules. Describe when this rule should match.", + }; + } + + if (!data.route_provider || !data.route_model) + return { + rule: null, + error: "Route provider and model are required.", + }; + + try { + const rule = await prisma.model_router_rules.create({ + data: { + router_id: Number(routerId), + enabled: true, // new rules are always enabled + priority: Number(data.priority), + type, + title, + description: data.description || null, + condition_logic: conditionLogic, + conditions: serializedConditions, + route_provider: String(data.route_provider), + route_model: String(data.route_model), + created_by: creatorId ? Number(creatorId) : null, + }, + }); + return { rule: this._hydrate(rule), error: null }; + } catch (error) { + console.error(error.message); + return ( + handleUniqueConstraintError(error) || { + rule: null, + error: error.message, + } + ); + } + }, + + get: async function (clause = {}) { + try { + const rule = await prisma.model_router_rules.findFirst({ + where: clause, + }); + return this._hydrate(rule); + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const results = await prisma.model_router_rules.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return results.map((r) => this._hydrate(r)); + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Get all rules for a given router, ordered by priority. + * -> delegate to the where method + * @param {number} routerId + * @returns {Promise>} + */ + forRouter: async function (routerId) { + return this.where({ router_id: Number(routerId) }, null, { + priority: "asc", + }); + }, + + update: async function (id = null, data = {}) { + if (!id) throw new Error("No rule id provided for update"); + + const updates = {}; + + if (data.title !== undefined) { + const title = this._validateTitle(data.title); + if (!title) + return { + rule: null, + error: "Title must be lowercase with underscores only.", + }; + updates.title = title; + } + + const simpleFields = [ + ["enabled", (v) => Boolean(v)], + ["priority", (v) => Number(v)], + ["description", (v) => v || null], + ["route_provider", (v) => String(v)], + ["route_model", (v) => String(v)], + ]; + for (const [key, map] of simpleFields) { + if (data[key] !== undefined) updates[key] = map(data[key]); + } + + const typeErr = assignEnum(updates, data, "type", VALID_TYPES, "Type"); + if (typeErr) return typeErr; + + // Conditions are only meaningful for calculated rules. When either + // condition_logic or conditions is provided we revalidate the pair + // together so we never persist a half-updated rule. + if (data.condition_logic !== undefined || data.conditions !== undefined) { + const effectiveType = updates.type || data.type; + if (effectiveType === "llm") { + updates.condition_logic = null; + updates.conditions = null; + } else { + const validated = this._validateConditions( + data.condition_logic, + data.conditions + ); + if (validated.error) return { rule: null, error: validated.error }; + updates.condition_logic = validated.condition_logic; + updates.conditions = validated.serialized; + } + } + + if (Object.keys(updates).length === 0) + return { rule: { id }, error: "No valid fields to update." }; + + try { + const rule = await prisma.model_router_rules.update({ + where: { id: Number(id) }, + data: updates, + }); + return { rule: this._hydrate(rule), error: null }; + } catch (error) { + console.error(error.message); + return ( + handleUniqueConstraintError(error) || { + rule: null, + error: error.message, + } + ); + } + }, + + delete: async function (id = null) { + if (!id) return false; + try { + await prisma.model_router_rules.delete({ + where: { id: Number(id) }, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Bulk update priorities for rules within a router. + * @param {Array<{id: number, priority: number}>} ruleUpdates + */ + reorderRules: async function (ruleUpdates = []) { + try { + await prisma.$transaction( + ruleUpdates.map(({ id, priority }) => + prisma.model_router_rules.update({ + where: { id: Number(id) }, + data: { priority: Number(priority) }, + }) + ) + ); + return { success: true, error: null }; + } catch (error) { + console.error(error.message); + return { success: false, error: error.message }; + } + }, + + _validateTitle: function (title) { + if (!title || typeof title !== "string") return null; + const cleaned = title.trim().toLowerCase(); + if (!TITLE_REGEX.test(cleaned)) return null; + return cleaned; + }, + + /** + * Validate a (condition_logic, conditions[]) pair for calculated rules. + * Returns either `{ error }` or `{ condition_logic, serialized }`. + */ + _validateConditions: function (logic, conditions) { + if (!VALID_CONDITION_LOGIC.includes(logic)) + return { + error: `Condition logic must be one of: ${VALID_CONDITION_LOGIC.join(", ")}`, + }; + if (!Array.isArray(conditions) || conditions.length === 0) + return { error: "At least one condition is required." }; + + const normalized = []; + for (const [i, c] of conditions.entries()) { + const cond = c || {}; + if (!VALID_PROPERTIES.includes(cond.property)) + return { + error: `Condition ${i + 1}: property must be one of: ${VALID_PROPERTIES.join(", ")}`, + }; + if (!VALID_COMPARATORS.includes(cond.comparator)) + return { + error: `Condition ${i + 1}: comparator must be one of: ${VALID_COMPARATORS.join(", ")}`, + }; + if ( + cond.value === undefined || + cond.value === null || + String(cond.value) === "" + ) + return { error: `Condition ${i + 1}: value is required.` }; + normalized.push({ + property: cond.property, + comparator: cond.comparator, + value: String(cond.value), + }); + } + + return { + condition_logic: logic, + serialized: JSON.stringify(normalized), + }; + }, + + /** + * Parse the stored JSON `conditions` string into an array so every consumer + * (API responses, evaluator, UI) works with the same shape. Safe on null and + * on rule types that don't use conditions. + */ + _hydrate: function (rule) { + if (!rule) return null; + return { ...rule, conditions: safeJsonParse(rule.conditions, null) }; + }, +}; + +function assignEnum(updates, data, key, validList, label) { + if (data[key] === undefined) return null; + if (!validList.includes(data[key])) + return { + rule: null, + error: `${label} must be one of: ${validList.join(", ")}`, + }; + updates[key] = data[key]; + return null; +} + +function handleUniqueConstraintError(error) { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) + return null; + + const target = error.meta?.target; + if (target?.includes("title")) + return { + rule: null, + error: "A rule with that title already exists on this router.", + }; + if (target?.includes("priority")) + return { + rule: null, + error: "A rule with that priority already exists on this router.", + }; + return { rule: null, error: "Duplicate rule constraint violated." }; +} + +module.exports = { ModelRouterRule }; diff --git a/server/models/passwordRecovery.js b/server/models/passwordRecovery.js new file mode 100644 index 0000000..d979a6c --- /dev/null +++ b/server/models/passwordRecovery.js @@ -0,0 +1,115 @@ +const { v4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const bcrypt = require("bcryptjs"); + +const RecoveryCode = { + tablename: "recovery_codes", + writable: [], + create: async function (userId, code) { + try { + const codeHash = await bcrypt.hash(code, 10); + const recoveryCode = await prisma.recovery_codes.create({ + data: { user_id: userId, code_hash: codeHash }, + }); + return { recoveryCode, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODE.", error.message); + return { recoveryCode: null, error: error.message }; + } + }, + createMany: async function (data) { + try { + const recoveryCodes = await prisma.$transaction( + data.map((recoveryCode) => + prisma.recovery_codes.create({ data: recoveryCode }) + ) + ); + return { recoveryCodes, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODES.", error.message); + return { recoveryCodes: null, error: error.message }; + } + }, + findFirst: async function (clause = {}) { + try { + const recoveryCode = await prisma.recovery_codes.findFirst({ + where: clause, + }); + return recoveryCode; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODE.", error.message); + return null; + } + }, + findMany: async function (clause = {}) { + try { + const recoveryCodes = await prisma.recovery_codes.findMany({ + where: clause, + }); + return recoveryCodes; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODES.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.recovery_codes.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE RECOVERY CODES.", error.message); + return false; + } + }, + hashesForUser: async function (userId = null) { + if (!userId) return []; + return (await this.findMany({ user_id: userId })).map( + (recovery) => recovery.code_hash + ); + }, +}; + +const PasswordResetToken = { + tablename: "password_reset_tokens", + resetExpiryMs: 600_000, // 10 minutes in ms; + writable: [], + calcExpiry: function () { + return new Date(Date.now() + this.resetExpiryMs); + }, + create: async function (userId) { + try { + const passwordResetToken = await prisma.password_reset_tokens.create({ + data: { user_id: userId, token: v4(), expiresAt: this.calcExpiry() }, + }); + return { passwordResetToken, error: null }; + } catch (error) { + console.error("FAILED TO CREATE PASSWORD RESET TOKEN.", error.message); + return { passwordResetToken: null, error: error.message }; + } + }, + findUnique: async function (clause = {}) { + try { + const passwordResetToken = await prisma.password_reset_tokens.findUnique({ + where: clause, + }); + return passwordResetToken; + } catch (error) { + console.error("FAILED TO FIND PASSWORD RESET TOKEN.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.password_reset_tokens.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE PASSWORD RESET TOKEN.", error.message); + return false; + } + }, +}; + +module.exports = { + RecoveryCode, + PasswordResetToken, +}; diff --git a/server/models/promptHistory.js b/server/models/promptHistory.js new file mode 100644 index 0000000..3b8f0b7 --- /dev/null +++ b/server/models/promptHistory.js @@ -0,0 +1,107 @@ +const prisma = require("../utils/prisma"); + +const PromptHistory = { + new: async function ({ workspaceId, prompt, modifiedBy = null }) { + try { + const history = await prisma.prompt_history.create({ + data: { + workspaceId: Number(workspaceId), + prompt: String(prompt), + modifiedBy: !!modifiedBy ? Number(modifiedBy) : null, + }, + }); + return { history, message: null }; + } catch (error) { + console.error(error.message); + return { history: null, message: error.message }; + } + }, + + /** + * Get the prompt history for a workspace. + * @param {number} workspaceId - The ID of the workspace to get prompt history for. + * @param {number|null} limit - The maximum number of history items to return. + * @param {string|null} orderBy - The field to order the history by. + * @returns {Promise>} A promise that resolves to an array of prompt history objects. + */ + forWorkspace: async function ( + workspaceId = null, + limit = null, + orderBy = null + ) { + if (!workspaceId) return []; + try { + const history = await prisma.prompt_history.findMany({ + where: { workspaceId: Number(workspaceId) }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { modifiedAt: "desc" } }), + include: { + user: { + select: { + username: true, + }, + }, + }, + }); + return history; + } catch (error) { + console.error(error.message); + return []; + } + }, + + get: async function (clause = {}, limit = null, orderBy = null) { + try { + const history = await prisma.prompt_history.findFirst({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + include: { + user: { + select: { + id: true, + username: true, + role: true, + }, + }, + }, + }); + return history || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.prompt_history.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Utility method to handle prompt changes and create history entries + * @param {import('./workspace').Workspace} workspaceData - The workspace object (previous state) + * @param {{id: number, role: string}|null} user - The user making the change + * @returns {Promise} + */ + handlePromptChange: async function (workspaceData, user = null) { + try { + await this.new({ + workspaceId: workspaceData.id, + prompt: workspaceData.openAiPrompt, // Store previous prompt as history + modifiedBy: user?.id, + }); + } catch (error) { + console.error("Failed to create prompt history:", error.message); + } + }, +}; + +module.exports = { PromptHistory }; diff --git a/server/models/scheduledJob.js b/server/models/scheduledJob.js new file mode 100644 index 0000000..fa66391 --- /dev/null +++ b/server/models/scheduledJob.js @@ -0,0 +1,499 @@ +const prisma = require("../utils/prisma"); +const later = require("@breejs/later"); +const cronValidate = require("cron-validate").default; + +// Use UTC time for cron interpretation. This ensures consistent behavior +// regardless of server timezone (e.g., when running in containers). +// The frontend is responsible for converting user's local time to UTC +// when creating/editing schedules, and converting UTC back to local time +// when displaying. +later.date.UTC(); + +const ScheduledJob = { + writable: ["name", "prompt", "tools", "schedule", "enabled"], + + /** + * Maximum number of scheduled jobs that can be enabled at once. + * null = no limit. Set to a positive integer to cap concurrent active jobs; + * attempting to enable a job past the cap will be rejected at the API layer. + * @todo: add a configuration option for this + * @type {number|null} + */ + MAX_ACTIVE: null, + + /** + * Compute the next run time from a cron expression. + * Uses @breejs/later which is already available via Bree. + * @param {string} cronExpression + * @returns {Date|null} + */ + computeNextRunAt: function (cronExpression) { + try { + const sched = later.parse.cron(cronExpression); + const next = later.schedule(sched).next(1); + return next || null; + } catch (error) { + console.error( + "Failed to compute next run time from cron:", + error.message + ); + return null; + } + }, + + /** + * Validate a cron expression. + * Uses cron-validate which is already available via Bree. + * @param {string} cronExpression + * @returns {boolean} + */ + isValidCron: function (cronExpression) { + try { + return cronValidate(cronExpression).isValid(); + } catch { + return false; + } + }, + + create: async function ({ name, prompt, tools = null, schedule } = {}) { + try { + const nextRunAt = this.computeNextRunAt(schedule); + const job = await prisma.scheduled_jobs.create({ + data: { + name: String(name), + prompt: String(prompt), + tools: tools ? JSON.stringify(tools) : null, + schedule: String(schedule), + nextRunAt, + }, + }); + return { job, error: null }; + } catch (error) { + console.error("Failed to create scheduled job:", error.message); + return { job: null, error: error.message }; + } + }, + + update: async function (id, data = {}) { + try { + const updates = {}; + for (const key of this.writable) { + if (data.hasOwnProperty(key)) { + if (key === "tools") { + updates[key] = data[key] ? JSON.stringify(data[key]) : null; + } else { + updates[key] = data[key]; + } + } + } + + // Recompute nextRunAt if schedule changed + if (updates.schedule) { + updates.nextRunAt = this.computeNextRunAt(updates.schedule); + } + + updates.updatedAt = new Date(); + + const job = await prisma.scheduled_jobs.update({ + where: { id: Number(id) }, + data: updates, + }); + return { job, error: null }; + } catch (error) { + console.error("Failed to update scheduled job:", error.message); + return { job: null, error: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const job = await prisma.scheduled_jobs.findFirst({ where: clause }); + return job || null; + } catch (error) { + console.error("Failed to get scheduled job:", error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = {} + ) { + try { + const results = await prisma.scheduled_jobs.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { createdAt: "desc" } }), + ...(Object.keys(include).length > 0 ? { include } : {}), + }); + return results; + } catch (error) { + console.error("Failed to query scheduled jobs:", error.message); + return []; + } + }, + + delete: async function (id) { + try { + await prisma.scheduled_jobs.delete({ where: { id: Number(id) } }); + return true; + } catch (error) { + console.error("Failed to delete scheduled job:", error.message); + return false; + } + }, + + allEnabled: async function () { + try { + return await prisma.scheduled_jobs.findMany({ + where: { enabled: true }, + }); + } catch (error) { + console.error("Failed to get enabled scheduled jobs:", error.message); + return []; + } + }, + + /** + * Count enabled scheduled jobs, optionally excluding a single job by id. + * `excludeId` is used by canActivate so that re-saving an already-enabled job + * is not double-counted against the limit. + * @param {number|null} excludeId + * @returns {Promise} + */ + countActive: async function (excludeId = null) { + try { + return await prisma.scheduled_jobs.count({ + where: { + enabled: true, + ...(excludeId != null ? { NOT: { id: Number(excludeId) } } : {}), + }, + }); + } catch (error) { + console.error("Failed to count active scheduled jobs:", error.message); + return 0; + } + }, + + /** + * Check whether a job can be activated without exceeding MAX_ACTIVE. + * Pass `excludeId` when re-saving an existing job to avoid counting it twice. + * @param {{ excludeId?: number|null }} [opts] + * @returns {Promise<{ allowed: boolean, limit: number|null, current: number }>} + */ + canActivate: async function ({ excludeId = null } = {}) { + const limit = this.MAX_ACTIVE; + if (limit == null) { + return { allowed: true, limit: null, current: 0 }; + } + const current = await this.countActive(excludeId); + return { allowed: current < limit, limit, current }; + }, + + /** + * Recompute nextRunAt from the current time. + * Used on cold startup to correct stale nextRunAt values. + * @param {number} id + */ + recomputeNextRunAt: async function (id) { + try { + const job = await this.get({ id: Number(id) }); + if (!job) return; + + const nextRunAt = this.computeNextRunAt(job.schedule); + if (!nextRunAt) return; + + await prisma.scheduled_jobs.update({ + where: { id: Number(id) }, + data: { nextRunAt, updatedAt: new Date() }, + }); + } catch (error) { + console.error("Failed to recompute nextRunAt:", error.message); + } + }, + + /** + * Update lastRunAt and nextRunAt after a job run. + * @param {number} id + */ + updateRunTimestamps: async function (id) { + try { + const job = await this.get({ id: Number(id) }); + if (!job) return; + + const nextRunAt = this.computeNextRunAt(job.schedule); + await prisma.scheduled_jobs.update({ + where: { id: Number(id) }, + data: { + lastRunAt: new Date(), + nextRunAt, + updatedAt: new Date(), + }, + }); + } catch (error) { + console.error("Failed to update run timestamps:", error.message); + } + }, + + /** + * Get ALL available tools for scheduled jobs to choose from. + * Unlike the global agent settings, each scheduled job can have its own tool configuration. + * This returns all possible tools so users can enable different tools for different scheduled tasks. + * + * @returns {Promise<{ + * category: string, + * name: string, + * items: Array<{ id: string, name: string, description?: string, requiresSetup?: boolean }> + * }[]>} + */ + availableTools: async function () { + const AgentPlugins = require("../utils/agents/aibitat/plugins"); + const ImportedPlugin = require("../utils/agents/imported"); + const { AgentFlows } = require("../utils/agentFlows"); + const MCPCompatibilityLayer = require("../utils/MCP"); + const { + listSQLConnections, + } = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors"); + const { + GmailBridge, + } = require("../utils/agents/aibitat/plugins/gmail/lib"); + const { + GoogleCalendarBridge, + } = require("../utils/agents/aibitat/plugins/google-calendar/lib"); + const { + OutlookBridge, + } = require("../utils/agents/aibitat/plugins/outlook/lib"); + + const categories = []; + + // Check which skills need setup + const sqlConnections = await listSQLConnections(); + const sqlNeedsSetup = sqlConnections.length === 0; + + const gmailConfig = await GmailBridge.getConfig(); + const gmailNeedsSetup = !gmailConfig.deploymentId || !gmailConfig.apiKey; + + const gcalConfig = await GoogleCalendarBridge.getConfig(); + const gcalNeedsSetup = !gcalConfig.deploymentId || !gcalConfig.apiKey; + + const outlookConfig = await OutlookBridge.getConfig(); + const outlookNeedsSetup = + !outlookConfig.clientId || + !outlookConfig.clientSecret || + !outlookConfig.accessToken; + + // Default skills (always available) + const DEFAULT_SKILLS = [ + { + id: "rag-memory", + name: "RAG Memory", + description: "Recall and cite information from embedded documents", + }, + { + id: "document-summarizer", + name: "Document Summarizer", + description: "Summarize documents in the workspace", + }, + { + id: "web-scraping", + name: "Web Scraping", + description: "Scrape content from web pages", + }, + ]; + + // Configurable skills without sub-skills + const SIMPLE_CONFIGURABLE_SKILLS = [ + { + id: "create-chart", + name: "Create Charts", + description: "Generate data visualization charts", + }, + { + id: "web-browsing", + name: "Web Browsing", + description: "Search and browse the web", + }, + { + id: "sql-agent", + name: "SQL Agent", + description: "Query connected SQL databases", + requiresSetup: sqlNeedsSetup, + }, + ]; + + // Build agent skills category + const agentSkillItems = [...DEFAULT_SKILLS, ...SIMPLE_CONFIGURABLE_SKILLS]; + + if (agentSkillItems.length > 0) { + categories.push({ + category: "agent-skills", + name: "Agent Skills", + items: agentSkillItems, + }); + } + + // Helper to prettify a sub-skill name (e.g., "gmail-get-inbox" -> "Get Inbox") + const prettifySubSkillName = (name, prefix) => { + let cleaned = name; + const prefixes = [prefix, "gcal", "filesystem", "create"]; + for (const p of prefixes) { + if (cleaned.startsWith(`${p}-`)) { + cleaned = cleaned.slice(p.length + 1); + break; + } + } + return cleaned + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + }; + + // Helper function to build sub-skill items from AgentPlugins + const buildSubSkillItems = (pluginKey, namePrefix) => { + const plugin = AgentPlugins[pluginKey]; + if (!plugin || !Array.isArray(plugin.plugin)) return []; + + return plugin.plugin.map((subPlugin) => ({ + id: `${plugin.name}#${subPlugin.name}`, + name: prettifySubSkillName(subPlugin.name, namePrefix), + description: subPlugin.description || null, + })); + }; + + // Filesystem Agent (has sub-skills) + const filesystemItems = buildSubSkillItems("filesystemAgent", "filesystem"); + if (filesystemItems.length > 0) { + categories.push({ + category: "filesystem-agent", + name: "File System", + items: filesystemItems, + }); + } + + // Create Files Agent (has sub-skills) + const createFilesItems = buildSubSkillItems("createFilesAgent", "create"); + if (createFilesItems.length > 0) { + categories.push({ + category: "create-files-agent", + name: "Create Files", + items: createFilesItems, + }); + } + + // Gmail Agent (has sub-skills) + const gmailItems = buildSubSkillItems("gmailAgent", "gmail"); + if (gmailItems.length > 0) { + categories.push({ + category: "gmail-agent", + name: "Gmail", + items: gmailItems.map((item) => ({ + ...item, + requiresSetup: gmailNeedsSetup, + })), + requiresSetup: gmailNeedsSetup, + }); + } + + // Google Calendar Agent (has sub-skills) + const googleCalendarItems = buildSubSkillItems( + "googleCalendarAgent", + "gcal" + ); + if (googleCalendarItems.length > 0) { + categories.push({ + category: "google-calendar-agent", + name: "Google Calendar", + items: googleCalendarItems.map((item) => ({ + ...item, + requiresSetup: gcalNeedsSetup, + })), + requiresSetup: gcalNeedsSetup, + }); + } + + // Outlook Agent (has sub-skills) + const outlookItems = buildSubSkillItems("outlookAgent", "outlook"); + if (outlookItems.length > 0) { + categories.push({ + category: "outlook-agent", + name: "Outlook", + items: outlookItems.map((item) => ({ + ...item, + requiresSetup: outlookNeedsSetup, + })), + requiresSetup: outlookNeedsSetup, + }); + } + + // Custom/imported skills category + const importedPlugins = ImportedPlugin.listImportedPlugins(); + if (importedPlugins.length > 0) { + const customSkillItems = importedPlugins.map((plugin) => ({ + id: `@@${plugin.hubId}`, + name: plugin.name || plugin.hubId, + description: plugin.description || null, + })); + + categories.push({ + category: "custom-skills", + name: "Custom Skills", + items: customSkillItems, + }); + } + + // Agent flows category + const allFlows = AgentFlows.listFlows(); + if (allFlows.length > 0) { + const flowItems = allFlows.map((flow) => ({ + id: `@@flow_${flow.uuid}`, + name: flow.name, + description: flow.description || null, + })); + + categories.push({ + category: "agent-flows", + name: "Agent Flows", + items: flowItems, + }); + } + + // MCP servers category - get all servers + // MCP servers are selected as a whole (@@mcp_serverName), not individual tools. + // The agent loader expands the server into its individual tools at runtime. + try { + const mcpLayer = new MCPCompatibilityLayer(); + const servers = await mcpLayer.servers(); + + const mcpItems = []; + for (const server of servers) { + const toolCount = server.tools?.length || 0; + mcpItems.push({ + id: `@@mcp_${server.name}`, + name: server.name, + description: + toolCount > 0 + ? `${toolCount} tools available` + : "No tools available", + }); + } + + if (mcpItems.length > 0) { + categories.push({ + category: "mcp-servers", + name: "MCP Servers", + items: mcpItems, + }); + } + } catch (error) { + console.error("Failed to load MCP servers for available tools:", error); + } + + return categories; + }, +}; + +module.exports = { ScheduledJob }; diff --git a/server/models/scheduledJobRun.js b/server/models/scheduledJobRun.js new file mode 100644 index 0000000..4be96d6 --- /dev/null +++ b/server/models/scheduledJobRun.js @@ -0,0 +1,362 @@ +const prisma = require("../utils/prisma"); + +const ScheduledJobRun = { + statuses: { + queued: "queued", + running: "running", + completed: "completed", + failed: "failed", + timed_out: "timed_out", + }, + + // Non-terminal statuses — a row in any of these states is considered + // "in flight" for dedup purposes. The parent claims a row as `queued` + // when enqueuing; the worker transitions it to `running` once it + // actually begins executing (it may sit in p-queue first). + nonTerminalStatuses: ["queued", "running"], + + /** + * Claim a new run for a job. At most one in-flight run per job is allowed — + * if a `queued` or `running` row already exists, this returns null and the + * caller should drop the request. The check + insert run inside an + * interactive transaction so two concurrent callers cannot both pass; + * SQLite serializes writes. + * + * The row is created in `queued` status; the worker transitions it to + * `running` via markRunning() once it actually begins executing. + * + * @param {number} jobId + * @returns {Promise} The created run row, or null if a run is + * already in progress for this job (or on failure). + */ + start: async function (jobId) { + try { + return await prisma.$transaction(async (tx) => { + const existing = await tx.scheduled_job_runs.findFirst({ + where: { + jobId: Number(jobId), + status: { in: this.nonTerminalStatuses }, + }, + select: { id: true }, + }); + if (existing) return null; + + return tx.scheduled_job_runs.create({ + data: { + jobId: Number(jobId), + status: this.statuses.queued, + }, + }); + }); + } catch (error) { + console.error("Failed to enqueue scheduled job run:", error.message); + return null; + } + }, + + /** + * Transition a queued run into the running state. Called by the worker as + * its first DB write, so `startedAt` reflects actual execution start rather + * than queue-claim time. Filtered updateMany makes it a no-op if the row + * has already been transitioned to a terminal state (e.g. parent failed it + * because the worker failed to spawn, then a stale child somehow boots). + * + * @param {number} id - scheduled_job_runs.id + * @returns {Promise} true if the row transitioned, false otherwise + */ + markRunning: async function (id) { + try { + const result = await prisma.scheduled_job_runs.updateMany({ + where: { id: Number(id), status: this.statuses.queued }, + data: { + status: this.statuses.running, + startedAt: new Date(), + }, + }); + return result.count > 0; + } catch (error) { + console.error( + "Failed to transition scheduled job run to running:", + error.message + ); + return false; + } + }, + + /** + * Mark a run as failed only if it has not already reached a terminal state. + * Used by the parent process when a worker exits unexpectedly — atomic + * filtered update prevents clobbering a row the worker already transitioned + * to `completed` (the rare race where the worker succeeded but exited + * non-zero during cleanup). + * @param {number} id - scheduled_job_runs.id + * @param {string} errorMsg + */ + failIfNotTerminal: async function (id, errorMsg) { + try { + const result = await prisma.scheduled_job_runs.updateMany({ + where: { + id: Number(id), + status: { in: this.nonTerminalStatuses }, + }, + data: { + status: this.statuses.failed, + error: String(errorMsg || "Worker exited unexpectedly"), + completedAt: new Date(), + }, + }); + return result.count > 0; + } catch (error) { + console.error( + "Failed to conditionally fail scheduled job run:", + error.message + ); + return false; + } + }, + + complete: async function (id, { result } = {}) { + try { + const run = await prisma.scheduled_job_runs.update({ + where: { id: Number(id) }, + data: { + status: this.statuses.completed, + result: typeof result === "string" ? result : JSON.stringify(result), + completedAt: new Date(), + }, + }); + return run; + } catch (error) { + console.error("Failed to complete scheduled job run:", error.message); + return null; + } + }, + + fail: async function (id, { error: errorMsg } = {}) { + try { + // Use updateMany with a filter to avoid overwriting a run that was + // already moved to a terminal state (e.g., killed by user). + const result = await prisma.scheduled_job_runs.updateMany({ + where: { + id: Number(id), + status: { in: this.nonTerminalStatuses }, + }, + data: { + status: this.statuses.failed, + error: String(errorMsg || "Unknown error"), + completedAt: new Date(), + }, + }); + if (result.count === 0) return null; + return await this.get({ id: Number(id) }); + } catch (error) { + console.error( + "Failed to mark scheduled job run as failed:", + error.message + ); + return null; + } + }, + + timeout: async function (id) { + try { + // Use updateMany with a filter to avoid overwriting a run that was + // already moved to a terminal state (e.g., killed by user). + const result = await prisma.scheduled_job_runs.updateMany({ + where: { + id: Number(id), + status: { in: this.nonTerminalStatuses }, + }, + data: { + status: this.statuses.timed_out, + error: "Job execution timed out", + completedAt: new Date(), + }, + }); + if (result.count === 0) return null; + return await this.get({ id: Number(id) }); + } catch (error) { + console.error( + "Failed to mark scheduled job run as timed out:", + error.message + ); + return null; + } + }, + + /** + * Kill a running or queued job run. This marks the run as failed with a + * user-initiated kill message. The actual worker process termination is + * handled by BackgroundService.killRun(). + * - Killing a run will also mark it as read (user killed it, so dont bother with unread status) + * @param {number} id - scheduled_job_runs.id + * @returns {Promise} The updated run row, or null if not killable + */ + kill: async function (id) { + try { + const result = await prisma.scheduled_job_runs.updateMany({ + where: { + id: Number(id), + status: { in: this.nonTerminalStatuses }, + }, + data: { + status: this.statuses.failed, + error: "Job killed by user", + completedAt: new Date(), + readAt: new Date(), + }, + }); + if (result.count === 0) return null; + return await this.get({ id: Number(id) }); + } catch (error) { + console.error("Failed to kill scheduled job run:", error.message); + return null; + } + }, + + get: async function (clause = {}, include = {}) { + try { + const run = await prisma.scheduled_job_runs.findFirst({ + where: clause, + ...(Object.keys(include).length > 0 ? { include } : {}), + }); + return run || null; + } catch (error) { + console.error("Failed to get scheduled job run:", error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = {}, + offset = 0 + ) { + try { + const results = await prisma.scheduled_job_runs.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null + ? { orderBy } + : { orderBy: { startedAt: "desc" } }), + ...(Object.keys(include).length > 0 ? { include } : {}), + ...(offset !== null ? { skip: offset } : {}), + }); + return results; + } catch (error) { + console.error("Failed to query scheduled job runs:", error.message); + return []; + } + }, + + markRead: async function (id) { + try { + await prisma.scheduled_job_runs.update({ + where: { id: Number(id) }, + data: { readAt: new Date() }, + }); + return true; + } catch (error) { + console.error("Failed to mark run as read:", error.message); + return false; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.scheduled_job_runs.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("Failed to delete scheduled job runs:", error.message); + return false; + } + }, + + /** + * Mark all orphaned in-flight runs (queued or running) as failed — used on + * cold startup to recover rows whose owning worker died with the server. + */ + failOrphanedRuns: async function () { + try { + const result = await prisma.scheduled_job_runs.updateMany({ + where: { status: { in: this.nonTerminalStatuses } }, + data: { + status: this.statuses.failed, + error: "Server restarted during execution", + completedAt: new Date(), + }, + }); + return result.count; + } catch (error) { + console.error("Failed to fail orphaned runs:", error.message); + return 0; + } + }, + + /** + * Continue a run in a workspace thread. + * This will create a new workspace and thread specific for the run if they do not exist, and add the run's response to the thread. + * @param {number} runId - The ID of the run to continue. + * @returns {Promise<{workspace: import("@prisma/client").workspaces | null, thread: import("@prisma/client").workspace_threads | null, error: string | null}>} A promise that resolves to an object containing the workspace, thread, and an error message if applicable. + */ + continueInThread: async function (runId) { + try { + const { Workspace } = require("./workspace"); + const { WorkspaceThread } = require("./workspaceThread"); + const { WorkspaceChats } = require("./workspaceChats"); + const { safeJsonParse } = require("../utils/http"); + + const run = await this.get({ id: Number(runId) }, { job: true }); + if (!run) throw new Error("Run not found"); + + const result = safeJsonParse(run.result, {}); + const responseText = result?.text || "No response was generated."; + + // Get or create the "Scheduled Jobs" workspace + const { workspace, error: workspaceError } = await Workspace.upsert( + { slug: "scheduled-jobs" }, + { + name: "Scheduled Jobs", + slug: "scheduled-jobs", + chatMode: "automatic", + } + ); + if (workspaceError) + throw new Error(workspaceError || "Failed to create workspace"); + + const { thread, message: threadError } = + await WorkspaceThread.new(workspace); + if (threadError) + throw new Error(threadError || "Failed to create thread"); + + await WorkspaceChats.new({ + workspaceId: workspace.id, + prompt: run.job.prompt, + response: { + text: responseText, + sources: result.sources || [], + outputs: result.outputs || [], + type: "chat", + }, + threadId: thread.id, + include: true, + }); + + return { + workspace, + thread, + error: null, + }; + } catch (error) { + return { + workspace: null, + thread: null, + error: error.message ?? "Unknown error", + }; + } + }, +}; + +module.exports = { ScheduledJobRun }; diff --git a/server/models/slashCommandsPresets.js b/server/models/slashCommandsPresets.js new file mode 100644 index 0000000..659af61 --- /dev/null +++ b/server/models/slashCommandsPresets.js @@ -0,0 +1,143 @@ +const { v4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const CMD_REGEX = new RegExp(/[^a-zA-Z0-9_-]/g); + +const SlashCommandPresets = { + formatCommand: function (command = "") { + if (!command || command.length < 2) return `/${v4().split("-")[0]}`; + + let adjustedCmd = command.toLowerCase(); // force lowercase + if (!adjustedCmd.startsWith("/")) adjustedCmd = `/${adjustedCmd}`; // Fix if no preceding / is found. + return `/${adjustedCmd.slice(1).toLowerCase().replace(CMD_REGEX, "-")}`; // replace any invalid chars with '-' + }, + + get: async function (clause = {}) { + try { + const preset = await prisma.slash_command_presets.findFirst({ + where: clause, + }); + return preset || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit) { + try { + const presets = await prisma.slash_command_presets.findMany({ + where: clause, + take: limit || undefined, + }); + return presets; + } catch (error) { + console.error(error.message); + return []; + } + }, + + // Command + userId must be unique combination. + create: async function (userId = null, presetData = {}) { + try { + const existingPreset = await this.get({ + userId: userId ? Number(userId) : null, + command: String(presetData.command), + }); + + if (existingPreset) { + console.log( + "SlashCommandPresets.create - preset already exists - will not create" + ); + return existingPreset; + } + + const preset = await prisma.slash_command_presets.create({ + data: { + ...presetData, + // This field (uid) is either the user_id or 0 (for non-multi-user mode). + // the UID field enforces the @@unique(userId, command) constraint since + // the real relational field (userId) cannot be non-null so this 'dummy' field gives us something + // to constrain against within the context of prisma and sqlite that works. + uid: userId ? Number(userId) : 0, + userId: userId ? Number(userId) : null, + }, + }); + return preset; + } catch (error) { + console.error("Failed to create preset", error.message); + return null; + } + }, + + getUserPresets: async function (userId = null) { + try { + return ( + await prisma.slash_command_presets.findMany({ + where: { userId: !!userId ? Number(userId) : null }, + orderBy: { createdAt: "asc" }, + }) + )?.map((preset) => ({ + id: preset.id, + command: preset.command, + prompt: preset.prompt, + description: preset.description, + })); + } catch (error) { + console.error("Failed to get user presets", error.message); + return []; + } + }, + + update: async function (presetId = null, presetData = {}) { + try { + const preset = await prisma.slash_command_presets.update({ + where: { id: Number(presetId) }, + data: presetData, + }); + return preset; + } catch (error) { + console.error("Failed to update preset", error.message); + return null; + } + }, + + delete: async function (presetId = null) { + try { + await prisma.slash_command_presets.delete({ + where: { id: Number(presetId) }, + }); + return true; + } catch (error) { + console.error("Failed to delete preset", error.message); + return false; + } + }, + + /** + * Migrates all slash command presets with null userId to the specified admin user. + * Called during multi-user mode enablement to assign orphaned presets to the new admin. + * @param {number} adminUserId - The admin user ID to assign presets to + * @returns {Promise} + */ + migrateToMultiUser: async function (adminUserId) { + try { + await prisma.slash_command_presets.updateMany({ + where: { userId: null }, + data: { + userId: adminUserId, + uid: adminUserId, + }, + }); + console.log( + "Successfully migrated slash command presets to multi-user mode" + ); + } catch (error) { + console.error( + "Error migrating slash command presets to multi-user mode:", + error + ); + } + }, +}; + +module.exports.SlashCommandPresets = SlashCommandPresets; diff --git a/server/models/systemPromptVariables.js b/server/models/systemPromptVariables.js new file mode 100644 index 0000000..68cb28c --- /dev/null +++ b/server/models/systemPromptVariables.js @@ -0,0 +1,375 @@ +const prisma = require("../utils/prisma"); +const moment = require("moment"); + +/** + * @typedef {Object} SystemPromptVariable + * @property {number} id + * @property {string} key + * @property {string|function} value + * @property {string} description + * @property {'system'|'user'|'workspace'|'static'} type + * @property {number} userId + * @property {boolean} multiUserRequired + */ + +const SystemPromptVariables = { + VALID_TYPES: ["user", "workspace", "system", "static"], + DEFAULT_VARIABLES: [ + { + key: "time", + value: () => moment().format("LTS"), + description: "Current time", + type: "system", + multiUserRequired: false, + }, + { + key: "time_24", + value: () => moment().format("HH:mm:ss"), + description: "Current time (24-hour format)", + type: "system", + multiUserRequired: false, + }, + { + key: "date", + value: () => moment().format("LL"), + description: "Current date", + type: "system", + multiUserRequired: false, + }, + { + key: "datetime", + value: () => moment().format("LLLL"), + description: "Current date and time", + type: "system", + multiUserRequired: false, + }, + { + key: "datetime_24", + value: () => moment().format("YYYY-MM-DD HH:mm:ss"), + description: "Current date and time (24-hour format)", + type: "system", + multiUserRequired: false, + }, + { + key: "user.id", + value: (userId = null) => { + if (!userId) return "[User ID]"; + return userId; + }, + description: "Current user's ID", + type: "user", + multiUserRequired: true, + }, + { + key: "user.name", + value: async (userId = null) => { + if (!userId) return "[User name]"; + try { + const user = await prisma.users.findUnique({ + where: { id: Number(userId) }, + select: { username: true }, + }); + return user?.username || "[User name is empty or unknown]"; + } catch (error) { + console.error("Error fetching user name:", error); + return "[User name is empty or unknown]"; + } + }, + description: "Current user's username", + type: "user", + multiUserRequired: true, + }, + { + key: "user.bio", + value: async (userId = null) => { + if (!userId) return "[User bio]"; + try { + const user = await prisma.users.findUnique({ + where: { id: Number(userId) }, + select: { bio: true }, + }); + return user?.bio || "[User bio is empty]"; + } catch (error) { + console.error("Error fetching user bio:", error); + return "[User bio is empty]"; + } + }, + description: "Current user's bio field from their profile", + type: "user", + multiUserRequired: true, + }, + { + key: "workspace.id", + value: (workspaceId = null) => { + if (!workspaceId) return "[Workspace ID]"; + return workspaceId; + }, + description: "Current workspace's ID", + type: "workspace", + multiUserRequired: false, + }, + { + key: "workspace.name", + value: async (workspaceId = null) => { + if (!workspaceId) return "[Workspace name]"; + const workspace = await prisma.workspaces.findUnique({ + where: { id: Number(workspaceId) }, + select: { name: true }, + }); + return workspace?.name || "[Workspace name is empty or unknown]"; + }, + description: "Current workspace's name", + type: "workspace", + multiUserRequired: false, + }, + ], + + /** + * Gets a system prompt variable by its key + * @param {string} key + * @returns {Promise} + */ + get: async function (key = null) { + if (!key) return null; + const variable = await prisma.system_prompt_variables.findUnique({ + where: { key: String(key) }, + }); + return variable; + }, + + /** + * Retrieves all system prompt variables with dynamic variables as well + * as user defined variables + * @param {number|null} userId - the current user ID (determines if in multi-user mode) + * @returns {Promise} + */ + getAll: async function (userId = null) { + // All user-defined system variables are available to everyone globally since only admins can create them. + const userDefinedSystemVariables = + await prisma.system_prompt_variables.findMany(); + const formattedDbVars = userDefinedSystemVariables.map((v) => ({ + id: v.id, + key: v.key, + value: v.value, + description: v.description, + type: v.type, + userId: v.userId, + })); + + // If userId is not provided, filter the default variables to only include non-multiUserRequired variables + // since we wont be able to dynamically inject user-related content. + const defaultSystemVariables = !userId + ? this.DEFAULT_VARIABLES.filter((v) => !v.multiUserRequired) + : this.DEFAULT_VARIABLES; + + return [...defaultSystemVariables, ...formattedDbVars]; + }, + + /** + * Creates a new system prompt variable + * @param {{ key: string, value: string, description: string, type: string, userId: number }} data + * @returns {Promise} + */ + create: async function ({ + key, + value, + description = null, + type = "static", + userId = null, + }) { + await this._checkVariableKey(key, true); + return await prisma.system_prompt_variables.create({ + data: { + key: String(key), + value: String(value), + description: description ? String(description) : null, + type: type ? String(type) : "static", + userId: userId ? Number(userId) : null, + }, + }); + }, + + /** + * Updates a system prompt variable by its unique database ID + * @param {number} id + * @param {{ key: string, value: string, description: string }} data + * @returns {Promise} + */ + update: async function (id, { key, value, description = null }) { + if (!id || !key || !value) return null; + const existingRecord = await prisma.system_prompt_variables.findFirst({ + where: { id: Number(id) }, + }); + if (!existingRecord) throw new Error("System prompt variable not found"); + await this._checkVariableKey(key, false); + + return await prisma.system_prompt_variables.update({ + where: { id: existingRecord.id }, + data: { + key: String(key), + value: String(value), + description: description ? String(description) : null, + }, + }); + }, + + /** + * Deletes a system prompt variable by its unique database ID + * @param {number} id + * @returns {Promise} + */ + delete: async function (id = null) { + try { + await prisma.system_prompt_variables.delete({ + where: { id: Number(id) }, + }); + return true; + } catch (error) { + console.error("Error deleting variable:", error); + return false; + } + }, + + /** + * Injects variables into a string based on the user ID and workspace ID (if provided) and the variables available + * @param {string} str - the input string to expand variables into + * @param {number|null} userId - the user ID to use for dynamic variables + * @param {number|null} workspaceId - the workspace ID to use for workspace variables + * @returns {Promise} + */ + expandSystemPromptVariables: async function ( + str, + userId = null, + workspaceId = null + ) { + if (!str) return str; + + try { + const allVariables = await this.getAll(userId); + let result = str; + + // Find all variable patterns in the string + const matches = str.match(/\{([^}]+)\}/g) || []; + + // Process each match + for (const match of matches) { + const key = match.substring(1, match.length - 1); // Remove { and } + + // Determine if the variable is a class-based variable (workspace.X or user.X) + const isWorkspaceOrUserVariable = ["workspace.", "user."].some( + (prefix) => key.startsWith(prefix) + ); + + // Handle class-based variables with current workspace's or user's data + if (isWorkspaceOrUserVariable) { + let variableTypeDisplay; + if (key.startsWith("workspace.")) variableTypeDisplay = "Workspace"; + else if (key.startsWith("user.")) variableTypeDisplay = "User"; + else throw new Error(`Invalid class-based variable: ${key}`); + + // Get the property name after the prefix + const prop = key.split(".")[1]; + const variable = allVariables.find((v) => v.key === key); + + // If the variable is a function, call it to get the current value + if (variable && typeof variable.value === "function") { + // If the variable is an async function, call it to get the current value + if (variable.value.constructor.name === "AsyncFunction") { + let value; + try { + if (variableTypeDisplay === "Workspace") + value = await variable.value(workspaceId); + else if (variableTypeDisplay === "User") + value = await variable.value(userId); + else throw new Error(`Invalid class-based variable: ${key}`); + } catch (error) { + console.error( + `Error processing ${variableTypeDisplay} variable ${key}:`, + error + ); + value = `[${variableTypeDisplay} ${prop}]`; + } + result = result.replace(match, value); + } else { + let value; + try { + // Call the variable function with the appropriate workspace or user ID + if (variableTypeDisplay === "Workspace") + value = variable.value(workspaceId); + else if (variableTypeDisplay === "User") + value = variable.value(userId); + else throw new Error(`Invalid class-based variable: ${key}`); + } catch (error) { + console.error( + `Error processing ${variableTypeDisplay} variable ${key}:`, + error + ); + value = `[${variableTypeDisplay} ${prop}]`; + } + result = result.replace(match, value); + } + } else { + // If the variable is not a function, replace the match with the variable value + result = result.replace(match, `[${variableTypeDisplay} ${prop}]`); + } + continue; + } + + // Handle regular variables (static types) + const variable = allVariables.find((v) => v.key === key); + if (!variable) continue; + + // For dynamic and system variables, call the function to get the current value + if ( + ["system"].includes(variable.type) && + typeof variable.value === "function" + ) { + try { + if (variable.value.constructor.name === "AsyncFunction") { + const value = await variable.value(userId); + result = result.replace(match, value); + } else { + const value = variable.value(); + result = result.replace(match, value); + } + } catch (error) { + console.error(`Error processing dynamic variable ${key}:`, error); + result = result.replace(match, match); + } + } else { + result = result.replace(match, variable.value || match); + } + } + return result; + } catch (error) { + console.error("Error in expandSystemPromptVariables:", error); + return str; + } + }, + + /** + * Internal function to check if a variable key is valid + * @param {string} key + * @param {boolean} checkExisting + * @returns {Promise} + */ + _checkVariableKey: async function (key = null, checkExisting = true) { + if (!key) throw new Error("Key is required"); + if (typeof key !== "string") throw new Error("Key must be a string"); + if (!/^[a-zA-Z0-9_]+$/.test(key)) + throw new Error("Key must contain only letters, numbers and underscores"); + if (key.length > 255) + throw new Error("Key must be less than 255 characters"); + if (key.length < 3) throw new Error("Key must be at least 3 characters"); + if (key.startsWith("user.")) + throw new Error("Key cannot start with 'user.'"); + if (key.startsWith("system.")) + throw new Error("Key cannot start with 'system.'"); + if (checkExisting && (await this.get(key)) !== null) + throw new Error("System prompt variable with this key already exists"); + + return true; + }, +}; + +module.exports = { SystemPromptVariables }; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js new file mode 100644 index 0000000..ac338b2 --- /dev/null +++ b/server/models/systemSettings.js @@ -0,0 +1,1208 @@ +process.env.NODE_ENV === "development" + ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) + : require("dotenv").config(); + +const { default: slugify } = require("slugify"); +const { isValidUrl, safeJsonParse } = require("../utils/http"); +const prisma = require("../utils/prisma"); +const { MetaGenerator } = require("../utils/boot/MetaGenerator"); +const { PGVector } = require("../utils/vectorDbProviders/pgvector"); +const { NativeEmbedder } = require("../utils/EmbeddingEngines/native"); +const { getBaseLLMProviderModel } = require("../utils/helpers"); +const { + ConnectionStringParser, +} = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils"); + +function isNullOrNaN(value) { + if (value === null) return true; + return isNaN(value); +} + +/** + * Merges a string field from source to target if it passes validation. + * @param {Object} target - The target object to merge into + * @param {Object} source - The source object to read from + * @param {string} fieldName - The field name to merge + * @param {Function|null} validator - Optional validator function that returns false to reject the value + */ +function mergeStringField(target, source, fieldName, validator = null) { + const value = source[fieldName]; + if (value && typeof value === "string" && value.trim()) { + if (validator && !validator(value)) return; + target[fieldName] = value.trim(); + } +} + +const SystemSettings = { + /** A default system prompt that is used when no other system prompt is set or available to the function caller. */ + saneDefaultSystemPrompt: + "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.", + protectedFields: ["multi_user_mode", "hub_api_key", "onboarding_complete"], + publicFields: [ + "footer_data", + "support_email", + "text_splitter_chunk_size", + "text_splitter_chunk_overlap", + "max_embed_chunk_size", + "agent_search_provider", + "agent_sql_connections", + "default_agent_skills", + "disabled_agent_skills", + "disabled_filesystem_skills", + "disabled_create_files_skills", + "disabled_gmail_skills", + "gmail_agent_config", + "disabled_google_calendar_skills", + "google_calendar_agent_config", + "disabled_outlook_skills", + "outlook_agent_config", + "imported_agent_skills", + "agent_clarifying_questions_enabled", + "agent_clarifying_questions_max_per_turn", + "custom_app_name", + "feature_flags", + "meta_page_title", + "meta_page_favicon", + "memory_enabled", + "memory_auto_extraction", + ], + supportedFields: [ + "logo_filename", + "telemetry_id", + "footer_data", + "support_email", + + "text_splitter_chunk_size", + "text_splitter_chunk_overlap", + "agent_search_provider", + "default_agent_skills", + "disabled_agent_skills", + "disabled_filesystem_skills", + "disabled_create_files_skills", + "disabled_gmail_skills", + "gmail_agent_config", + "disabled_google_calendar_skills", + "google_calendar_agent_config", + "disabled_outlook_skills", + "outlook_agent_config", + "agent_sql_connections", + "agent_clarifying_questions_enabled", + "agent_clarifying_questions_max_per_turn", + "custom_app_name", + "default_system_prompt", + + // Meta page customization + "meta_page_title", + "meta_page_favicon", + + // beta feature flags + "experimental_live_file_sync", + + // Hub settings + "hub_api_key", + + // Memory/Personalization + "memory_enabled", + "memory_auto_extraction", + ], + validations: { + footer_data: (updates) => { + try { + const array = JSON.parse(updates) + .filter((setting) => isValidUrl(setting.url)) + .slice(0, 3); // max of 3 items in footer. + return JSON.stringify(array); + } catch { + console.error(`Failed to run validation function on footer_data`); + return JSON.stringify([]); + } + }, + text_splitter_chunk_size: (update) => { + try { + if (isNullOrNaN(update)) throw new Error("Value is not a number."); + if (Number(update) <= 0) throw new Error("Value must be non-zero."); + const { purgeEntireVectorCache } = require("../utils/files"); + purgeEntireVectorCache(); + return Number(update); + } catch (e) { + console.error( + `Failed to run validation function on text_splitter_chunk_size`, + e.message + ); + return 1000; + } + }, + text_splitter_chunk_overlap: (update) => { + try { + if (isNullOrNaN(update)) throw new Error("Value is not a number"); + if (Number(update) < 0) throw new Error("Value cannot be less than 0."); + const { purgeEntireVectorCache } = require("../utils/files"); + purgeEntireVectorCache(); + return Number(update); + } catch (e) { + console.error( + `Failed to run validation function on text_splitter_chunk_overlap`, + e.message + ); + return 20; + } + }, + agent_search_provider: (update) => { + try { + if (update === "none") return null; + if ( + ![ + "google-search-engine", + "serpapi", + "searchapi", + "serper-dot-dev", + "bing-search", + "baidu-search", + "serply-engine", + "searxng-engine", + "tavily-search", + "duckduckgo-engine", + "exa-search", + "perplexity-search", + "brave-search", + "crw-search", + ].includes(update) + ) + throw new Error("Invalid SERP provider."); + return String(update); + } catch (e) { + console.error( + `Failed to run validation function on agent_search_provider`, + e.message + ); + return null; + } + }, + default_agent_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate agent skills.`); + return JSON.stringify([]); + } + }, + memory_enabled: async (update) => { + try { + const enabled = String(update) === "true"; + const { + BackgroundService, + } = require("../utils/BackgroundWorkers/index.js"); + const bgService = new BackgroundService(); + const autoSetting = await SystemSettings.get({ + label: "memory_auto_extraction", + }); + const autoOn = !autoSetting || autoSetting.value === "true"; + await bgService.syncMemoryJob(enabled && autoOn); + return String(enabled); + } catch (e) { + console.error( + `Failed to run validation function on memory_enabled`, + e.message + ); + return String(update); + } + }, + memory_auto_extraction: async (update) => { + try { + const enabled = String(update) === "true"; + const { + BackgroundService, + } = require("../utils/BackgroundWorkers/index.js"); + const bgService = new BackgroundService(); + const memoriesOn = await SystemSettings.memoriesEnabled(); + await bgService.syncMemoryJob(memoriesOn && enabled); + return String(enabled); + } catch (e) { + console.error( + `Failed to run validation function on memory_auto_extraction`, + e.message + ); + return String(update); + } + }, + disabled_agent_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled agent skills.`); + return JSON.stringify([]); + } + }, + disabled_filesystem_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled filesystem skills.`); + return JSON.stringify([]); + } + }, + disabled_create_files_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled create files skills.`); + return JSON.stringify([]); + } + }, + disabled_gmail_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled gmail skills.`); + return JSON.stringify([]); + } + }, + gmail_agent_config: async (update) => { + const GmailBridge = require("../utils/agents/aibitat/plugins/gmail/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "gmail_agent_config" }))?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "deploymentId"); + mergeStringField( + mergedConfig, + newConfig, + "apiKey", + (v) => !v.match(/^\*+$/) + ); + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error(`Could not validate gmail agent config:`, e.message); + return JSON.stringify({}); + } finally { + GmailBridge.reset(); + } + }, + disabled_google_calendar_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled google calendar skills.`); + return JSON.stringify([]); + } + }, + google_calendar_agent_config: async (update) => { + const GoogleCalendarBridge = require("../utils/agents/aibitat/plugins/google-calendar/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "google_calendar_agent_config" })) + ?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "deploymentId"); + mergeStringField( + mergedConfig, + newConfig, + "apiKey", + (v) => !v.match(/^\*+$/) + ); + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error( + `Could not validate google calendar agent config:`, + e.message + ); + return JSON.stringify({}); + } finally { + GoogleCalendarBridge.reset(); + } + }, + disabled_outlook_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled outlook skills.`); + return JSON.stringify([]); + } + }, + outlook_agent_config: async (update) => { + const OutlookBridge = require("../utils/agents/aibitat/plugins/outlook/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "outlook_agent_config" }))?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "clientId"); + mergeStringField(mergedConfig, newConfig, "tenantId"); + mergeStringField( + mergedConfig, + newConfig, + "clientSecret", + (v) => !v.match(/^\*+$/) + ); + + if (newConfig.accessToken !== undefined) { + mergedConfig.accessToken = newConfig.accessToken; + } + if (newConfig.refreshToken !== undefined) { + mergedConfig.refreshToken = newConfig.refreshToken; + } + if (newConfig.tokenExpiry !== undefined) { + mergedConfig.tokenExpiry = newConfig.tokenExpiry; + } + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error(`Could not validate outlook agent config:`, e.message); + return JSON.stringify({}); + } finally { + OutlookBridge.reset(); + } + }, + agent_sql_connections: async (updates) => { + const existingConnections = safeJsonParse( + (await SystemSettings.get({ label: "agent_sql_connections" }))?.value, + [] + ); + try { + const updatedConnections = mergeConnections( + existingConnections, + safeJsonParse(updates, []) + ); + return JSON.stringify(updatedConnections); + } catch { + console.error(`Failed to merge connections`); + return JSON.stringify(existingConnections ?? []); + } + }, + agent_clarifying_questions_enabled: (update) => { + if (typeof update === "boolean") return update ? "true" : "false"; + return String(update) === "true" ? "true" : "false"; + }, + agent_clarifying_questions_max_per_turn: (update) => { + const n = Number(update); + if (!Number.isFinite(n) || n < 1) return 3; + return Math.min(Math.floor(n), 10); + }, + experimental_live_file_sync: (update) => { + if (typeof update === "boolean") + return update === true ? "enabled" : "disabled"; + if (!["enabled", "disabled"].includes(update)) return "disabled"; + return String(update); + }, + meta_page_title: (newTitle) => { + try { + if (typeof newTitle !== "string" || !newTitle) return null; + return String(newTitle); + } catch { + return null; + } finally { + new MetaGenerator().clearConfig(); + } + }, + meta_page_favicon: (faviconUrl) => { + if (!faviconUrl) return null; + try { + const url = new URL(faviconUrl); + return url.toString(); + } catch { + return null; + } finally { + new MetaGenerator().clearConfig(); + } + }, + hub_api_key: (apiKey) => { + if (!apiKey) return null; + return String(apiKey); + }, + default_system_prompt: (prompt) => { + if (typeof prompt !== "string" || !prompt) return null; + if (prompt.trim() === SystemSettings.saneDefaultSystemPrompt) + return SystemSettings.saneDefaultSystemPrompt; + return String(prompt.trim()); + }, + }, + currentSettings: async function () { + const { hasVectorCachedFiles } = require("../utils/files"); + const { + ToolReranker, + } = require("../utils/agents/aibitat/utils/toolReranker"); + const AIbitat = require("../utils/agents/aibitat"); + + const llmProvider = process.env.LLM_PROVIDER; + const vectorDB = process.env.VECTOR_DB; + const embeddingEngine = process.env.EMBEDDING_ENGINE ?? "native"; + return { + // -------------------------------------------------------- + // General Settings + // -------------------------------------------------------- + RequiresAuth: !!process.env.AUTH_TOKEN, + AuthToken: !!process.env.AUTH_TOKEN, + JWTSecret: !!process.env.JWT_SECRET, + StorageDir: process.env.STORAGE_DIR, + MultiUserMode: await this.isMultiUserMode(), + MemoryEnabled: await this.memoriesEnabled(), + MemoryAutoExtraction: await this.memoryAutoExtractionSetting(), + DisableTelemetry: process.env.DISABLE_TELEMETRY || "false", + + // -------------------------------------------------------- + // Embedder Provider Selection Settings & Configs + // -------------------------------------------------------- + EmbeddingEngine: embeddingEngine, + HasExistingEmbeddings: await this.hasEmbeddings(), // check if they have any currently embedded documents active in workspaces. + HasCachedEmbeddings: hasVectorCachedFiles(), // check if they any currently cached embedded docs. + EmbeddingBasePath: process.env.EMBEDDING_BASE_PATH, + EmbeddingModelPref: + embeddingEngine === "native" + ? NativeEmbedder._getEmbeddingModel() + : process.env.EMBEDDING_MODEL_PREF, + EmbeddingOutputDimensions: + process.env.EMBEDDING_OUTPUT_DIMENSIONS || null, + EmbeddingModelMaxChunkLength: + process.env.EMBEDDING_MODEL_MAX_CHUNK_LENGTH, + OllamaEmbeddingBatchSize: process.env.OLLAMA_EMBEDDING_BATCH_SIZE || 1, + VoyageAiApiKey: !!process.env.VOYAGEAI_API_KEY, + GenericOpenAiEmbeddingApiKey: + !!process.env.GENERIC_OPEN_AI_EMBEDDING_API_KEY, + GenericOpenAiEmbeddingMaxConcurrentChunks: + process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS || 500, + GeminiEmbeddingApiKey: !!process.env.GEMINI_EMBEDDING_API_KEY, + GenericOpenAiEmbeddingPassagePrefix: + process.env.GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX || "", + GenericOpenAiEmbeddingQueryPrefix: + process.env.GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX || "", + + // -------------------------------------------------------- + // VectorDB Provider Selection Settings & Configs + // -------------------------------------------------------- + VectorDB: vectorDB, + ...this.vectorDBPreferenceKeys(), + + // -------------------------------------------------------- + // LLM Provider Selection Settings & Configs + // -------------------------------------------------------- + LLMProvider: llmProvider, + LLMModel: getBaseLLMProviderModel({ provider: llmProvider }) || null, + ModelRouterId: process.env.MODEL_ROUTER_ID || null, + ...this.llmPreferenceKeys(), + + // -------------------------------------------------------- + // Whisper (Audio transcription) Selection Settings & Configs + // - Currently the only 3rd party is OpenAI, so is OPEN_AI_KEY is set + // - then it can be shared. + // -------------------------------------------------------- + WhisperProvider: process.env.WHISPER_PROVIDER || "local", + WhisperModelPref: + process.env.WHISPER_MODEL_PREF || "Xenova/whisper-small", + WhisperGenericOpenAiBaseUrl: process.env.WHISPER_GENERIC_OPEN_AI_BASE_URL, + WhisperGenericOpenAiApiKey: !!process.env.WHISPER_GENERIC_OPEN_AI_API_KEY, + WhisperGenericOpenAiModel: process.env.WHISPER_GENERIC_OPEN_AI_MODEL, + + // -------------------------------------------------------- + // TTS/STT Selection Settings & Configs + // - Currently the only 3rd party is OpenAI or the native browser-built in + // -------------------------------------------------------- + TextToSpeechProvider: process.env.TTS_PROVIDER || "native", + TTSOpenAIKey: !!process.env.TTS_OPEN_AI_KEY, + TTSOpenAIVoiceModel: process.env.TTS_OPEN_AI_VOICE_MODEL, + + // Eleven Labs TTS + TTSElevenLabsKey: !!process.env.TTS_ELEVEN_LABS_KEY, + TTSElevenLabsVoiceModel: process.env.TTS_ELEVEN_LABS_VOICE_MODEL, + // Piper TTS + TTSPiperTTSVoiceModel: + process.env.TTS_PIPER_VOICE_MODEL ?? "en_US-hfc_female-medium", + // OpenAI Generic TTS + TTSOpenAICompatibleKey: !!process.env.TTS_OPEN_AI_COMPATIBLE_KEY, + TTSOpenAICompatibleModel: process.env.TTS_OPEN_AI_COMPATIBLE_MODEL, + TTSOpenAICompatibleVoiceModel: + process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL, + TTSOpenAICompatibleEndpoint: process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT, + // Kokoro TTS + TTSKokoroEndpoint: process.env.TTS_KOKORO_ENDPOINT, + TTSKokoroKey: !!process.env.TTS_KOKORO_KEY, + TTSKokoroVoiceModel: process.env.TTS_KOKORO_VOICE_MODEL, + + // STT Selection + SpeechToTextProvider: process.env.STT_PROVIDER || "native", + // STT OpenAI + STTOpenAIModel: process.env.STT_OPEN_AI_MODEL, + + // STT Lemonade + STTLemonadeBasePath: process.env.STT_LEMONADE_BASE_PATH, + STTLemonadeModelPref: process.env.STT_LEMONADE_MODEL_PREF, + + // STT Deepgram + STTDeepgramApiKey: !!process.env.STT_DEEPGRAM_API_KEY, + STTDeepgramModel: process.env.STT_DEEPGRAM_MODEL, + + // STT Generic OpenAI + STTOpenAICompatibleKey: !!process.env.STT_OPEN_AI_COMPATIBLE_KEY, + STTOpenAICompatibleModel: process.env.STT_OPEN_AI_COMPATIBLE_MODEL, + STTOpenAICompatibleEndpoint: process.env.STT_OPEN_AI_COMPATIBLE_ENDPOINT, + + // STT Groq + STTGroqApiKey: !!process.env.STT_GROQ_API_KEY, + STTGroqModel: process.env.STT_GROQ_MODEL, + + // -------------------------------------------------------- + // Agent Settings & Configs + // -------------------------------------------------------- + AgentSerpApiKey: !!process.env.AGENT_SERPAPI_API_KEY || null, + AgentSerpApiEngine: process.env.AGENT_SERPAPI_ENGINE || "google", + AgentSearchApiKey: !!process.env.AGENT_SEARCHAPI_API_KEY || null, + AgentSearchApiEngine: process.env.AGENT_SEARCHAPI_ENGINE || "google", + AgentSerperApiKey: !!process.env.AGENT_SERPER_DEV_KEY || null, + AgentBingSearchApiKey: !!process.env.AGENT_BING_SEARCH_API_KEY || null, + AgentBaiduSearchApiKey: !!process.env.AGENT_BAIDU_SEARCH_API_KEY || null, + AgentSerplyApiKey: !!process.env.AGENT_SERPLY_API_KEY || null, + AgentSearXNGApiUrl: process.env.AGENT_SEARXNG_API_URL || null, + AgentTavilyApiKey: !!process.env.AGENT_TAVILY_API_KEY || null, + AgentExaApiKey: !!process.env.AGENT_EXA_API_KEY || null, + AgentPerplexityApiKey: !!process.env.AGENT_PERPLEXITY_API_KEY || null, + AgentBraveApiKey: !!process.env.AGENT_BRAVE_API_KEY || null, + AgentCrwApiKey: !!process.env.AGENT_CRW_API_KEY || null, + AgentCrwApiUrl: process.env.AGENT_CRW_API_URL || null, + + // -------------------------------------------------------- + // Compliance Settings + // -------------------------------------------------------- + // Disable View Chat History for the whole instance. + DisableViewChatHistory: + "DISABLE_VIEW_CHAT_HISTORY" in process.env || false, + WorkspaceDeletionProtection: + "WORKSPACE_DELETION_PROTECTION" in process.env || false, + + // -------------------------------------------------------- + // Simple SSO Settings + // -------------------------------------------------------- + SimpleSSOEnabled: "SIMPLE_SSO_ENABLED" in process.env || false, + SimpleSSONoLogin: "SIMPLE_SSO_NO_LOGIN" in process.env || false, + SimpleSSONoLoginRedirect: this.simpleSSO.noLoginRedirect(), + + // -------------------------------------------------------- + // Agent Skill Settings + // -------------------------------------------------------- + AgentSkillMaxToolCalls: AIbitat.defaultMaxToolCalls(), + AgentSkillRerankerEnabled: ToolReranker.isEnabled(), + AgentSkillRerankerTopN: ToolReranker.getTopN(), + AgentClarifyingQuestionsEnabled: + (await this.getValueOrFallback( + { label: "agent_clarifying_questions_enabled" }, + "false" + )) === "true", + AgentClarifyingQuestionsMaxPerTurn: Number( + (await this.getValueOrFallback( + { label: "agent_clarifying_questions_max_per_turn" }, + "3" + )) || 3 + ), + }; + }, + + get: async function (clause = {}) { + try { + const setting = await prisma.system_settings.findFirst({ where: clause }); + return setting || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + getValueOrFallback: async function (clause = {}, fallback = null) { + try { + return (await this.get(clause))?.value ?? fallback; + } catch (error) { + console.error(error.message); + return fallback; + } + }, + + where: async function (clause = {}, limit) { + try { + const settings = await prisma.system_settings.findMany({ + where: clause, + take: limit || undefined, + }); + return settings; + } catch (error) { + console.error(error.message); + return []; + } + }, + + // Can take generic keys and will pre-filter invalid keys + // from the set before sending to the explicit update function + // that will then enforce validations as well. + updateSettings: async function (updates = {}) { + const validFields = Object.keys(updates).filter((key) => + this.supportedFields.includes(key) + ); + + Object.entries(updates).forEach(([key]) => { + if (validFields.includes(key)) return; + delete updates[key]; + }); + + return this._updateSettings(updates); + }, + + delete: async function (clause = {}) { + try { + if (!Object.keys(clause).length) + throw new Error("Clause cannot be empty"); + await prisma.system_settings.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + // Explicit update of settings + key validations. + // Only use this method when directly setting a key value + // that takes no user input for the keys being modified. + _updateSettings: async function (updates = {}) { + try { + const updatePromises = []; + for (const key of Object.keys(updates)) { + let validatedValue = updates[key]; + if (this.validations.hasOwnProperty(key)) { + if (this.validations[key].constructor.name === "AsyncFunction") { + validatedValue = await this.validations[key](updates[key]); + } else { + validatedValue = this.validations[key](updates[key]); + } + } + + if (validatedValue === undefined) continue; + + updatePromises.push( + prisma.system_settings.upsert({ + where: { label: key }, + update: { + value: validatedValue === null ? null : String(validatedValue), + }, + create: { + label: key, + value: validatedValue === null ? null : String(validatedValue), + }, + }) + ); + } + + await Promise.all(updatePromises); + return { success: true, error: null }; + } catch (error) { + console.error("FAILED TO UPDATE SYSTEM SETTINGS", error.message); + return { success: false, error: error.message }; + } + }, + + isMultiUserMode: async function () { + try { + const setting = await this.get({ label: "multi_user_mode" }); + return setting?.value === "true"; + } catch (error) { + console.error(error.message); + return false; + } + }, + + memoriesEnabled: async function () { + try { + const setting = await this.get({ label: "memory_enabled" }); + return setting?.value === "true"; + } catch (error) { + console.error(error.message); + return false; + } + }, + + autoMemoriesEnabled: async function () { + try { + if (!(await this.memoriesEnabled())) return false; + const setting = await this.get({ label: "memory_auto_extraction" }); + return !setting || setting.value === "true"; + } catch (error) { + console.error(error.message); + return false; + } + }, + + memoryAutoExtractionSetting: async function () { + try { + const setting = await this.get({ label: "memory_auto_extraction" }); + return !setting || setting.value === "true"; + } catch (error) { + console.error(error.message); + return true; + } + }, + + isOnboardingComplete: async function () { + try { + const setting = await this.get({ label: "onboarding_complete" }); + return setting?.value === "true"; + } catch (error) { + console.error(error.message); + return false; + } + }, + + markOnboardingComplete: async function () { + try { + await this._updateSettings({ onboarding_complete: true }); + const { Telemetry } = require("./telemetry"); + await Telemetry.sendTelemetry("onboarding_complete"); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + currentLogoFilename: async function () { + try { + const setting = await this.get({ label: "logo_filename" }); + return setting?.value || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + hasEmbeddings: async function () { + try { + const { Document } = require("./documents"); + const count = await Document.count({}, 1); + return count > 0; + } catch (error) { + console.error(error.message); + return false; + } + }, + + vectorDBPreferenceKeys: function () { + return { + // Pinecone DB Keys + PineConeKey: !!process.env.PINECONE_API_KEY, + PineConeIndex: process.env.PINECONE_INDEX, + + // Chroma DB Keys + ChromaEndpoint: process.env.CHROMA_ENDPOINT, + ChromaApiHeader: process.env.CHROMA_API_HEADER, + ChromaApiKey: !!process.env.CHROMA_API_KEY, + + // ChromaCloud DB Keys + ChromaCloudApiKey: !!process.env.CHROMACLOUD_API_KEY, + ChromaCloudTenant: process.env.CHROMACLOUD_TENANT, + ChromaCloudDatabase: process.env.CHROMACLOUD_DATABASE, + + // Weaviate DB Keys + WeaviateEndpoint: process.env.WEAVIATE_ENDPOINT, + WeaviateApiKey: !!process.env.WEAVIATE_API_KEY, + + // QDrant DB Keys + QdrantEndpoint: process.env.QDRANT_ENDPOINT, + QdrantApiKey: !!process.env.QDRANT_API_KEY, + + // Milvus DB Keys + MilvusAddress: process.env.MILVUS_ADDRESS, + MilvusUsername: process.env.MILVUS_USERNAME, + MilvusPassword: !!process.env.MILVUS_PASSWORD, + + // Zilliz DB Keys + ZillizEndpoint: process.env.ZILLIZ_ENDPOINT, + ZillizApiToken: !!process.env.ZILLIZ_API_TOKEN, + + // AstraDB Keys + AstraDBApplicationToken: !!process?.env?.ASTRA_DB_APPLICATION_TOKEN, + AstraDBEndpoint: process?.env?.ASTRA_DB_ENDPOINT, + + // PGVector Keys + PGVectorConnectionString: !!PGVector.connectionString() || false, + PGVectorTableName: PGVector.tableName(), + }; + }, + + llmPreferenceKeys: function () { + return { + // OpenAI Keys + OpenAiKey: !!process.env.OPEN_AI_KEY, + OpenAiModelPref: process.env.OPEN_MODEL_PREF || "gpt-4.1-nano", + + // Azure + OpenAI Keys + AzureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT, + AzureOpenAiKey: !!process.env.AZURE_OPENAI_KEY, + AzureOpenAiModelPref: + process.env.AZURE_OPENAI_MODEL_PREF || process.env.OPEN_MODEL_PREF, + AzureOpenAiEmbeddingModelPref: process.env.EMBEDDING_MODEL_PREF, + AzureOpenAiTokenLimit: process.env.AZURE_OPENAI_TOKEN_LIMIT || 4096, + AzureOpenAiModelType: process.env.AZURE_OPENAI_MODEL_TYPE || "default", + + // Anthropic Keys + AnthropicApiKey: !!process.env.ANTHROPIC_API_KEY, + AnthropicModelPref: + process.env.ANTHROPIC_MODEL_PREF || "claude-sonnet-4-6", + AnthropicCacheControl: process.env.ANTHROPIC_CACHE_CONTROL || "none", + + // Gemini Keys + GeminiLLMApiKey: !!process.env.GEMINI_API_KEY, + GeminiLLMModelPref: + process.env.GEMINI_LLM_MODEL_PREF || "gemini-2.0-flash-lite", + GeminiSafetySetting: + process.env.GEMINI_SAFETY_SETTING || "BLOCK_MEDIUM_AND_ABOVE", + + // LMStudio Keys + LMStudioBasePath: process.env.LMSTUDIO_BASE_PATH, + LMStudioTokenLimit: process.env.LMSTUDIO_MODEL_TOKEN_LIMIT || null, + LMStudioModelPref: process.env.LMSTUDIO_MODEL_PREF, + LMStudioAuthToken: !!process.env.LMSTUDIO_AUTH_TOKEN, + + // LocalAI Keys + LocalAiApiKey: !!process.env.LOCAL_AI_API_KEY, + LocalAiBasePath: process.env.LOCAL_AI_BASE_PATH, + LocalAiModelPref: process.env.LOCAL_AI_MODEL_PREF, + LocalAiTokenLimit: process.env.LOCAL_AI_MODEL_TOKEN_LIMIT, + + // Ollama LLM Keys + OllamaLLMAuthToken: !!process.env.OLLAMA_AUTH_TOKEN, + OllamaLLMBasePath: process.env.OLLAMA_BASE_PATH, + OllamaLLMModelPref: process.env.OLLAMA_MODEL_PREF, + OllamaLLMTokenLimit: process.env.OLLAMA_MODEL_TOKEN_LIMIT || null, + OllamaLLMKeepAliveSeconds: process.env.OLLAMA_KEEP_ALIVE_TIMEOUT ?? 300, + + // Novita LLM Keys + NovitaLLMApiKey: !!process.env.NOVITA_LLM_API_KEY, + NovitaLLMModelPref: process.env.NOVITA_LLM_MODEL_PREF, + NovitaLLMTimeout: process.env.NOVITA_LLM_TIMEOUT_MS, + + // TogetherAI Keys + TogetherAiApiKey: !!process.env.TOGETHER_AI_API_KEY, + TogetherAiModelPref: process.env.TOGETHER_AI_MODEL_PREF, + + // Fireworks AI API Keys + FireworksAiLLMApiKey: !!process.env.FIREWORKS_AI_LLM_API_KEY, + FireworksAiLLMModelPref: process.env.FIREWORKS_AI_LLM_MODEL_PREF, + + // Perplexity AI Keys + PerplexityApiKey: !!process.env.PERPLEXITY_API_KEY, + PerplexityModelPref: process.env.PERPLEXITY_MODEL_PREF, + + // OpenRouter Keys + OpenRouterApiKey: !!process.env.OPENROUTER_API_KEY, + OpenRouterModelPref: process.env.OPENROUTER_MODEL_PREF, + OpenRouterTimeout: process.env.OPENROUTER_TIMEOUT_MS, + + // Mistral AI (API) Keys + MistralApiKey: !!process.env.MISTRAL_API_KEY, + MistralModelPref: process.env.MISTRAL_MODEL_PREF, + + // Groq AI API Keys + GroqApiKey: !!process.env.GROQ_API_KEY, + GroqModelPref: process.env.GROQ_MODEL_PREF, + + // KoboldCPP Keys + KoboldCPPModelPref: process.env.KOBOLD_CPP_MODEL_PREF, + KoboldCPPBasePath: process.env.KOBOLD_CPP_BASE_PATH, + KoboldCPPTokenLimit: process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT, + KoboldCPPMaxTokens: process.env.KOBOLD_CPP_MAX_TOKENS, + + // Text Generation Web UI Keys + TextGenWebUIBasePath: process.env.TEXT_GEN_WEB_UI_BASE_PATH, + TextGenWebUITokenLimit: process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT, + TextGenWebUIAPIKey: !!process.env.TEXT_GEN_WEB_UI_API_KEY, + + // LiteLLM Keys + LiteLLMModelPref: process.env.LITE_LLM_MODEL_PREF, + LiteLLMTokenLimit: process.env.LITE_LLM_MODEL_TOKEN_LIMIT, + LiteLLMBasePath: process.env.LITE_LLM_BASE_PATH, + LiteLLMApiKey: !!process.env.LITE_LLM_API_KEY, + + // Moonshot AI Keys + MoonshotAiApiKey: !!process.env.MOONSHOT_AI_API_KEY, + MoonshotAiModelPref: + process.env.MOONSHOT_AI_MODEL_PREF || "moonshot-v1-32k", + + // Generic OpenAI Keys + GenericOpenAiBasePath: process.env.GENERIC_OPEN_AI_BASE_PATH, + GenericOpenAiModelPref: process.env.GENERIC_OPEN_AI_MODEL_PREF, + GenericOpenAiTokenLimit: process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT, + GenericOpenAiKey: !!process.env.GENERIC_OPEN_AI_API_KEY, + GenericOpenAiMaxTokens: process.env.GENERIC_OPEN_AI_MAX_TOKENS, + + // Foundry Keys + FoundryBasePath: process.env.FOUNDRY_BASE_PATH, + FoundryModelPref: process.env.FOUNDRY_MODEL_PREF, + FoundryModelTokenLimit: process.env.FOUNDRY_MODEL_TOKEN_LIMIT, + + AwsBedrockLLMApiKey: !!process.env.AWS_BEDROCK_LLM_API_KEY, + AwsBedrockLLMRegion: process.env.AWS_BEDROCK_LLM_REGION, + AwsBedrockLLMModel: process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE, + AwsBedrockLLMTokenLimit: + process.env.AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT || 8192, + + // Cohere API Keys + CohereApiKey: !!process.env.COHERE_API_KEY, + CohereModelPref: process.env.COHERE_MODEL_PREF, + + // DeepSeek API Keys + DeepSeekApiKey: !!process.env.DEEPSEEK_API_KEY, + DeepSeekModelPref: process.env.DEEPSEEK_MODEL_PREF, + + // APIPie LLM API Keys + ApipieLLMApiKey: !!process.env.APIPIE_LLM_API_KEY, + ApipieLLMModelPref: process.env.APIPIE_LLM_MODEL_PREF, + + // xAI LLM API Keys + XAIApiKey: !!process.env.XAI_LLM_API_KEY, + XAIModelPref: process.env.XAI_LLM_MODEL_PREF, + + // NVIDIA NIM Keys + NvidiaNimLLMBasePath: process.env.NVIDIA_NIM_LLM_BASE_PATH, + NvidiaNimLLMModelPref: process.env.NVIDIA_NIM_LLM_MODEL_PREF, + NvidiaNimLLMTokenLimit: process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT, + + // PPIO API keys + PPIOApiKey: !!process.env.PPIO_API_KEY, + PPIOModelPref: process.env.PPIO_MODEL_PREF, + + // CometAPI LLM Keys + CometApiLLMApiKey: !!process.env.COMETAPI_LLM_API_KEY, + CometApiLLMModelPref: process.env.COMETAPI_LLM_MODEL_PREF, + CometApiLLMTimeout: process.env.COMETAPI_LLM_TIMEOUT_MS, + + // Z.AI Keys + ZAiApiKey: !!process.env.ZAI_API_KEY, + ZAiModelPref: process.env.ZAI_MODEL_PREF, + + // GiteeAI API Keys + GiteeAIApiKey: !!process.env.GITEE_AI_API_KEY, + GiteeAIModelPref: process.env.GITEE_AI_MODEL_PREF, + GiteeAITokenLimit: process.env.GITEE_AI_MODEL_TOKEN_LIMIT || 8192, + + // Docker Model Runner Keys + DockerModelRunnerBasePath: process.env.DOCKER_MODEL_RUNNER_BASE_PATH, + DockerModelRunnerModelPref: + process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_PREF, + DockerModelRunnerModelTokenLimit: + process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_TOKEN_LIMIT || 8192, + + // Privatemode Keys + PrivateModeBasePath: process.env.PRIVATEMODE_LLM_BASE_PATH, + PrivateModeModelPref: process.env.PRIVATEMODE_LLM_MODEL_PREF, + + // SambaNova Keys + SambaNovaLLMApiKey: !!process.env.SAMBANOVA_LLM_API_KEY, + SambaNovaLLMModelPref: process.env.SAMBANOVA_LLM_MODEL_PREF, + + // Lemonade Keys + LemonadeLLMBasePath: process.env.LEMONADE_LLM_BASE_PATH, + LemonadeLLMApiKey: !!process.env.LEMONADE_LLM_API_KEY, + LemonadeLLMModelPref: process.env.LEMONADE_LLM_MODEL_PREF, + LemonadeLLMModelTokenLimit: + process.env.LEMONADE_LLM_MODEL_TOKEN_LIMIT || 8192, + + // Minimax Keys + MinimaxApiKey: !!process.env.MINIMAX_API_KEY, + MinimaxModelPref: process.env.MINIMAX_MODEL_PREF, + + // Cerebras Keys + CerebrasApiKey: !!process.env.CEREBRAS_API_KEY, + CerebrasModelPref: process.env.CEREBRAS_MODEL_PREF, + }; + }, + + agent_sql_connections: async function () { + const setting = await SystemSettings.get({ + label: "agent_sql_connections", + }); + if (!setting) return []; + + const connections = safeJsonParse(setting.value, []).map((conn) => { + let scheme = conn.engine; + if (scheme === "sql-server") scheme = "mssql"; + if (scheme === "postgresql") scheme = "postgres"; + const parser = new ConnectionStringParser({ scheme }); + + const parsed = parser.parse(conn.connectionString); + return { + ...conn, + username: parsed.username, + password: parsed.password, + host: parsed.hosts?.[0]?.host, + port: parsed.hosts?.[0]?.port, + database: parsed.endpoint, + scheme: parsed.scheme, + }; + }); + + return connections; + }, + getFeatureFlags: async function () { + return { + experimental_live_file_sync: + (await SystemSettings.get({ label: "experimental_live_file_sync" })) + ?.value === "enabled", + }; + }, + + /** + * Get user configured Community Hub Settings + * Connection key is used to authenticate with the Community Hub API + * for your account. + * @returns {Promise<{connectionKey: string}>} + */ + hubSettings: async function () { + try { + const hubKey = await this.get({ label: "hub_api_key" }); + return { connectionKey: hubKey?.value || null }; + } catch (error) { + console.error(error.message); + return { connectionKey: null }; + } + }, + + simpleSSO: { + /** + * Gets the no login redirect URL. If the conditions below are not met, this will return null. + * - If simple SSO is not enabled. + * - If simple SSO login page is not disabled. + * - If the no login redirect is not a valid URL or is not set. + * @returns {string | null} + */ + noLoginRedirect: () => { + if (!("SIMPLE_SSO_ENABLED" in process.env)) return null; // if simple SSO is not enabled, return null + if (!("SIMPLE_SSO_NO_LOGIN" in process.env)) return null; // if the no login config is not set, return null + if (!("SIMPLE_SSO_NO_LOGIN_REDIRECT" in process.env)) return null; // if the no login redirect is not set, return null + + try { + let url = new URL(process.env.SIMPLE_SSO_NO_LOGIN_REDIRECT); + return url.toString(); + } catch {} + + // if the no login redirect is not a valid URL or is not set, return null + return null; + }, + }, +}; + +/** + * Merges SQL connection updates from the frontend with existing backend connections. + * Processes three types of actions: "remove", "update", and "add". + * + * @param {Array} existingConnections - Current connections stored in the database + * @param {Array} updates - Connection updates from frontend, each with an action property + * @returns {Array} - The merged connections array + */ +function mergeConnections(existingConnections = [], updates = []) { + const connectionsMap = new Map( + existingConnections.map((conn) => [conn.database_id, conn]) + ); + + for (const update of updates) { + const { + action, + database_id, + originalDatabaseId, + connectionString, + engine, + schema, + } = update; + + switch (action) { + case "remove": { + connectionsMap.delete(database_id); + break; + } + case "update": { + if (!connectionString) continue; + const newId = slugify(database_id); + + // Verify original connection exists + if (!connectionsMap.has(originalDatabaseId)) { + console.warn( + `[mergeConnections] Update skipped: Original connection "${originalDatabaseId}" not found` + ); + break; + } + + // Check for name conflict (excluding the one being updated) + if (newId !== originalDatabaseId && connectionsMap.has(newId)) { + console.warn( + `[mergeConnections] Update skipped: New name "${newId}" conflicts with existing connection` + ); + break; + } + + // Remove old and add updated connection + connectionsMap.delete(originalDatabaseId); + connectionsMap.set(newId, { + engine, + database_id: newId, + connectionString, + ...(schema && { schema }), + }); + break; + } + + case "add": { + if (!connectionString) continue; + const slugifiedId = slugify(database_id); + + // Skip if already exists + if (connectionsMap.has(slugifiedId)) { + console.warn( + `[mergeConnections] Add skipped: Connection "${slugifiedId}" already exists` + ); + break; + } + + connectionsMap.set(slugifiedId, { + engine, + database_id: slugifiedId, + connectionString, + ...(schema && { schema }), + }); + break; + } + + default: { + if (!action) continue; + throw new Error("SQL connection update contains an invalid action."); + } + } + } + + return Array.from(connectionsMap.values()); +} + +module.exports.SystemSettings = SystemSettings; diff --git a/server/models/telemetry.js b/server/models/telemetry.js new file mode 100644 index 0000000..266e4d3 --- /dev/null +++ b/server/models/telemetry.js @@ -0,0 +1,148 @@ +const { v4 } = require("uuid"); +const { SystemSettings } = require("./systemSettings"); + +// Map of events and last sent time to check if the event is on cooldown +// This will be cleared on server restart - but that is fine since it is mostly to just +// prevent spamming the logs. +const TelemetryCooldown = new Map(); + +const Telemetry = { + // Write-only key. It can't read events or any of your other data, so it's safe to use in public apps. + pubkey: "phc_9qu7QLpV8L84P3vFmEiZxL020t2EqIubP7HHHxrSsqS", + stubDevelopmentEvents: true, // [DO NOT TOUCH] Core team only. + label: "telemetry_id", + /* + Key value pairs of events that should be debounced to prevent spamming the logs. + This should be used for events that could be triggered in rapid succession that are not useful to atomically log. + The value is the number of seconds to debounce the event + */ + debounced: { + sent_chat: 1800, + agent_chat_sent: 1800, + agent_chat_started: 1800, + agent_tool_call: 1800, + + // Document mgmt events + document_uploaded: 30, + documents_embedded_in_workspace: 30, + link_uploaded: 30, + raw_document_uploaded: 30, + document_parsed: 30, + agent_generated_file_downloaded: 30, + }, + + id: async function () { + const result = await SystemSettings.get({ label: this.label }); + return result?.value || null; + }, + + connect: async function () { + const client = this.client(); + const distinctId = await this.findOrCreateId(); + return { client, distinctId }; + }, + + isDev: function () { + return process.env.NODE_ENV === "development" && this.stubDevelopmentEvents; + }, + + client: function () { + if (process.env.DISABLE_TELEMETRY === "true" || this.isDev()) return null; + const { PostHog } = require("posthog-node"); + return new PostHog(this.pubkey); + }, + + runtime: function () { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") return "docker"; + if (process.env.NODE_ENV === "production") return "production"; + return "other"; + }, + + /** + * Checks if the event is on cooldown + * @param {string} event - The event to check + * @returns {boolean} - True if the event is on cooldown, false otherwise + */ + isOnCooldown: function (event) { + // If the event is not debounced, return false + if (!this.debounced[event]) return false; + + // If the event is not in the cooldown map, return false + const lastSent = TelemetryCooldown.get(event); + if (!lastSent) return false; + + // If the event is in the cooldown map, check if it has expired + const now = Date.now(); + const cooldown = this.debounced[event] * 1000; + return now - lastSent < cooldown; + }, + + /** + * Marks the event as on cooldown - will check if the event is debounced first + * @param {string} event - The event to mark + */ + markOnCooldown: function (event) { + if (!this.debounced[event]) return; + TelemetryCooldown.set(event, Date.now()); + }, + + sendTelemetry: async function ( + event, + eventProperties = {}, + subUserId = null, + silent = false + ) { + try { + const { client, distinctId: systemId } = await this.connect(); + if (!client) return; + const distinctId = !!subUserId ? `${systemId}::${subUserId}` : systemId; + const properties = { ...eventProperties, runtime: this.runtime() }; + + // If the event is on cooldown, return + if (this.isOnCooldown(event)) return; + + // Silence some events to keep logs from being too messy in production + // eg: Tool calls from agents spamming the logs. + if (!silent) { + console.log(`\x1b[32m[TELEMETRY SENT]\x1b[0m`, { + event, + distinctId, + properties, + }); + } + + client.capture({ + event, + distinctId, + properties, + }); + } catch { + return; + } finally { + // Mark the event as on cooldown if needed + this.markOnCooldown(event); + } + }, + + flush: async function () { + const client = this.client(); + if (!client) return; + await client.shutdownAsync(); + }, + + setUid: async function () { + const newId = v4(); + await SystemSettings._updateSettings({ [this.label]: newId }); + return newId; + }, + + findOrCreateId: async function () { + let currentId = await this.id(); + if (currentId) return currentId; + + currentId = await this.setUid(); + return currentId; + }, +}; + +module.exports = { Telemetry }; diff --git a/server/models/temporaryAuthToken.js b/server/models/temporaryAuthToken.js new file mode 100644 index 0000000..327c69b --- /dev/null +++ b/server/models/temporaryAuthToken.js @@ -0,0 +1,104 @@ +const { makeJWT } = require("../utils/http"); +const prisma = require("../utils/prisma"); + +/** + * Temporary auth tokens are used for simple SSO. + * They simply enable the ability for a time-based token to be used in the query of the /sso/login URL + * to login as a user without the need of a username and password. These tokens are single-use and expire. + */ +const TemporaryAuthToken = { + expiry: 1000 * 60 * 6, // 1 hour + tablename: "temporary_auth_tokens", + writable: [], + + makeTempToken: () => { + const uuidAPIKey = require("uuid-apikey"); + return `allm-tat-${uuidAPIKey.create().apiKey}`; + }, + + /** + * Issues a temporary auth token for a user via its ID. + * @param {number} userId + * @returns {Promise<{token: string|null, error: string | null}>} + */ + issue: async function (userId = null) { + if (!userId) + throw new Error("User ID is required to issue a temporary auth token."); + await this.invalidateUserTokens(userId); + + try { + const token = this.makeTempToken(); + const expiresAt = new Date(Date.now() + this.expiry); + await prisma.temporary_auth_tokens.create({ + data: { + token, + expiresAt, + userId: Number(userId), + }, + }); + + return { token, error: null }; + } catch (error) { + console.error("FAILED TO CREATE TEMPORARY AUTH TOKEN.", error.message); + return { token: null, error: error.message }; + } + }, + + /** + * Invalidates (deletes) all temporary auth tokens for a user via their ID. + * @param {number} userId + * @returns {Promise} + */ + invalidateUserTokens: async function (userId) { + if (!userId) + throw new Error( + "User ID is required to invalidate temporary auth tokens." + ); + await prisma.temporary_auth_tokens.deleteMany({ + where: { userId: Number(userId) }, + }); + return true; + }, + + /** + * Validates a temporary auth token and returns the session token + * to be set in the browser localStorage for authentication. + * @param {string} publicToken - the token to validate against + * @returns {Promise<{sessionToken: string|null, token: import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | null, error: string | null}>} + */ + validate: async function (publicToken = "") { + /** @type {import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | undefined | null} **/ + let token; + + try { + if (!publicToken) + throw new Error( + "Public token is required to validate a temporary auth token." + ); + token = await prisma.temporary_auth_tokens.findUnique({ + where: { token: String(publicToken) }, + include: { user: true }, + }); + if (!token) throw new Error("Invalid token."); + if (token.expiresAt < new Date()) throw new Error("Token expired."); + if (token.user.suspended) throw new Error("User account suspended."); + + // Create a new session token for the user valid for 30 days + const sessionToken = makeJWT( + { id: token.user.id, username: token.user.username }, + process.env.JWT_EXPIRY + ); + + return { sessionToken, token, error: null }; + } catch (error) { + console.error("FAILED TO VALIDATE TEMPORARY AUTH TOKEN.", error.message); + return { sessionToken: null, token: null, error: error.message }; + } finally { + // Delete the token after it has been used under all circumstances if it was retrieved + if (token) + await prisma.temporary_auth_tokens.delete({ where: { id: token.id } }); + } + }, +}; + +module.exports = { TemporaryAuthToken }; diff --git a/server/models/user.js b/server/models/user.js new file mode 100644 index 0000000..917e50c --- /dev/null +++ b/server/models/user.js @@ -0,0 +1,381 @@ +const { Prisma } = require("@prisma/client"); +const prisma = require("../utils/prisma"); +const { EventLogs } = require("./eventLogs"); + +/** + * @typedef {Object} User + * @property {number} id + * @property {string} username + * @property {string} password + * @property {string} pfpFilename + * @property {string} role + * @property {boolean} suspended + * @property {number|null} dailyMessageLimit + */ + +const User = { + usernameRegex: new RegExp(/^[a-z][a-z0-9._@-]*$/), + writable: [ + // Used for generic updates so we can validate keys in request body + "username", + "password", + "pfpFilename", + "role", + "suspended", + "dailyMessageLimit", + "bio", + ], + validations: { + /** + * Unix-style username regex: + * - Must start with a lowercase letter + * - Can contain lowercase letters, digits, underscores, hyphens, @ signs, and periods + * - 2-64 characters long + */ + username: (newValue = "") => { + try { + const username = String(newValue); + if (username.length > 64) + throw new Error("Username cannot be longer than 64 characters"); + if (username.length < 2) + throw new Error("Username must be at least 2 characters"); + if (!User.usernameRegex.test(username)) + throw new Error( + "Username must start with a lowercase letter and only contain lowercase letters, numbers, underscores, hyphens, and periods" + ); + return username; + } catch (e) { + throw new Error(e.message); + } + }, + role: (role = "default") => { + const VALID_ROLES = ["default", "admin", "manager"]; + if (!VALID_ROLES.includes(role)) { + throw new Error( + `Invalid role. Allowed roles are: ${VALID_ROLES.join(", ")}` + ); + } + return String(role); + }, + dailyMessageLimit: (dailyMessageLimit = null) => { + if (dailyMessageLimit === null) return null; + const limit = Number(dailyMessageLimit); + if (isNaN(limit) || limit < 1) { + throw new Error( + "Daily message limit must be null or a number greater than or equal to 1" + ); + } + return limit; + }, + bio: (bio = "") => { + if (!bio || typeof bio !== "string") return ""; + if (bio.length > 1000) + throw new Error("Bio cannot be longer than 1,000 characters"); + return String(bio); + }, + }, + // validations for the above writable fields. + castColumnValue: function (key, value) { + switch (key) { + case "suspended": + return Number(Boolean(value)); + case "dailyMessageLimit": + return value === null ? null : Number(value); + default: + return String(value); + } + }, + + filterFields: function (user = {}) { + const { + password: _password, + web_push_subscription_config: _web_push_subscription_config, + ...rest + } = user; + return { ...rest }; + }, + _identifyErrorAndFormatMessage: function (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + // P2002 is the unique constraint violation error code + if (error.code === "P2002") { + const target = error.meta?.target; + return `A user with that ${target?.join(", ")} already exists`; + } + } + return error.message; + }, + + create: async function ({ + username, + password, + role = "default", + dailyMessageLimit = null, + bio = "", + }) { + const passwordCheck = this.checkPasswordComplexity(password); + if (!passwordCheck.checkedOK) { + return { user: null, error: passwordCheck.error }; + } + + try { + // Validate username format (validation function handles all checks) + const validatedUsername = this.validations.username(username); + + const bcrypt = require("bcryptjs"); + const hashedPassword = bcrypt.hashSync(password, 10); + const user = await prisma.users.create({ + data: { + username: validatedUsername, + password: hashedPassword, + role: this.validations.role(role), + bio: this.validations.bio(bio), + dailyMessageLimit: + this.validations.dailyMessageLimit(dailyMessageLimit), + }, + }); + return { user: this.filterFields(user), error: null }; + } catch (error) { + console.error("FAILED TO CREATE USER.", error.message); + return { user: null, error: this._identifyErrorAndFormatMessage(error) }; + } + }, + // Log the changes to a user object, but omit sensitive fields + // that are not meant to be logged. + loggedChanges: function (updates, prev = {}) { + const changes = {}; + const sensitiveFields = ["password"]; + + Object.keys(updates).forEach((key) => { + if (!sensitiveFields.includes(key) && updates[key] !== prev[key]) { + changes[key] = `${prev[key]} => ${updates[key]}`; + } + }); + + return changes; + }, + + update: async function (userId, updates = {}) { + try { + if (!userId) throw new Error("No user id provided for update"); + const currentUser = await prisma.users.findUnique({ + where: { id: parseInt(userId) }, + }); + if (!currentUser) return { success: false, error: "User not found" }; + + // We previously had more lenient username validation, but now with more strict validation + // we dont want to break existing users by changing non-username fields. + // If they are not explictly changing the username, do not attempt to validate it. + if (updates.hasOwnProperty("username")) { + if (updates.username === currentUser.username) delete updates.username; + } + + // Removes non-writable fields for generic updates + // and force-casts to the proper type; + Object.entries(updates).forEach(([key, value]) => { + if (this.writable.includes(key)) { + if (this.validations.hasOwnProperty(key)) { + updates[key] = this.validations[key]( + this.castColumnValue(key, value) + ); + } else { + updates[key] = this.castColumnValue(key, value); + } + return; + } + delete updates[key]; + }); + + if (Object.keys(updates).length === 0) + return { success: false, error: "No valid updates applied." }; + + // Handle password specific updates + if (updates.hasOwnProperty("password")) { + const passwordCheck = this.checkPasswordComplexity(updates.password); + if (!passwordCheck.checkedOK) { + return { success: false, error: passwordCheck.error }; + } + const bcrypt = require("bcryptjs"); + updates.password = bcrypt.hashSync(updates.password, 10); + } + + const user = await prisma.users.update({ + where: { id: parseInt(userId) }, + data: updates, + }); + + await EventLogs.logEvent( + "user_updated", + { + username: user.username, + changes: this.loggedChanges(updates, currentUser), + }, + userId + ); + return { success: true, error: null }; + } catch (error) { + console.error("FAILED TO UPDATE USER.", error.message); + return { + success: false, + error: this._identifyErrorAndFormatMessage(error), + }; + } + }, + + /** + * Explicit direct update of user object. + * Only use this method when directly setting a key value + * that takes no user input for the keys being modified. + * @param {number} id - The id of the user to update. + * @param {Object} data - The data to update the user with. + * @returns {Promise} The updated user object. + */ + _update: async function (id = null, data = {}) { + if (!id) throw new Error("No user id provided for update"); + + try { + const user = await prisma.users.update({ + where: { id }, + data, + }); + return { user, message: null }; + } catch (error) { + console.error(error.message); + return { user: null, message: error.message }; + } + }, + + /** + * Get all users that match the given clause without filtering the fields. + * Internal use only - do not use this method for user-input flows + * @param {Object} clause - The clause to filter the users by. + * @param {number|null} limit - The maximum number of users to return. + * @returns {Promise>} The users that match the given clause. + */ + _where: async function (clause = {}, limit = null) { + try { + const users = await prisma.users.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + }); + return users; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Returns a user object based on the clause provided. + * @param {Object} clause - The clause to use to find the user. + * @returns {Promise} The user object or null if not found. + */ + get: async function (clause = {}) { + try { + const user = await prisma.users.findFirst({ where: clause }); + return user ? this.filterFields({ ...user }) : null; + } catch (error) { + console.error(error.message); + return null; + } + }, + // Returns user object with all fields + _get: async function (clause = {}) { + try { + const user = await prisma.users.findFirst({ where: clause }); + return user ? { ...user } : null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.users.count({ where: clause }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.users.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit = null) { + try { + const users = await prisma.users.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + }); + return users.map((usr) => this.filterFields(usr)); + } catch (error) { + console.error(error.message); + return []; + } + }, + + checkPasswordComplexity: function (passwordInput = "") { + const passwordComplexity = require("joi-password-complexity"); + // Can be set via ENV variable on boot. No frontend config at this time. + // Docs: https://www.npmjs.com/package/joi-password-complexity + const complexityOptions = { + min: process.env.PASSWORDMINCHAR || 8, + max: process.env.PASSWORDMAXCHAR || 250, + lowerCase: process.env.PASSWORDLOWERCASE || 0, + upperCase: process.env.PASSWORDUPPERCASE || 0, + numeric: process.env.PASSWORDNUMERIC || 0, + symbol: process.env.PASSWORDSYMBOL || 0, + // reqCount should be equal to how many conditions you are testing for (1-4) + requirementCount: process.env.PASSWORDREQUIREMENTS || 0, + }; + + const complexityCheck = passwordComplexity( + complexityOptions, + "password" + ).validate(passwordInput); + if (complexityCheck.hasOwnProperty("error")) { + let myError = ""; + let prepend = ""; + for (let i = 0; i < complexityCheck.error.details.length; i++) { + myError += prepend + complexityCheck.error.details[i].message; + prepend = ", "; + } + return { checkedOK: false, error: myError }; + } + + return { checkedOK: true, error: "No error." }; + }, + + /** + * Check if a user can send a chat based on their daily message limit. + * This limit is system wide and not per workspace and only applies to + * multi-user mode AND non-admin users. + * @param {User} user The user object record. + * @returns {Promise} True if the user can send a chat, false otherwise. + */ + canSendChat: async function (user) { + const { ROLES } = require("../utils/middleware/multiUserProtected"); + if (!user || user.dailyMessageLimit === null || user.role === ROLES.admin) + return true; + + const { WorkspaceChats } = require("./workspaceChats"); + const currentChatCount = await WorkspaceChats.count({ + user_id: user.id, + createdAt: { + gte: new Date(new Date() - 24 * 60 * 60 * 1000), // 24 hours + }, + }); + + return currentChatCount < user.dailyMessageLimit; + }, +}; + +module.exports = { User }; diff --git a/server/models/vectors.js b/server/models/vectors.js new file mode 100644 index 0000000..3653303 --- /dev/null +++ b/server/models/vectors.js @@ -0,0 +1,79 @@ +const prisma = require("../utils/prisma"); +const { Document } = require("./documents"); + +const DocumentVectors = { + bulkInsert: async function (vectorRecords = []) { + if (vectorRecords.length === 0) return; + + try { + const inserts = []; + vectorRecords.forEach((record) => { + inserts.push( + prisma.document_vectors.create({ + data: { + docId: record.docId, + vectorId: record.vectorId, + }, + }) + ); + }); + await prisma.$transaction(inserts); + return { documentsInserted: inserts.length }; + } catch (error) { + console.error("Bulk insert failed", error); + return { documentsInserted: 0 }; + } + }, + + where: async function (clause = {}, limit) { + try { + const results = await prisma.document_vectors.findMany({ + where: clause, + take: limit || undefined, + }); + return results; + } catch (error) { + console.error("Where query failed", error); + return []; + } + }, + + deleteForWorkspace: async function (workspaceId) { + const documents = await Document.forWorkspace(workspaceId); + const docIds = [...new Set(documents.map((doc) => doc.docId))]; + + try { + await prisma.document_vectors.deleteMany({ + where: { docId: { in: docIds } }, + }); + return true; + } catch (error) { + console.error("Delete for workspace failed", error); + return false; + } + }, + + deleteIds: async function (ids = []) { + try { + await prisma.document_vectors.deleteMany({ + where: { id: { in: ids } }, + }); + return true; + } catch (error) { + console.error("Delete IDs failed", error); + return false; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.document_vectors.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("Delete failed", error); + return false; + } + }, +}; + +module.exports = { DocumentVectors }; diff --git a/server/models/workspace.js b/server/models/workspace.js new file mode 100644 index 0000000..240da37 --- /dev/null +++ b/server/models/workspace.js @@ -0,0 +1,724 @@ +const prisma = require("../utils/prisma"); +const slugifyModule = require("slugify"); +const { Document } = require("./documents"); +const { WorkspaceUser } = require("./workspaceUsers"); +const { ROLES } = require("../utils/middleware/multiUserProtected"); +const { v4: uuidv4 } = require("uuid"); +const { User } = require("./user"); +const { PromptHistory } = require("./promptHistory"); +const { SystemSettings } = require("./systemSettings"); + +function isNullOrNaN(value) { + if (value === null) return true; + return isNaN(value); +} + +/** + * @typedef {Object} Workspace + * @property {number} id - The ID of the workspace + * @property {string} name - The name of the workspace + * @property {string} slug - The slug of the workspace + * @property {string} openAiPrompt - The OpenAI prompt of the workspace + * @property {string} openAiTemp - The OpenAI temperature of the workspace + * @property {number} openAiHistory - The OpenAI history of the workspace + * @property {number} similarityThreshold - The similarity threshold of the workspace + * @property {string} chatProvider - The chat provider of the workspace + * @property {string} chatModel - The chat model of the workspace + * @property {number} topN - The top N of the workspace + * @property {string} chatMode - The chat mode of the workspace + * @property {string} agentProvider - The agent provider of the workspace + * @property {string} agentModel - The agent model of the workspace + * @property {string} queryRefusalResponse - The query refusal response of the workspace + * @property {string} vectorSearchMode - The vector search mode of the workspace + */ + +const Workspace = { + VALID_CHAT_MODES: ["chat", "query", "automatic"], + defaultPrompt: SystemSettings.saneDefaultSystemPrompt, + + // Used for generic updates so we can validate keys in request body + // commented fields are not writable, but are available on the db object + writable: [ + "name", + // "slug", + // "vectorTag", + "openAiTemp", + "openAiHistory", + "lastUpdatedAt", + "openAiPrompt", + "similarityThreshold", + "chatProvider", + "chatModel", + "topN", + "chatMode", + // "pfpFilename", + "agentProvider", + "agentModel", + "queryRefusalResponse", + "vectorSearchMode", + "router_id", + ], + + validations: { + name: (value) => { + // If the name is not provided or is not a string then we will use a default name. + // as the name field is not nullable in the db schema or has a default value. + if (!value || typeof value !== "string") return "My Workspace"; + return String(value).slice(0, 255); + }, + openAiTemp: (value) => { + if (value === null || value === undefined) return null; + const temp = parseFloat(value); + if (isNullOrNaN(temp) || temp < 0) return null; + return temp; + }, + openAiHistory: (value) => { + if (value === null || value === undefined) return 20; + const history = parseInt(value); + if (isNullOrNaN(history)) return 20; + if (history < 0) return 0; + return history; + }, + similarityThreshold: (value) => { + if (value === null || value === undefined) return 0.25; + const threshold = parseFloat(value); + if (isNullOrNaN(threshold)) return 0.25; + if (threshold < 0) return 0.0; + if (threshold > 1) return 1.0; + return threshold; + }, + topN: (value) => { + if (value === null || value === undefined) return 4; + const n = parseInt(value); + if (isNullOrNaN(n)) return 4; + if (n < 1) return 1; + return n; + }, + chatMode: (value) => { + if (!value || !Workspace.VALID_CHAT_MODES.includes(value)) + return "automatic"; + return value; + }, + chatProvider: (value) => { + if (!value || typeof value !== "string" || value === "none") return null; + return String(value); + }, + chatModel: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + agentProvider: (value) => { + if (!value || typeof value !== "string" || value === "none") return null; + return String(value); + }, + agentModel: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + queryRefusalResponse: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + openAiPrompt: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + vectorSearchMode: (value) => { + if ( + !value || + typeof value !== "string" || + !["default", "rerank"].includes(value) + ) + return "default"; + return value; + }, + router_id: (value) => { + if ([null, undefined, "", "none"].includes(value)) return null; + const id = Number(value); + if (isNaN(id)) return null; + return id; + }, + }, + + /** + * The default Slugify module requires some additional mapping to prevent downstream issues + * with some vector db providers and instead of building a normalization method for every provider + * we can capture this on the table level to not have to worry about it. + * @param {...any} args - slugify args for npm package. + * @returns {string} + */ + slugify: function (...args) { + slugifyModule.extend({ + "+": " plus ", + "!": " bang ", + "@": " at ", + "*": " splat ", + ".": " dot ", + ":": "", + "~": "", + "(": "", + ")": "", + "'": "", + '"': "", + "|": "", + }); + return slugifyModule(...args); + }, + + /** + * Validate the fields for a workspace update. + * @param {Object} updates - The updates to validate - should be writable fields + * @returns {Object} The validated updates. Only valid fields are returned. + */ + validateFields: function (updates = {}) { + const validatedFields = {}; + for (const [key, value] of Object.entries(updates)) { + if (!this.writable.includes(key)) continue; + if (this.validations[key]) { + validatedFields[key] = this.validations[key](value); + } else { + // If there is no validation for the field then we will just pass it through. + validatedFields[key] = value; + } + } + return validatedFields; + }, + + /** + * Create a new workspace. + * @param {string} name - The name of the workspace. + * @param {number} creatorId - The ID of the user creating the workspace. + * @param {Object} additionalFields - Additional fields to apply to the workspace - will be validated. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the created workspace and an error message if applicable. + */ + new: async function (name = null, creatorId = null, additionalFields = {}) { + if (!name) return { workspace: null, message: "name cannot be null" }; + var slug = this.slugify(name, { lower: true }); + slug = slug || uuidv4(); + + const existingBySlug = await this.get({ slug }); + if (existingBySlug !== null) { + const slugSeed = Math.floor(10000000 + Math.random() * 90000000); + slug = this.slugify(`${name}-${slugSeed}`, { lower: true }); + } + + // If system prompt wasn't sent, apply the system default system prompt + if (!additionalFields.openAiPrompt) { + const defaultSystemPrompt = await SystemSettings.get({ + label: "default_system_prompt", + }); + additionalFields.openAiPrompt = !!defaultSystemPrompt?.value + ? defaultSystemPrompt.value + : this.defaultPrompt; + } + + try { + const workspace = await prisma.workspaces.create({ + data: { + name: this.validations.name(name), + chatMode: "automatic", + ...this.validateFields(additionalFields), + slug, + }, + }); + + // If created with a user then we need to create the relationship as well. + // If creating with an admin User it wont change anything because admins can + // view all workspaces anyway. + if (!!creatorId) await WorkspaceUser.create(creatorId, workspace.id); + return { workspace, message: null }; + } catch (error) { + console.error(error.message); + return { workspace: null, message: error.message }; + } + }, + + /** + * Update the settings for a workspace. Applies validations to the updates provided. + * @param {number} id - The ID of the workspace to update. + * @param {Object} updates - The data to update. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable. + */ + update: async function (id = null, updates = {}) { + if (!id) throw new Error("No workspace id provided for update"); + + const validatedUpdates = this.validateFields(updates); + if (Object.keys(validatedUpdates).length === 0) + return { workspace: { id }, message: "No valid fields to update!" }; + + // If the user unset the chatProvider we will need + // to then clear the chatModel as well to prevent confusion during + // LLM loading. + if (validatedUpdates?.chatProvider === "default") { + validatedUpdates.chatProvider = null; + validatedUpdates.chatModel = null; + } + + // When switching to anythingllm-router, chatModel is not used. + // When switching away from anythingllm-router, clear router_id. + if (validatedUpdates?.chatProvider === "anythingllm-router") { + validatedUpdates.chatModel = null; + } else if ( + validatedUpdates?.chatProvider && + validatedUpdates.chatProvider !== "anythingllm-router" + ) { + validatedUpdates.router_id = null; + } + + return this._update(id, validatedUpdates); + }, + + /** + * Direct update of workspace settings without any validation. + * @param {number} id - The ID of the workspace to update. + * @param {Object} data - The data to update. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable. + */ + _update: async function (id = null, data = {}) { + if (!id) throw new Error("No workspace id provided for update"); + + try { + const workspace = await prisma.workspaces.update({ + where: { id }, + data, + }); + return { workspace, message: null }; + } catch (error) { + console.error(error.message); + return { workspace: null, message: error.message }; + } + }, + + getWithUser: async function (user = null, clause = {}) { + if ([ROLES.admin, ROLES.manager].includes(user.role)) + return this.get(clause); + + try { + const workspace = await prisma.workspaces.findFirst({ + where: { + ...clause, + workspace_users: { + some: { + user_id: user?.id, + }, + }, + }, + include: { + workspace_users: true, + documents: true, + }, + }); + + if (!workspace) return null; + + return { + ...workspace, + documents: await Document.forWorkspace(workspace.id), + contextWindow: this._getContextWindow(workspace), + currentContextTokenCount: await this._getCurrentContextTokenCount( + workspace.id + ), + }; + } catch (error) { + console.error(error.message); + return null; + } + }, + + /** + * Get the total token count of all parsed files in a workspace/thread + * @param {number} workspaceId - The ID of the workspace + * @param {number|null} threadId - Optional thread ID to filter by + * @returns {Promise} Total token count of all files + * @private + */ + async _getCurrentContextTokenCount(workspaceId, threadId = null) { + const { WorkspaceParsedFiles } = require("./workspaceParsedFiles"); + return await WorkspaceParsedFiles.totalTokenCount({ + workspaceId: Number(workspaceId), + threadId: threadId ? Number(threadId) : null, + }); + }, + + /** + * Get the context window size for a workspace based on its provider and model settings. + * If the workspace has no provider/model set, falls back to system defaults. + * @param {Workspace} workspace - The workspace to get context window for + * @returns {number|null} The context window size in tokens (defaults to null if no provider/model found) + * @private + */ + _getContextWindow: function (workspace) { + const { + getLLMProviderClass, + getBaseLLMProviderModel, + } = require("../utils/helpers"); + const provider = workspace.chatProvider || process.env.LLM_PROVIDER || null; + const LLMProvider = getLLMProviderClass({ provider }); + const model = + workspace.chatModel || getBaseLLMProviderModel({ provider }) || null; + + if (!provider || !model) return null; + return LLMProvider?.promptWindowLimit?.(model) || null; + }, + + get: async function (clause = {}) { + try { + const workspace = await prisma.workspaces.findFirst({ + where: clause, + include: { + documents: true, + }, + }); + + if (!workspace) return null; + return { + ...workspace, + contextWindow: this._getContextWindow(workspace), + currentContextTokenCount: await this._getCurrentContextTokenCount( + workspace.id + ), + }; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.workspaces.delete({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const results = await prisma.workspaces.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithUser: async function ( + user, + clause = {}, + limit = null, + orderBy = null + ) { + if ([ROLES.admin, ROLES.manager].includes(user.role)) + return await this.where(clause, limit, orderBy); + + try { + const workspaces = await prisma.workspaces.findMany({ + where: { + ...clause, + workspace_users: { + some: { + user_id: user.id, + }, + }, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return workspaces; + } catch (error) { + console.error(error.message); + return []; + } + }, + + whereWithUsers: async function (clause = {}, limit = null, orderBy = null) { + try { + const workspaces = await this.where(clause, limit, orderBy); + for (const workspace of workspaces) { + const userIds = ( + await WorkspaceUser.where({ workspace_id: Number(workspace.id) }) + ).map((rel) => rel.user_id); + workspace.userIds = userIds; + } + return workspaces; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Get all users for a workspace. + * @param {number} workspaceId - The ID of the workspace to get users for. + * @returns {Promise>} A promise that resolves to an array of user objects. + */ + workspaceUsers: async function (workspaceId) { + try { + const users = ( + await WorkspaceUser.where({ workspace_id: Number(workspaceId) }) + ).map((rel) => rel); + + const usersById = await User.where({ + id: { in: users.map((user) => user.user_id) }, + }); + + const userInfo = usersById.map((user) => { + const workspaceUser = users.find((u) => u.user_id === user.id); + return { + userId: user.id, + username: user.username, + role: user.role, + lastUpdatedAt: workspaceUser.lastUpdatedAt, + }; + }); + + return userInfo; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Update the users for a workspace. Will remove all existing users and replace them with the new list. + * @param {number} workspaceId - The ID of the workspace to update. + * @param {number[]} userIds - An array of user IDs to add to the workspace. + * @returns {Promise<{success: boolean, error: string | null}>} A promise that resolves to an object containing the success status and an error message if applicable. + */ + updateUsers: async function (workspaceId, userIds = []) { + try { + await WorkspaceUser.delete({ workspace_id: Number(workspaceId) }); + await WorkspaceUser.createManyUsers(userIds, workspaceId); + return { success: true, error: null }; + } catch (error) { + console.error(error.message); + return { success: false, error: error.message }; + } + }, + + trackChange: async function (prevData, newData, user) { + try { + await this._trackWorkspacePromptChange(prevData, newData, user); + return; + } catch (error) { + console.error("Error tracking workspace change:", error.message); + return; + } + }, + + /** + * We are tracking this change to determine the need to a prompt library or + * prompt assistant feature. If this is something you would like to see - tell us on GitHub! + * We now track the prompt change in the PromptHistory model. + * which is a sub-model of the Workspace model. + * @param {Workspace} prevData - The previous data of the workspace. + * @param {Workspace} newData - The new data of the workspace. + * @param {{id: number, role: string}|null} user - The user who made the change. + * @returns {Promise} + */ + _trackWorkspacePromptChange: async function (prevData, newData, user = null) { + if ( + !!newData?.openAiPrompt && // new prompt is set + !!prevData?.openAiPrompt && // previous prompt was not null (default) + prevData?.openAiPrompt !== this.defaultPrompt && // previous prompt was not default + newData?.openAiPrompt !== prevData?.openAiPrompt // previous and new prompt are not the same + ) + await PromptHistory.handlePromptChange(prevData, user); // log the change to the prompt history + + const { Telemetry } = require("./telemetry"); + const { EventLogs } = require("./eventLogs"); + if ( + !newData?.openAiPrompt || // no prompt change + newData?.openAiPrompt === this.defaultPrompt || // new prompt is default prompt + newData?.openAiPrompt === prevData?.openAiPrompt // same prompt + ) + return; + + await Telemetry.sendTelemetry("workspace_prompt_changed"); + await EventLogs.logEvent( + "workspace_prompt_changed", + { + workspaceName: prevData?.name, + prevSystemPrompt: prevData?.openAiPrompt || this.defaultPrompt, + newSystemPrompt: newData?.openAiPrompt, + }, + user?.id + ); + return; + }, + + // Direct DB queries for API use only. + /** + * Generic prisma FindMany query for workspaces collections + * @param {import("../node_modules/.prisma/client/index.d.ts").Prisma.TypeMap['model']['workspaces']['operations']['findMany']['args']} prismaQuery + * @returns + */ + _findMany: async function (prismaQuery = {}) { + try { + const results = await prisma.workspaces.findMany(prismaQuery); + return results; + } catch (error) { + console.error(error.message); + return null; + } + }, + + /** + * Generic prisma query for .get of workspaces collections + * @param {import("../node_modules/.prisma/client/index.d.ts").Prisma.TypeMap['model']['workspaces']['operations']['findFirst']['args']} prismaQuery + * @returns + */ + _findFirst: async function (prismaQuery = {}) { + try { + const results = await prisma.workspaces.findFirst(prismaQuery); + return results; + } catch (error) { + console.error(error.message); + return null; + } + }, + + /** + * Upsert a workspace. + * If the workspace does not exist, it will be created. + * If the workspace exists, it will be updated (if data is provided). + * @param {Object} clause - The clause to upsert the workspace by. + * @param {Object} createData - The data to create the workspace with. + * @param {Object} updateData - The data to update the workspace with if it already exists. + * @returns {Promise<{workspace: import("@prisma/client").workspaces | null, error: string | null}>} A promise that resolves to an object containing the upserted workspace and an error message if applicable. + */ + upsert: async function (clause = {}, createData = {}, updateData = {}) { + try { + const workspace = await prisma.workspaces.upsert({ + where: clause, + update: updateData, + create: createData, + }); + return { workspace, error: null }; + } catch (error) { + console.error(error.message); + return { workspace: null, error: error.message }; + } + }, + + /** + * Get the prompt history for a workspace. + * @param {Object} options - The options to get prompt history for. + * @param {number} options.workspaceId - The ID of the workspace to get prompt history for. + * @returns {Promise>} A promise that resolves to an array of prompt history objects. + */ + promptHistory: async function ({ workspaceId }) { + try { + const results = await PromptHistory.forWorkspace(workspaceId); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * Delete the prompt history for a workspace. + * @param {Object} options - The options to delete the prompt history for. + * @param {number} options.workspaceId - The ID of the workspace to delete prompt history for. + * @returns {Promise} A promise that resolves to a boolean indicating the success of the operation. + */ + deleteAllPromptHistory: async function ({ workspaceId }) { + try { + return await PromptHistory.delete({ workspaceId }); + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Delete the prompt history for a workspace. + * @param {Object} options - The options to delete the prompt history for. + * @param {number} options.workspaceId - The ID of the workspace to delete prompt history for. + * @param {number} options.id - The ID of the prompt history to delete. + * @returns {Promise} A promise that resolves to a boolean indicating the success of the operation. + */ + deletePromptHistory: async function ({ workspaceId, id }) { + try { + return await PromptHistory.delete({ id, workspaceId }); + } catch (error) { + console.error(error.message); + return false; + } + }, + + /** + * Checks if the workspace's chat provider/model waterfall supports native tool calling. + * @param {Workspace} workspace - The workspace object to check + * @returns {Promise} + */ + supportsNativeToolCalling: async function (workspace = {}) { + if (!workspace) return false; + const { getBaseLLMProviderModel } = require("../utils/helpers"); + const AIbitat = require("../utils/agents/aibitat"); + const provider = + workspace?.agentProvider ?? + workspace?.chatProvider ?? + process.env.LLM_PROVIDER; + + // Model router delegates to a resolved provider at chat time. + // Check the router's fallback provider for tool calling support + // as a reasonable proxy for the router's capabilities. + if (provider === "anythingllm-router") { + const { ModelRouter } = require("./modelRouter"); + const routerId = + workspace?.router_id || + (process.env.MODEL_ROUTER_ID + ? Number(process.env.MODEL_ROUTER_ID) + : null); + if (!routerId) return false; + const router = await ModelRouter.get({ id: routerId }); + if (!router) return false; + const fallbackConfig = { + provider: router.fallback_provider, + model: router.fallback_model, + }; + const fallbackProvider = new AIbitat(fallbackConfig).getProviderForConfig( + fallbackConfig + ); + return (await fallbackProvider.supportsNativeToolCalling?.()) ?? false; + } + + const model = + workspace?.agentModel ?? + workspace?.chatModel ?? + getBaseLLMProviderModel({ provider }); + const agentConfig = { provider, model }; + const agentProvider = new AIbitat(agentConfig).getProviderForConfig( + agentConfig + ); + const nativeToolCalling = await agentProvider.supportsNativeToolCalling?.(); + return nativeToolCalling; + }, + + /** + * Checks if the agent command is available for a workspace + * by checking if the workspace's agent provider supports native tool calling. + * - If the workspaces chat provider/model supports native tool calling, then the agent command is NOT available + * as it will be assumed the model is capable of handling tool calls. + * Otherwise, the agent command is available and the user must opt-in to "@agent" to use tool calls. + * @param {Workspace} workspace - The workspace object to check + * @returns {Promise} + */ + isAgentCommandAvailable: async function (workspace) { + if (workspace.chatMode !== "automatic") return true; + const nativeToolCalling = await this.supportsNativeToolCalling(workspace); + return nativeToolCalling === false; + }, +}; + +module.exports = { Workspace }; diff --git a/server/models/workspaceAgentInvocation.js b/server/models/workspaceAgentInvocation.js new file mode 100644 index 0000000..b008337 --- /dev/null +++ b/server/models/workspaceAgentInvocation.js @@ -0,0 +1,97 @@ +const prisma = require("../utils/prisma"); +const { v4: uuidv4 } = require("uuid"); + +const WorkspaceAgentInvocation = { + // returns array of strings with their @ handle. + // must start with @agent for now. + parseAgents: function (promptString) { + if (!promptString.startsWith("@agent")) return []; + return promptString.split(/\s+/).filter((v) => v.startsWith("@")); + }, + + close: async function (uuid) { + if (!uuid) return; + try { + await prisma.workspace_agent_invocations.update({ + where: { uuid: String(uuid) }, + data: { closed: true }, + }); + } catch {} + }, + + new: async function ({ prompt, workspace, user = null, thread = null }) { + try { + const invocation = await prisma.workspace_agent_invocations.create({ + data: { + uuid: uuidv4(), + workspace_id: workspace.id, + prompt: String(prompt), + user_id: user?.id, + thread_id: thread?.id, + }, + }); + + return { invocation, message: null }; + } catch (error) { + console.error(error.message); + return { invocation: null, message: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const invocation = await prisma.workspace_agent_invocations.findFirst({ + where: clause, + }); + + return invocation || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + getWithWorkspace: async function (clause = {}) { + try { + const invocation = await prisma.workspace_agent_invocations.findFirst({ + where: clause, + include: { + workspace: true, + }, + }); + + return invocation || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.workspace_agent_invocations.delete({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function (clause = {}, limit = null, orderBy = null) { + try { + const results = await prisma.workspace_agent_invocations.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, +}; + +module.exports = { WorkspaceAgentInvocation }; diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js new file mode 100644 index 0000000..2597346 --- /dev/null +++ b/server/models/workspaceChats.js @@ -0,0 +1,386 @@ +const prisma = require("../utils/prisma"); +const { safeJSONStringify } = require("../utils/helpers/chat/responses"); + +const WorkspaceChats = { + new: async function ({ + workspaceId, + prompt, + response = {}, + user = null, + threadId = null, + include = true, + apiSessionId = null, + }) { + try { + const chat = await prisma.workspace_chats.create({ + data: { + workspaceId, + prompt, + response: safeJSONStringify(response), + user_id: user?.id || null, + thread_id: threadId, + api_session_id: apiSessionId, + include, + }, + }); + return { chat, message: null }; + } catch (error) { + console.error(error.message); + return { chat: null, message: error.message }; + } + }, + + forWorkspaceByUser: async function ( + workspaceId = null, + userId = null, + limit = null, + orderBy = null + ) { + if (!workspaceId || !userId) return []; + try { + const chats = await prisma.workspace_chats.findMany({ + where: { + workspaceId, + user_id: userId, + thread_id: null, // this function is now only used for the default thread on workspaces and users + api_session_id: null, // do not include api-session chats in the frontend for anyone. + include: true, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + forWorkspaceByApiSessionId: async function ( + workspaceId = null, + apiSessionId = null, + limit = null, + orderBy = null + ) { + if (!workspaceId || !apiSessionId) return []; + try { + const chats = await prisma.workspace_chats.findMany({ + where: { + workspaceId, + user_id: null, + api_session_id: String(apiSessionId), + thread_id: null, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + forWorkspace: async function ( + workspaceId = null, + limit = null, + orderBy = null + ) { + if (!workspaceId) return []; + try { + const chats = await prisma.workspace_chats.findMany({ + where: { + workspaceId, + thread_id: null, // this function is now only used for the default thread on workspaces + api_session_id: null, // do not include api-session chats in the frontend for anyone. + include: true, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + /** + * @deprecated Use markThreadHistoryInvalidV2 instead. + */ + markHistoryInvalid: async function (workspaceId = null, user = null) { + if (!workspaceId) return; + try { + await prisma.workspace_chats.updateMany({ + where: { + workspaceId, + user_id: user?.id, + thread_id: null, // this function is now only used for the default thread on workspaces + }, + data: { + include: false, + }, + }); + return; + } catch (error) { + console.error(error.message); + } + }, + + /** + * @deprecated Use markThreadHistoryInvalidV2 instead. + */ + markThreadHistoryInvalid: async function ( + workspaceId = null, + user = null, + threadId = null + ) { + if (!workspaceId || !threadId) return; + try { + await prisma.workspace_chats.updateMany({ + where: { + workspaceId, + thread_id: threadId, + user_id: user?.id, + }, + data: { + include: false, + }, + }); + return; + } catch (error) { + console.error(error.message); + } + }, + + /** + * @description This function is used to mark a thread's history as invalid. + * and works with an arbitrary where clause. + * @param {Object} whereClause - The where clause to update the chats. + * @param {Object} data - The data to update the chats with. + * @returns {Promise} + */ + markThreadHistoryInvalidV2: async function (whereClause = {}) { + if (!whereClause) return; + try { + await prisma.workspace_chats.updateMany({ + where: whereClause, + data: { + include: false, + }, + }); + return; + } catch (error) { + console.error(error.message); + } + }, + + get: async function (clause = {}, limit = null, orderBy = null) { + try { + const chat = await prisma.workspace_chats.findFirst({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return chat || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.workspace_chats.deleteMany({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + offset = null + ) { + try { + const chats = await prisma.workspace_chats.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(offset !== null ? { skip: offset } : {}), + ...(orderBy !== null ? { orderBy } : {}), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.workspace_chats.count({ + where: clause, + }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, + + whereWithData: async function ( + clause = {}, + limit = null, + offset = null, + orderBy = null + ) { + const { Workspace } = require("./workspace"); + const { User } = require("./user"); + + try { + const results = await this.where(clause, limit, orderBy, offset); + + for (const res of results) { + const workspace = await Workspace.get({ id: res.workspaceId }); + res.workspace = workspace + ? { name: workspace.name, slug: workspace.slug } + : { name: "deleted workspace", slug: null }; + + const user = res.user_id ? await User.get({ id: res.user_id }) : null; + res.user = user + ? { username: user.username } + : { username: res.api_session_id !== null ? "API" : "unknown user" }; + } + + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + updateFeedbackScore: async function (chatId = null, feedbackScore = null) { + if (!chatId) return; + try { + await prisma.workspace_chats.update({ + where: { + id: Number(chatId), + }, + data: { + feedbackScore: + feedbackScore === null ? null : Number(feedbackScore) === 1, + }, + }); + return; + } catch (error) { + console.error(error.message); + } + }, + + // Explicit update of settings + key validations. + // Only use this method when directly setting a key value + // that takes no user input for the keys being modified. + _update: async function (id = null, data = {}) { + if (!id) throw new Error("No workspace chat id provided for update"); + + try { + await prisma.workspace_chats.update({ + where: { id }, + data, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + markMemoryProcessed: async function (ids = []) { + if (!Array.isArray(ids) || ids.length === 0) return; + try { + const safeIds = ids.map(Number).filter(Number.isInteger); + if (safeIds.length === 0) return; + await prisma.workspace_chats.updateMany({ + where: { id: { in: safeIds } }, + data: { memoryProcessed: true }, + }); + } catch (error) { + console.error(error.message); + } + }, + + migrateToMultiUser: async function (adminUserId) { + try { + await prisma.workspace_chats.updateMany({ + where: { user_id: null }, + data: { user_id: adminUserId }, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + bulkCreate: async function (chatsData) { + // TODO: Replace with createMany when we update prisma to latest version + // The version of prisma that we are currently using does not support createMany with SQLite + try { + const createdChats = []; + for (const chatData of chatsData) { + const chat = await prisma.workspace_chats.create({ + data: chatData, + }); + createdChats.push(chat); + } + return { chats: createdChats, message: null }; + } catch (error) { + console.error(error.message); + return { chats: null, message: error.message }; + } + }, + upsert: async function ( + chatId = null, + data = { + workspaceId: null, + prompt: null, + response: {}, + user: null, + threadId: null, + include: true, + apiSessionId: null, + } + ) { + try { + const payload = { + workspaceId: data.workspaceId, + response: safeJSONStringify(data.response), + user_id: data.user?.id || null, + thread_id: data.threadId, + api_session_id: data.apiSessionId, + include: data.include, + }; + + const { chat } = await prisma.workspace_chats.upsert({ + where: { + id: Number(chatId), + user_id: data.user?.id || null, + }, + // On updates, we already have the prompt so we don't need to set it again. + update: { ...payload, lastUpdatedAt: new Date() }, + + // On creates, we need to set the prompt or else record will fail. + create: { ...payload, prompt: data.prompt }, + }); + return { chat, message: null }; + } catch (error) { + console.error(error.message); + return { chat: null, message: error.message }; + } + }, +}; + +module.exports = { WorkspaceChats }; diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js new file mode 100644 index 0000000..76e8075 --- /dev/null +++ b/server/models/workspaceParsedFiles.js @@ -0,0 +1,249 @@ +const prisma = require("../utils/prisma"); +const { EventLogs } = require("./eventLogs"); +const { Document } = require("./documents"); +const { documentsPath, directUploadsPath } = require("../utils/files"); +const { safeJsonParse } = require("../utils/http"); +const fs = require("fs"); +const path = require("path"); + +const WorkspaceParsedFiles = { + create: async function ({ + filename, + workspaceId, + userId = null, + threadId = null, + metadata = null, + tokenCountEstimate = 0, + }) { + try { + const file = await prisma.workspace_parsed_files.create({ + data: { + filename, + workspaceId: parseInt(workspaceId), + userId: userId ? parseInt(userId) : null, + threadId: threadId ? parseInt(threadId) : null, + metadata, + tokenCountEstimate, + }, + }); + + await EventLogs.logEvent( + "workspace_file_uploaded", + { + filename, + workspaceId, + }, + userId + ); + + return { file, error: null }; + } catch (error) { + console.error("FAILED TO CREATE PARSED FILE RECORD.", error.message); + return { file: null, error: error.message }; + } + }, + + /** + * Gets a parsed file by its ID or a clause. + * @param {object} clause - The clause to filter the parsed files. + * @returns {Promise} The parsed file. + */ + get: async function (clause = {}) { + try { + const file = await prisma.workspace_parsed_files.findFirst({ + where: clause, + }); + return file; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + select = null + ) { + try { + const files = await prisma.workspace_parsed_files.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(select !== null ? { select } : {}), + }); + return files; + } catch (error) { + console.error(error.message); + return []; + } + }, + + delete: async function (clause = {}) { + try { + const result = await prisma.workspace_parsed_files.deleteMany({ + where: clause, + }); + return result.count > 0; + } catch (error) { + console.error(error.message); + return false; + } + }, + + totalTokenCount: async function (clause = {}) { + const { _sum } = await prisma.workspace_parsed_files.aggregate({ + where: clause, + _sum: { tokenCountEstimate: true }, + }); + return _sum.tokenCountEstimate || 0; + }, + + /** + * Moves a parsed file to the documents and embeds it. + * @param {import("@prisma/client").users | null} user - The user performing the operation. + * @param {number} fileId - The ID of the parsed file. + * @param {import("@prisma/client").workspaces} workspace - The workspace the file belongs to. + * @returns {Promise<{ success: boolean, error: string | null, document: import("@prisma/client").workspace_documents | null }>} The result of the operation. + */ + moveToDocumentsAndEmbed: async function (user = null, fileId, workspace) { + try { + const parsedFile = await this.get({ + id: parseInt(fileId), + ...(user ? { userId: user.id } : {}), + workspaceId: workspace.id, + }); + if (!parsedFile) throw new Error("File not found"); + + // Get file location from metadata + const metadata = safeJsonParse(parsedFile.metadata, {}); + const location = metadata.location; + if (!location) throw new Error("No file location in metadata"); + + // Get file from metadata location + const sourceFile = path.join(directUploadsPath, path.basename(location)); + if (!fs.existsSync(sourceFile)) throw new Error("Source file not found"); + + // Move to custom-documents + const customDocsPath = path.join(documentsPath, "custom-documents"); + if (!fs.existsSync(customDocsPath)) + fs.mkdirSync(customDocsPath, { recursive: true }); + + // Copy the file to custom-documents + const targetPath = path.join(customDocsPath, path.basename(location)); + fs.copyFileSync(sourceFile, targetPath); + fs.unlinkSync(sourceFile); + + const { + failedToEmbed = [], + errors = [], + embedded = [], + } = await Document.addDocuments( + workspace, + [`custom-documents/${path.basename(location)}`], + parsedFile.userId + ); + + if (failedToEmbed.length > 0) + throw new Error(errors[0] || "Failed to embed document"); + + const document = await Document.get({ + workspaceId: workspace.id, + docpath: embedded[0], + }); + return { success: true, error: null, document }; + } catch (error) { + console.error("Failed to move and embed file:", error); + return { success: false, error: error.message, document: null }; + } finally { + await this.delete({ + id: parseInt(fileId), + ...(user ? { userId: user.id } : {}), + workspaceId: workspace.id, + }); + } + }, + + getContextMetadataAndLimits: async function ( + workspace, + thread = null, + user = null + ) { + try { + if (!workspace) throw new Error("Workspace is required"); + const files = await this.where({ + workspaceId: workspace.id, + threadId: thread?.id || null, + ...(user ? { userId: user.id } : {}), + }); + + const results = []; + let totalTokens = 0; + + for (const file of files) { + const metadata = safeJsonParse(file.metadata, {}); + totalTokens += file.tokenCountEstimate || 0; + results.push({ + id: file.id, + title: metadata.title || metadata.location, + location: metadata.location, + token_count_estimate: file.tokenCountEstimate, + }); + } + + return { + files: results, + contextWindow: workspace.contextWindow, + currentContextTokenCount: totalTokens, + }; + } catch (error) { + console.error("Failed to get context metadata:", error); + return { + files: [], + contextWindow: Infinity, + currentContextTokenCount: 0, + }; + } + }, + + getContextFiles: async function (workspace, thread = null, user = null) { + try { + const files = await this.where({ + workspaceId: workspace.id, + threadId: thread?.id || null, + ...(user ? { userId: user.id } : {}), + }); + + const results = []; + for (const file of files) { + const metadata = safeJsonParse(file.metadata, {}); + const location = metadata.location; + if (!location) continue; + + const sourceFile = path.join( + directUploadsPath, + path.basename(location) + ); + if (!fs.existsSync(sourceFile)) continue; + + const content = fs.readFileSync(sourceFile, "utf-8"); + const data = safeJsonParse(content, null); + if (!data?.pageContent) continue; + + results.push({ + pageContent: data.pageContent, + token_count_estimate: file.tokenCountEstimate, + ...metadata, + }); + } + + return results; + } catch (error) { + console.error("Failed to get context files:", error); + return []; + } + }, +}; + +module.exports = { WorkspaceParsedFiles }; diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js new file mode 100644 index 0000000..30e88fa --- /dev/null +++ b/server/models/workspaceThread.js @@ -0,0 +1,151 @@ +const prisma = require("../utils/prisma"); +const slugifyModule = require("slugify"); +const { v4: uuidv4 } = require("uuid"); +const truncate = require("truncate"); + +const WorkspaceThread = { + defaultName: "Thread", + writable: ["name"], + + /** + * The default Slugify module requires some additional mapping to prevent downstream issues + * if the user is able to define a slug externally. We have to block non-escapable URL chars + * so that is the slug is rendered it doesn't break the URL or UI when visited. + * @param {...any} args - slugify args for npm package. + * @returns {string} + */ + slugify: function (...args) { + slugifyModule.extend({ + "+": " plus ", + "!": " bang ", + "@": " at ", + "*": " splat ", + ".": " dot ", + ":": "", + "~": "", + "(": "", + ")": "", + "'": "", + '"': "", + "|": "", + }); + return slugifyModule(...args); + }, + + new: async function (workspace, userId = null, data = {}) { + try { + const thread = await prisma.workspace_threads.create({ + data: { + name: data.name ? String(data.name) : this.defaultName, + slug: data.slug + ? this.slugify(data.slug, { lowercase: true }) + : uuidv4(), + user_id: userId ? Number(userId) : null, + workspace_id: workspace.id, + }, + }); + + return { thread, message: null }; + } catch (error) { + console.error(error.message); + return { thread: null, message: error.message }; + } + }, + + update: async function (prevThread = null, data = {}) { + if (!prevThread) throw new Error("No thread id provided for update"); + + const validData = {}; + Object.entries(data).forEach(([key, value]) => { + if (!this.writable.includes(key)) return; + validData[key] = value; + }); + + if (Object.keys(validData).length === 0) + return { thread: prevThread, message: "No valid fields to update!" }; + + try { + const thread = await prisma.workspace_threads.update({ + where: { id: prevThread.id }, + data: validData, + }); + return { thread, message: null }; + } catch (error) { + console.error(error.message); + return { thread: null, message: error.message }; + } + }, + + get: async function (clause = {}) { + try { + const thread = await prisma.workspace_threads.findFirst({ + where: clause, + }); + + return thread || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.workspace_threads.deleteMany({ + where: clause, + }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = null + ) { + try { + const results = await prisma.workspace_threads.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + // Will fire on first message (included or not) for a thread and rename the thread based on the prompt. + autoRenameThread: async function ({ + workspace = null, + thread = null, + user = null, + prompt = null, + onRename = null, + }) { + if (!workspace || !thread || !prompt) return false; + if (thread.name !== this.defaultName) return false; // don't rename if already named. + + const { WorkspaceChats } = require("./workspaceChats"); + const chatCount = await WorkspaceChats.count({ + workspaceId: workspace.id, + user_id: user?.id || null, + thread_id: thread.id, + }); + if (chatCount !== 1) return { renamed: false, thread }; + const { thread: updatedThread } = await this.update(thread, { + name: truncate(prompt, 22), + }); + + onRename?.(updatedThread); + return true; + }, +}; + +module.exports = { WorkspaceThread }; diff --git a/server/models/workspaceUsers.js b/server/models/workspaceUsers.js new file mode 100644 index 0000000..c27dc85 --- /dev/null +++ b/server/models/workspaceUsers.js @@ -0,0 +1,103 @@ +const prisma = require("../utils/prisma"); + +const WorkspaceUser = { + createMany: async function (userId, workspaceIds = []) { + if (workspaceIds.length === 0) return; + try { + await prisma.$transaction( + workspaceIds.map((workspaceId) => + prisma.workspace_users.create({ + data: { user_id: userId, workspace_id: workspaceId }, + }) + ) + ); + } catch (error) { + console.error(error.message); + } + return; + }, + + /** + * Create many workspace users. + * @param {Array} userIds - An array of user IDs to create workspace users for. + * @param {number} workspaceId - The ID of the workspace to create workspace users for. + * @returns {Promise} A promise that resolves when the workspace users are created. + */ + createManyUsers: async function (userIds = [], workspaceId) { + if (userIds.length === 0) return; + try { + await prisma.$transaction( + userIds.map((userId) => + prisma.workspace_users.create({ + data: { + user_id: Number(userId), + workspace_id: Number(workspaceId), + }, + }) + ) + ); + } catch (error) { + console.error(error.message); + } + return; + }, + + create: async function (userId = 0, workspaceId = 0) { + try { + await prisma.workspace_users.create({ + data: { user_id: Number(userId), workspace_id: Number(workspaceId) }, + }); + return true; + } catch (error) { + console.error( + "FAILED TO CREATE WORKSPACE_USER RELATIONSHIP.", + error.message + ); + return false; + } + }, + + get: async function (clause = {}) { + try { + const result = await prisma.workspace_users.findFirst({ where: clause }); + return result || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit = null) { + try { + const results = await prisma.workspace_users.findMany({ + where: clause, + ...(limit !== null ? { take: limit } : {}), + }); + return results; + } catch (error) { + console.error(error.message); + return []; + } + }, + + count: async function (clause = {}) { + try { + const count = await prisma.workspace_users.count({ where: clause }); + return count; + } catch (error) { + console.error(error.message); + return 0; + } + }, + + delete: async function (clause = {}) { + try { + await prisma.workspace_users.deleteMany({ where: clause }); + } catch (error) { + console.error(error.message); + } + return; + }, +}; + +module.exports.WorkspaceUser = WorkspaceUser; diff --git a/server/models/workspacesSuggestedMessages.js b/server/models/workspacesSuggestedMessages.js new file mode 100644 index 0000000..ef35a5b --- /dev/null +++ b/server/models/workspacesSuggestedMessages.js @@ -0,0 +1,83 @@ +const prisma = require("../utils/prisma"); + +const WorkspaceSuggestedMessages = { + get: async function (clause = {}) { + try { + const message = await prisma.workspace_suggested_messages.findFirst({ + where: clause, + }); + return message || null; + } catch (error) { + console.error(error.message); + return null; + } + }, + + where: async function (clause = {}, limit) { + try { + const messages = await prisma.workspace_suggested_messages.findMany({ + where: clause, + take: limit || undefined, + }); + return messages; + } catch (error) { + console.error(error.message); + return []; + } + }, + + saveAll: async function (messages, workspaceSlug) { + try { + const workspace = await prisma.workspaces.findUnique({ + where: { slug: workspaceSlug }, + }); + + if (!workspace) throw new Error("Workspace not found"); + + // Delete all existing messages for the workspace + await prisma.workspace_suggested_messages.deleteMany({ + where: { workspaceId: workspace.id }, + }); + + // Create new messages + // We create each message individually because prisma + // with sqlite does not support createMany() + for (const message of messages) { + await prisma.workspace_suggested_messages.create({ + data: { + workspaceId: workspace.id, + heading: message.heading, + message: message.message, + }, + }); + } + } catch (error) { + console.error("Failed to save all messages", error.message); + } + }, + + getMessages: async function (workspaceSlug) { + try { + const workspace = await prisma.workspaces.findUnique({ + where: { slug: workspaceSlug }, + }); + + if (!workspace) throw new Error("Workspace not found"); + + const messages = await prisma.workspace_suggested_messages.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { createdAt: "asc" }, + }); + + return messages.map((msg) => ({ + heading: msg.heading, + message: msg.message, + })); + } catch (error) { + console.error("Failed to get all messages", error.message); + return []; + } + }, +}; + +module.exports.WorkspaceSuggestedMessages = WorkspaceSuggestedMessages; diff --git a/server/nodemon.json b/server/nodemon.json new file mode 100644 index 0000000..d778fe5 --- /dev/null +++ b/server/nodemon.json @@ -0,0 +1,6 @@ +{ + "events": { + "start": "yarn swagger", + "restart": "yarn swagger" + } +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..db2cfe3 --- /dev/null +++ b/server/package.json @@ -0,0 +1,127 @@ +{ + "name": "anything-llm-server", + "version": "1.15.0", + "description": "Server endpoints to process or create content for chatting", + "main": "index.js", + "author": "Timothy Carambat (Mintplex Labs)", + "license": "MIT", + "private": false, + "engines": { + "node": ">=18.12.1" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development nodemon --ignore documents --ignore vector-cache --ignore storage --ignore swagger --trace-warnings index.js", + "start": "cross-env NODE_ENV=production node index.js", + "lint": "eslint --fix .", + "lint:check": "eslint .", + "swagger": "node ./swagger/init.js" + }, + "prisma": { + "seed": "node prisma/seed.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "@breejs/later": "4.2.0", + "@datastax/astra-db-ts": "^0.1.3", + "@ladjs/graceful": "^3.2.2", + "@lancedb/lancedb": "0.15.0", + "@langchain/anthropic": "0.1.16", + "@langchain/community": "0.0.53", + "@langchain/core": "0.1.61", + "@langchain/openai": "0.0.28", + "@langchain/textsplitters": "0.0.0", + "@mintplex-labs/bree": "^9.2.6", + "@mintplex-labs/express-ws": "^5.0.7", + "@mintplex-labs/mdpdf": "^0.1.9", + "@modelcontextprotocol/sdk": "^1.24.3", + "@pinecone-database/pinecone": "^2.0.1", + "@prisma/client": "5.3.1", + "@qdrant/js-client-rest": "^1.9.0", + "@vscode/ripgrep": "1.17.1", + "@xenova/transformers": "^2.14.0", + "@zilliz/milvus2-sdk-node": "2.3.5", + "adm-zip": "^0.5.16", + "apache-arrow": "19.0.0", + "bcryptjs": "^3.0.3", + "body-parser": "^1.20.3", + "chalk": "^4", + "chart.js": "^4.5.1", + "chartjs-node-canvas": "^5.0.0", + "check-disk-space": "^3.4.0", + "cheerio": "1.0.0", + "chromadb": "^2.0.1", + "cors": "^2.8.5", + "cron-validate": "1.4.5", + "diff": "7.0.0", + "docx": "9.6.1", + "dompurify": "3.3.3", + "dotenv": "^16.0.3", + "elevenlabs": "^0.5.0", + "exceljs": "4.4.0", + "express": "^4.21.2", + "extract-json-from-string": "^1.0.1", + "fast-levenshtein": "^3.0.0", + "fix-path": "^4.0.0", + "graphql": "^16.7.1", + "ip": "^2.0.1", + "joi": "^17.11.0", + "joi-password-complexity": "^5.2.0", + "js-tiktoken": "^1.0.8", + "jsdom": "26.1.0", + "jsonrepair": "^3.7.0", + "jsonwebtoken": "^9.0.0", + "langchain": "0.1.36", + "marked": "15.0.12", + "mime": "^3.0.0", + "moment": "^2.29.4", + "mssql": "^10.0.2", + "multer": "2.0.0", + "mysql2": "^3.9.8", + "node-telegram-bot-api": "^0.67.0", + "ollama": "^0.6.3", + "openai": "4.95.1", + "p-queue": "6.6.2", + "pdf-lib": "1.17.1", + "pg": "^8.11.5", + "pinecone-client": "^1.1.0", + "pluralize": "^8.0.0", + "posthog-node": "^3.1.1", + "pptxgenjs": "4.0.1", + "prisma": "5.3.1", + "slugify": "^1.6.6", + "strip-ansi": "^7.1.2", + "swagger-autogen": "^2.23.5", + "swagger-ui-express": "^5.0.0", + "truncate": "^3.0.0", + "url-pattern": "^1.0.3", + "uuid": "^9.0.0", + "uuid-apikey": "^1.5.3", + "weaviate-ts-client": "^1.4.0", + "web-push": "^3.6.7", + "winston": "^3.13.0" + }, + "resolutions": { + "**/graphql-request/form-data": "3.0.4", + "form-data": "4.0.4" + }, + "devDependencies": { + "@eslint/js": "9", + "@inquirer/prompts": "^4.3.1", + "cross-env": "^7.0.3", + "eslint": "9", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-ft-flow": "^3.0.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "eslint-plugin-unused-imports": "^4.4.1", + "flow-bin": "^0.217.0", + "flow-remove-types": "^2.217.1", + "globals": "^17.4.0", + "hermes-eslint": "^0.15.0", + "node-html-markdown": "^1.3.0", + "nodemon": "^2.0.22", + "prettier": "^3.0.3" + } +} diff --git a/server/prisma/migrations/20230921191814_init/migration.sql b/server/prisma/migrations/20230921191814_init/migration.sql new file mode 100644 index 0000000..911559b --- /dev/null +++ b/server/prisma/migrations/20230921191814_init/migration.sql @@ -0,0 +1,125 @@ +-- CreateTable +CREATE TABLE "api_keys" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "secret" TEXT, + "createdBy" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "workspace_documents" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "docId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "docpath" TEXT NOT NULL, + "workspaceId" INTEGER NOT NULL, + "metadata" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_documents_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "invites" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "code" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "claimedBy" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" INTEGER NOT NULL, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "system_settings" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "label" TEXT NOT NULL, + "value" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "users" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT, + "password" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'default', + "suspended" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "document_vectors" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "docId" TEXT NOT NULL, + "vectorId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "welcome_messages" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user" TEXT NOT NULL, + "response" TEXT NOT NULL, + "orderIndex" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "workspaces" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "vectorTag" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "openAiTemp" REAL, + "openAiHistory" INTEGER NOT NULL DEFAULT 20, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "openAiPrompt" TEXT +); + +-- CreateTable +CREATE TABLE "workspace_chats" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "workspaceId" INTEGER NOT NULL, + "prompt" TEXT NOT NULL, + "response" TEXT NOT NULL, + "include" BOOLEAN NOT NULL DEFAULT true, + "user_id" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "workspace_users" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "workspace_id" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_users_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_secret_key" ON "api_keys"("secret"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_documents_docId_key" ON "workspace_documents"("docId"); + +-- CreateIndex +CREATE UNIQUE INDEX "invites_code_key" ON "invites"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "system_settings_label_key" ON "system_settings"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspaces_slug_key" ON "workspaces"("slug"); diff --git a/server/prisma/migrations/20231101001441_init/migration.sql b/server/prisma/migrations/20231101001441_init/migration.sql new file mode 100644 index 0000000..1c20f46 --- /dev/null +++ b/server/prisma/migrations/20231101001441_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "similarityThreshold" REAL DEFAULT 0.25; diff --git a/server/prisma/migrations/20231101195421_init/migration.sql b/server/prisma/migrations/20231101195421_init/migration.sql new file mode 100644 index 0000000..705bca3 --- /dev/null +++ b/server/prisma/migrations/20231101195421_init/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "cache_data" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "data" TEXT NOT NULL, + "belongsTo" TEXT, + "byId" INTEGER, + "expiresAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/server/prisma/migrations/20231129012019_add/migration.sql b/server/prisma/migrations/20231129012019_add/migration.sql new file mode 100644 index 0000000..7e37f7e --- /dev/null +++ b/server/prisma/migrations/20231129012019_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT; diff --git a/server/prisma/migrations/20240113013409_init/migration.sql b/server/prisma/migrations/20240113013409_init/migration.sql new file mode 100644 index 0000000..09b9448 --- /dev/null +++ b/server/prisma/migrations/20240113013409_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "chatModel" TEXT; diff --git a/server/prisma/migrations/20240118201333_init/migration.sql b/server/prisma/migrations/20240118201333_init/migration.sql new file mode 100644 index 0000000..aaf47f7 --- /dev/null +++ b/server/prisma/migrations/20240118201333_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "topN" INTEGER DEFAULT 4 CHECK ("topN" > 0); diff --git a/server/prisma/migrations/20240202002020_init/migration.sql b/server/prisma/migrations/20240202002020_init/migration.sql new file mode 100644 index 0000000..6035e74 --- /dev/null +++ b/server/prisma/migrations/20240202002020_init/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "embed_configs" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "uuid" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "chat_mode" TEXT NOT NULL DEFAULT 'query', + "allowlist_domains" TEXT, + "allow_model_override" BOOLEAN NOT NULL DEFAULT false, + "allow_temperature_override" BOOLEAN NOT NULL DEFAULT false, + "allow_prompt_override" BOOLEAN NOT NULL DEFAULT false, + "max_chats_per_day" INTEGER, + "max_chats_per_session" INTEGER, + "workspace_id" INTEGER NOT NULL, + "createdBy" INTEGER, + "usersId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "embed_configs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "embed_configs_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "embed_chats" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "prompt" TEXT NOT NULL, + "response" TEXT NOT NULL, + "session_id" TEXT NOT NULL, + "include" BOOLEAN NOT NULL DEFAULT true, + "connection_information" TEXT, + "embed_id" INTEGER NOT NULL, + "usersId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "embed_chats_embed_id_fkey" FOREIGN KEY ("embed_id") REFERENCES "embed_configs" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "embed_chats_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "embed_configs_uuid_key" ON "embed_configs"("uuid"); diff --git a/server/prisma/migrations/20240206181106_init/migration.sql b/server/prisma/migrations/20240206181106_init/migration.sql new file mode 100644 index 0000000..9655c7b --- /dev/null +++ b/server/prisma/migrations/20240206181106_init/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "workspace_suggested_messages" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "workspaceId" INTEGER NOT NULL, + "heading" TEXT NOT NULL, + "message" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_suggested_messages_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "workspace_suggested_messages_workspaceId_idx" ON "workspace_suggested_messages"("workspaceId"); diff --git a/server/prisma/migrations/20240206211916_init/migration.sql b/server/prisma/migrations/20240206211916_init/migration.sql new file mode 100644 index 0000000..f2e882a --- /dev/null +++ b/server/prisma/migrations/20240206211916_init/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "event_logs" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "event" TEXT NOT NULL, + "metadata" TEXT, + "userId" INTEGER, + "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE INDEX "event_logs_event_idx" ON "event_logs"("event"); diff --git a/server/prisma/migrations/20240208224848_init/migration.sql b/server/prisma/migrations/20240208224848_init/migration.sql new file mode 100644 index 0000000..f7e6561 --- /dev/null +++ b/server/prisma/migrations/20240208224848_init/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "workspace_chats" ADD COLUMN "thread_id" INTEGER; + +-- CreateTable +CREATE TABLE "workspace_threads" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "workspace_id" INTEGER NOT NULL, + "user_id" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_threads_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_threads_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_threads_slug_key" ON "workspace_threads"("slug"); + +-- CreateIndex +CREATE INDEX "workspace_threads_workspace_id_idx" ON "workspace_threads"("workspace_id"); + +-- CreateIndex +CREATE INDEX "workspace_threads_user_id_idx" ON "workspace_threads"("user_id"); diff --git a/server/prisma/migrations/20240210004405_init/migration.sql b/server/prisma/migrations/20240210004405_init/migration.sql new file mode 100644 index 0000000..3d824ab --- /dev/null +++ b/server/prisma/migrations/20240210004405_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspace_chats" ADD COLUMN "feedbackScore" BOOLEAN; diff --git a/server/prisma/migrations/20240216214639_init/migration.sql b/server/prisma/migrations/20240216214639_init/migration.sql new file mode 100644 index 0000000..368782b --- /dev/null +++ b/server/prisma/migrations/20240216214639_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "chatMode" TEXT DEFAULT 'chat'; diff --git a/server/prisma/migrations/20240219211018_init/migration.sql b/server/prisma/migrations/20240219211018_init/migration.sql new file mode 100644 index 0000000..98e8b24 --- /dev/null +++ b/server/prisma/migrations/20240219211018_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspace_documents" ADD COLUMN "pinned" BOOLEAN DEFAULT false; diff --git a/server/prisma/migrations/20240301002308_init/migration.sql b/server/prisma/migrations/20240301002308_init/migration.sql new file mode 100644 index 0000000..5847bea --- /dev/null +++ b/server/prisma/migrations/20240301002308_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "pfpFilename" TEXT; diff --git a/server/prisma/migrations/20240326231053_init/migration.sql b/server/prisma/migrations/20240326231053_init/migration.sql new file mode 100644 index 0000000..85fe8be --- /dev/null +++ b/server/prisma/migrations/20240326231053_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "invites" ADD COLUMN "workspaceIds" TEXT; diff --git a/server/prisma/migrations/20240405015034_init/migration.sql b/server/prisma/migrations/20240405015034_init/migration.sql new file mode 100644 index 0000000..54a39d9 --- /dev/null +++ b/server/prisma/migrations/20240405015034_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "chatProvider" TEXT; diff --git a/server/prisma/migrations/20240412183346_init/migration.sql b/server/prisma/migrations/20240412183346_init/migration.sql new file mode 100644 index 0000000..8014c9c --- /dev/null +++ b/server/prisma/migrations/20240412183346_init/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "agentModel" TEXT; +ALTER TABLE "workspaces" ADD COLUMN "agentProvider" TEXT; + +-- CreateTable +CREATE TABLE "workspace_agent_invocations" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "uuid" TEXT NOT NULL, + "prompt" TEXT NOT NULL, + "closed" BOOLEAN NOT NULL DEFAULT false, + "user_id" INTEGER, + "thread_id" INTEGER, + "workspace_id" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_agent_invocations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_agent_invocations_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_agent_invocations_uuid_key" ON "workspace_agent_invocations"("uuid"); + +-- CreateIndex +CREATE INDEX "workspace_agent_invocations_uuid_idx" ON "workspace_agent_invocations"("uuid"); diff --git a/server/prisma/migrations/20240425004220_init/migration.sql b/server/prisma/migrations/20240425004220_init/migration.sql new file mode 100644 index 0000000..14ec764 --- /dev/null +++ b/server/prisma/migrations/20240425004220_init/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "seen_recovery_codes" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "recovery_codes" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "code_hash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "recovery_codes_user_id_idx" ON "recovery_codes"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id"); diff --git a/server/prisma/migrations/20240430230707_init/migration.sql b/server/prisma/migrations/20240430230707_init/migration.sql new file mode 100644 index 0000000..af29a13 --- /dev/null +++ b/server/prisma/migrations/20240430230707_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "queryRefusalResponse" TEXT; diff --git a/server/prisma/migrations/20240510032311_init/migration.sql b/server/prisma/migrations/20240510032311_init/migration.sql new file mode 100644 index 0000000..3b82efb --- /dev/null +++ b/server/prisma/migrations/20240510032311_init/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "slash_command_presets" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "command" TEXT NOT NULL, + "prompt" TEXT NOT NULL, + "description" TEXT NOT NULL, + "uid" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "slash_command_presets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "slash_command_presets_uid_command_key" ON "slash_command_presets"("uid", "command"); diff --git a/server/prisma/migrations/20240618224346_init/migration.sql b/server/prisma/migrations/20240618224346_init/migration.sql new file mode 100644 index 0000000..cce1713 --- /dev/null +++ b/server/prisma/migrations/20240618224346_init/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "workspace_documents" ADD COLUMN "watched" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "document_sync_queues" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "staleAfterMs" INTEGER NOT NULL DEFAULT 604800000, + "nextSyncAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSyncedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "workspaceDocId" INTEGER NOT NULL, + CONSTRAINT "document_sync_queues_workspaceDocId_fkey" FOREIGN KEY ("workspaceDocId") REFERENCES "workspace_documents" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "document_sync_executions" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "queueId" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'unknown', + "result" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "document_sync_executions_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "document_sync_queues" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "document_sync_queues_workspaceDocId_key" ON "document_sync_queues"("workspaceDocId"); diff --git a/server/prisma/migrations/20240821215625_init/migration.sql b/server/prisma/migrations/20240821215625_init/migration.sql new file mode 100644 index 0000000..35bce1b --- /dev/null +++ b/server/prisma/migrations/20240821215625_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspace_chats" ADD COLUMN "api_session_id" TEXT; diff --git a/server/prisma/migrations/20240824005054_init/migration.sql b/server/prisma/migrations/20240824005054_init/migration.sql new file mode 100644 index 0000000..7dc4632 --- /dev/null +++ b/server/prisma/migrations/20240824005054_init/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "browser_extension_api_keys" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "key" TEXT NOT NULL, + "user_id" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL, + CONSTRAINT "browser_extension_api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "browser_extension_api_keys_key_key" ON "browser_extension_api_keys"("key"); + +-- CreateIndex +CREATE INDEX "browser_extension_api_keys_user_id_idx" ON "browser_extension_api_keys"("user_id"); diff --git a/server/prisma/migrations/20241003192954_init/migration.sql b/server/prisma/migrations/20241003192954_init/migration.sql new file mode 100644 index 0000000..e3d26d3 --- /dev/null +++ b/server/prisma/migrations/20241003192954_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "dailyMessageLimit" INTEGER; diff --git a/server/prisma/migrations/20241029203722_init/migration.sql b/server/prisma/migrations/20241029203722_init/migration.sql new file mode 100644 index 0000000..29ee89a --- /dev/null +++ b/server/prisma/migrations/20241029203722_init/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "temporary_auth_tokens" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "token" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "temporary_auth_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "temporary_auth_tokens_token_key" ON "temporary_auth_tokens"("token"); diff --git a/server/prisma/migrations/20241029233509_init/migration.sql b/server/prisma/migrations/20241029233509_init/migration.sql new file mode 100644 index 0000000..4540629 --- /dev/null +++ b/server/prisma/migrations/20241029233509_init/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "temporary_auth_tokens_token_idx" ON "temporary_auth_tokens"("token"); + +-- CreateIndex +CREATE INDEX "temporary_auth_tokens_userId_idx" ON "temporary_auth_tokens"("userId"); diff --git a/server/prisma/migrations/20250102204948_init/migration.sql b/server/prisma/migrations/20250102204948_init/migration.sql new file mode 100644 index 0000000..788409b --- /dev/null +++ b/server/prisma/migrations/20250102204948_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "vectorSearchMode" TEXT DEFAULT 'default'; diff --git a/server/prisma/migrations/20250226005538_init/migration.sql b/server/prisma/migrations/20250226005538_init/migration.sql new file mode 100644 index 0000000..4902fbc --- /dev/null +++ b/server/prisma/migrations/20250226005538_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "bio" TEXT DEFAULT ''; diff --git a/server/prisma/migrations/20250318154720_init/migration.sql b/server/prisma/migrations/20250318154720_init/migration.sql new file mode 100644 index 0000000..810953e --- /dev/null +++ b/server/prisma/migrations/20250318154720_init/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "system_prompt_variables" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "key" TEXT NOT NULL, + "value" TEXT, + "description" TEXT, + "type" TEXT NOT NULL DEFAULT 'system', + "userId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "system_prompt_variables_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "system_prompt_variables_key_key" ON "system_prompt_variables"("key"); + +-- CreateIndex +CREATE INDEX "system_prompt_variables_userId_idx" ON "system_prompt_variables"("userId"); diff --git a/server/prisma/migrations/20250506214129_init/migration.sql b/server/prisma/migrations/20250506214129_init/migration.sql new file mode 100644 index 0000000..85deb92 --- /dev/null +++ b/server/prisma/migrations/20250506214129_init/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "prompt_history" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "workspaceId" INTEGER NOT NULL, + "prompt" TEXT NOT NULL, + "modifiedBy" INTEGER, + "modifiedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "prompt_history_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "prompt_history_modifiedBy_fkey" FOREIGN KEY ("modifiedBy") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "prompt_history_workspaceId_idx" ON "prompt_history"("workspaceId"); diff --git a/server/prisma/migrations/20250709230835_init/migration.sql b/server/prisma/migrations/20250709230835_init/migration.sql new file mode 100644 index 0000000..a183c5c --- /dev/null +++ b/server/prisma/migrations/20250709230835_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "embed_configs" ADD COLUMN "message_limit" INTEGER DEFAULT 20; diff --git a/server/prisma/migrations/20250725194841_init/migration.sql b/server/prisma/migrations/20250725194841_init/migration.sql new file mode 100644 index 0000000..1510948 --- /dev/null +++ b/server/prisma/migrations/20250725194841_init/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "desktop_mobile_devices" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "deviceOs" TEXT NOT NULL, + "deviceName" TEXT NOT NULL, + "token" TEXT NOT NULL, + "approved" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "desktop_mobile_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "desktop_mobile_devices_token_key" ON "desktop_mobile_devices"("token"); + +-- CreateIndex +CREATE INDEX "desktop_mobile_devices_userId_idx" ON "desktop_mobile_devices"("userId"); diff --git a/server/prisma/migrations/20250808171557_init/migration.sql b/server/prisma/migrations/20250808171557_init/migration.sql new file mode 100644 index 0000000..4b3e751 --- /dev/null +++ b/server/prisma/migrations/20250808171557_init/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "workspace_parsed_files" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "filename" TEXT NOT NULL, + "workspaceId" INTEGER NOT NULL, + "userId" INTEGER, + "threadId" INTEGER, + "metadata" TEXT, + "tokenCountEstimate" INTEGER DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "workspace_parsed_files_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_parsed_files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_parsed_files_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "workspace_threads" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_parsed_files_filename_key" ON "workspace_parsed_files"("filename"); + +-- CreateIndex +CREATE INDEX "workspace_parsed_files_workspaceId_idx" ON "workspace_parsed_files"("workspaceId"); + +-- CreateIndex +CREATE INDEX "workspace_parsed_files_userId_idx" ON "workspace_parsed_files"("userId"); diff --git a/server/prisma/migrations/20260130040204_init/migration.sql b/server/prisma/migrations/20260130040204_init/migration.sql new file mode 100644 index 0000000..9b9561e --- /dev/null +++ b/server/prisma/migrations/20260130040204_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "web_push_subscription_config" TEXT; diff --git a/server/prisma/migrations/20260313192859_init/migration.sql b/server/prisma/migrations/20260313192859_init/migration.sql new file mode 100644 index 0000000..9e4d04e --- /dev/null +++ b/server/prisma/migrations/20260313192859_init/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the `welcome_messages` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "welcome_messages"; +PRAGMA foreign_keys=on; diff --git a/server/prisma/migrations/20260319202916_init/migration.sql b/server/prisma/migrations/20260319202916_init/migration.sql new file mode 100644 index 0000000..a486fbf --- /dev/null +++ b/server/prisma/migrations/20260319202916_init/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "external_communication_connectors" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "type" TEXT NOT NULL, + "config" TEXT NOT NULL DEFAULT '{}', + "active" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "external_communication_connectors_type_key" ON "external_communication_connectors"("type"); diff --git a/server/prisma/migrations/20260406120000_init/migration.sql b/server/prisma/migrations/20260406120000_init/migration.sql new file mode 100644 index 0000000..2ade09e --- /dev/null +++ b/server/prisma/migrations/20260406120000_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "api_keys" ADD COLUMN "name" TEXT; diff --git a/server/prisma/migrations/20260423191158_init/migration.sql b/server/prisma/migrations/20260423191158_init/migration.sql new file mode 100644 index 0000000..db4b139 --- /dev/null +++ b/server/prisma/migrations/20260423191158_init/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "scheduled_jobs" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "prompt" TEXT NOT NULL, + "tools" TEXT, + "schedule" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "lastRunAt" DATETIME, + "nextRunAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "scheduled_job_runs" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "jobId" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'queued', + "result" TEXT, + "error" TEXT, + "startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" DATETIME, + "readAt" DATETIME, + CONSTRAINT "scheduled_job_runs_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "scheduled_jobs" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "scheduled_job_runs_jobId_idx" ON "scheduled_job_runs"("jobId"); diff --git a/server/prisma/migrations/20260424013043_init/migration.sql b/server/prisma/migrations/20260424013043_init/migration.sql new file mode 100644 index 0000000..2e4306a --- /dev/null +++ b/server/prisma/migrations/20260424013043_init/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "workspace_chats" ADD COLUMN "memory_processed" BOOLEAN; + +-- CreateTable +CREATE TABLE "memories" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER, + "workspace_id" INTEGER, + "scope" TEXT NOT NULL DEFAULT 'workspace', + "content" TEXT NOT NULL, + "last_used_at" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "memories_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "memories_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "memories_user_id_workspace_id_idx" ON "memories"("user_id", "workspace_id"); + +-- CreateIndex +CREATE INDEX "memories_user_id_scope_idx" ON "memories"("user_id", "scope"); diff --git a/server/prisma/migrations/20260520160208_init/migration.sql b/server/prisma/migrations/20260520160208_init/migration.sql new file mode 100644 index 0000000..c726984 --- /dev/null +++ b/server/prisma/migrations/20260520160208_init/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "router_id" INTEGER; + +-- CreateTable +CREATE TABLE "model_routers" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "description" TEXT, + "fallback_provider" TEXT NOT NULL, + "fallback_model" TEXT NOT NULL, + "cooldown_seconds" INTEGER NOT NULL DEFAULT 30, + "created_by" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "model_router_rules" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "router_id" INTEGER NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL, + "type" TEXT NOT NULL DEFAULT 'calculated', + "title" TEXT NOT NULL, + "description" TEXT, + "condition_logic" TEXT, + "conditions" TEXT, + "route_provider" TEXT NOT NULL, + "route_model" TEXT NOT NULL, + "created_by" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "model_router_rules_router_id_fkey" FOREIGN KEY ("router_id") REFERENCES "model_routers" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "model_routers_name_key" ON "model_routers"("name"); + +-- CreateIndex +CREATE INDEX "model_router_rules_router_id_idx" ON "model_router_rules"("router_id"); + +-- CreateIndex +CREATE INDEX "model_router_rules_router_id_enabled_priority_idx" ON "model_router_rules"("router_id", "enabled", "priority"); + +-- CreateIndex +CREATE UNIQUE INDEX "model_router_rules_router_id_title_key" ON "model_router_rules"("router_id", "title"); diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 0000000..65cfd6c --- /dev/null +++ b/server/prisma/schema.prisma @@ -0,0 +1,488 @@ +generator client { + provider = "prisma-client-js" +} + +// Uncomment the following lines and comment out the SQLite datasource block above to use PostgreSQL +// Make sure to set the correct DATABASE_URL in your .env file +// After swapping run `yarn prisma:setup` from the root directory to migrate the database +// +// datasource db { +// provider = "postgresql" +// url = env("DATABASE_URL") +// } +datasource db { + provider = "sqlite" + url = "file:../storage/anythingllm.db" +} + +model api_keys { + id Int @id @default(autoincrement()) + name String? + secret String? @unique + createdBy Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) +} + +model workspace_documents { + id Int @id @default(autoincrement()) + docId String @unique + filename String + docpath String + workspaceId Int + metadata String? + pinned Boolean? @default(false) + watched Boolean? @default(false) + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspaceId], references: [id]) + document_sync_queues document_sync_queues? +} + +model invites { + id Int @id @default(autoincrement()) + code String @unique + status String @default("pending") + claimedBy Int? + workspaceIds String? + createdAt DateTime @default(now()) + createdBy Int + lastUpdatedAt DateTime @default(now()) +} + +model system_settings { + id Int @id @default(autoincrement()) + label String @unique + value String? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) +} + +model users { + id Int @id @default(autoincrement()) + username String? @unique + password String + pfpFilename String? + role String @default("default") + suspended Int @default(0) + seen_recovery_codes Boolean? @default(false) + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + dailyMessageLimit Int? + bio String? @default("") + web_push_subscription_config String? + workspace_chats workspace_chats[] + workspace_users workspace_users[] + embed_configs embed_configs[] + embed_chats embed_chats[] + threads workspace_threads[] + recovery_codes recovery_codes[] + password_reset_tokens password_reset_tokens[] + workspace_agent_invocations workspace_agent_invocations[] + slash_command_presets slash_command_presets[] + browser_extension_api_keys browser_extension_api_keys[] + temporary_auth_tokens temporary_auth_tokens[] + system_prompt_variables system_prompt_variables[] + prompt_history prompt_history[] + desktop_mobile_devices desktop_mobile_devices[] + workspace_parsed_files workspace_parsed_files[] + memories memories[] +} + +model recovery_codes { + id Int @id @default(autoincrement()) + user_id Int + code_hash String + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + +model password_reset_tokens { + id Int @id @default(autoincrement()) + user_id Int + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + +model document_vectors { + id Int @id @default(autoincrement()) + docId String + vectorId String + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) +} + +model workspaces { + id Int @id @default(autoincrement()) + name String + slug String @unique + vectorTag String? + createdAt DateTime @default(now()) + openAiTemp Float? + openAiHistory Int @default(20) + lastUpdatedAt DateTime @default(now()) + // THIS IS THE SYSTEM PROMPT FOR THE WORKSPACE + openAiPrompt String? + similarityThreshold Float? @default(0.25) + chatProvider String? + chatModel String? + topN Int? @default(4) + chatMode String? @default("chat") + pfpFilename String? + agentProvider String? + agentModel String? + queryRefusalResponse String? + vectorSearchMode String? @default("default") + router_id Int? // No relation to prevent whole table migration (SQLite limitation) + workspace_users workspace_users[] + documents workspace_documents[] + workspace_suggested_messages workspace_suggested_messages[] + embed_configs embed_configs[] + threads workspace_threads[] + workspace_agent_invocations workspace_agent_invocations[] + prompt_history prompt_history[] + workspace_parsed_files workspace_parsed_files[] + memories memories[] +} + +model workspace_threads { + id Int @id @default(autoincrement()) + name String + slug String @unique + workspace_id Int + user_id Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade) + user users? @relation(fields: [user_id], references: [id], onDelete: Cascade) + workspace_parsed_files workspace_parsed_files[] + + @@index([workspace_id]) + @@index([user_id]) +} + +model workspace_suggested_messages { + id Int @id @default(autoincrement()) + workspaceId Int + heading String + message String + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@index([workspaceId]) +} + +model workspace_chats { + id Int @id @default(autoincrement()) + workspaceId Int + prompt String + response String + include Boolean @default(true) + user_id Int? + thread_id Int? // No relation to prevent whole table migration + api_session_id String? // String identifier for only the dev API to partition chats in any mode. + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + feedbackScore Boolean? + memoryProcessed Boolean? @map("memory_processed") + users users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model workspace_agent_invocations { + id Int @id @default(autoincrement()) + uuid String @unique + prompt String // Contains agent invocation to parse + option additional text for seed. + closed Boolean @default(false) + user_id Int? + thread_id Int? // No relation to prevent whole table migration + workspace_id Int + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + user users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([uuid]) +} + +model workspace_users { + id Int @id @default(autoincrement()) + user_id Int + workspace_id Int + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model cache_data { + id Int @id @default(autoincrement()) + name String + data String + belongsTo String? + byId Int? + expiresAt DateTime? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) +} + +model embed_configs { + id Int @id @default(autoincrement()) + uuid String @unique + enabled Boolean @default(false) + chat_mode String @default("query") + allowlist_domains String? + allow_model_override Boolean @default(false) + allow_temperature_override Boolean @default(false) + allow_prompt_override Boolean @default(false) + max_chats_per_day Int? + max_chats_per_session Int? + message_limit Int? @default(20) + workspace_id Int + createdBy Int? + usersId Int? + createdAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade) + embed_chats embed_chats[] + users users? @relation(fields: [usersId], references: [id]) +} + +model embed_chats { + id Int @id @default(autoincrement()) + prompt String + response String + session_id String + include Boolean @default(true) + connection_information String? + embed_id Int + usersId Int? + createdAt DateTime @default(now()) + embed_config embed_configs @relation(fields: [embed_id], references: [id], onDelete: Cascade) + users users? @relation(fields: [usersId], references: [id]) +} + +model event_logs { + id Int @id @default(autoincrement()) + event String + metadata String? + userId Int? + occurredAt DateTime @default(now()) + + @@index([event]) +} + +model slash_command_presets { + id Int @id @default(autoincrement()) + command String + prompt String + description String + uid Int @default(0) // 0 is null user + userId Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + user users? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([uid, command]) +} + +model document_sync_queues { + id Int @id @default(autoincrement()) + staleAfterMs Int @default(604800000) // 7 days + nextSyncAt DateTime + createdAt DateTime @default(now()) + lastSyncedAt DateTime @default(now()) + workspaceDocId Int @unique + workspaceDoc workspace_documents? @relation(fields: [workspaceDocId], references: [id], onDelete: Cascade) + runs document_sync_executions[] +} + +model document_sync_executions { + id Int @id @default(autoincrement()) + queueId Int + status String @default("unknown") + result String? + createdAt DateTime @default(now()) + queue document_sync_queues @relation(fields: [queueId], references: [id], onDelete: Cascade) +} + +model browser_extension_api_keys { + id Int @id @default(autoincrement()) + key String @unique + user_id Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @updatedAt + user users? @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + +model temporary_auth_tokens { + id Int @id @default(autoincrement()) + token String @unique + userId Int + expiresAt DateTime + createdAt DateTime @default(now()) + user users @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) +} + +model system_prompt_variables { + id Int @id @default(autoincrement()) + key String @unique + value String? + description String? + type String @default("system") // system, user, dynamic + userId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user users? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model prompt_history { + id Int @id @default(autoincrement()) + workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspaceId Int + prompt String + modifiedBy Int? + modifiedAt DateTime @default(now()) + user users? @relation(fields: [modifiedBy], references: [id]) + + @@index([workspaceId]) +} + +// Schema specific to mobile app <> Desktop app connection +model desktop_mobile_devices { + id Int @id @default(autoincrement()) + deviceOs String + deviceName String + token String @unique + approved Boolean @default(false) + userId Int? + createdAt DateTime @default(now()) + user users? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model workspace_parsed_files { + id Int @id @default(autoincrement()) + filename String @unique + workspaceId Int + userId Int? + threadId Int? + metadata String? + tokenCountEstimate Int? @default(0) + createdAt DateTime @default(now()) + workspace workspaces @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user users? @relation(fields: [userId], references: [id], onDelete: Cascade) + thread workspace_threads? @relation(fields: [threadId], references: [id], onDelete: Cascade) + + @@index([workspaceId]) + @@index([userId]) +} + +model external_communication_connectors { + id Int @id @default(autoincrement()) + type String @unique + config String @default("{}") + active Boolean @default(false) + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) +} + +model scheduled_jobs { + id Int @id @default(autoincrement()) + name String + prompt String + tools String? // JSON array of tool identifiers (null = use all enabled agent skills) + schedule String // Cron expression + enabled Boolean @default(true) + lastRunAt DateTime? + nextRunAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + runs scheduled_job_runs[] +} + +model scheduled_job_runs { + id Int @id @default(autoincrement()) + jobId Int + status String @default("queued") // queued | running | completed | failed | timed_out — model always sets explicitly + result String? // JSON execution trace + error String? + startedAt DateTime @default(now()) + completedAt DateTime? + readAt DateTime? // null = unread + job scheduled_jobs @relation(fields: [jobId], references: [id], onDelete: Cascade) + + @@index([jobId]) +} + +model memories { + id Int @id @default(autoincrement()) + userId Int? @map("user_id") + workspaceId Int? @map("workspace_id") + scope String @default("workspace") + content String + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") + + user users? @relation(fields: [userId], references: [id], onDelete: Cascade) + workspace workspaces? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@index([userId, workspaceId]) + @@index([userId, scope]) +} + +model model_routers { + id Int @id @default(autoincrement()) + name String @unique + description String? + fallback_provider String + fallback_model String + cooldown_seconds Int @default(30) + created_by Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + rules model_router_rules[] +} + +model model_router_rules { + id Int @id @default(autoincrement()) + router_id Int + enabled Boolean @default(true) + priority Int + type String @default("calculated") + title String + description String? + // For `calculated` rules: the boolean operator joining `conditions` — "AND" + // (all must match) or "OR" (any must match). Null for `llm` rules. + condition_logic String? + // For `calculated` rules: JSON-stringified array of condition objects, each + // shaped as { property: string, comparator: string, value: string } where + // `property` ∈ VALID_PROPERTIES and `comparator` ∈ VALID_COMPARATORS (see + // server/models/modelRouterRule.js). `value` is always stored as a string; + // numeric comparators parse it at evaluation time. Null for `llm` rules. + conditions String? + route_provider String + route_model String + created_by Int? + createdAt DateTime @default(now()) + lastUpdatedAt DateTime @default(now()) + router model_routers @relation(fields: [router_id], references: [id], onDelete: Cascade) + + @@unique([router_id, title]) + @@index([router_id]) + @@index([router_id, enabled, priority]) +} \ No newline at end of file diff --git a/server/prisma/seed.js b/server/prisma/seed.js new file mode 100644 index 0000000..202ac04 --- /dev/null +++ b/server/prisma/seed.js @@ -0,0 +1,31 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +async function main() { + const settings = [ + { label: "multi_user_mode", value: "false" }, + { label: "logo_filename", value: "anything-llm.png" }, + ]; + + for (let setting of settings) { + const existing = await prisma.system_settings.findUnique({ + where: { label: setting.label }, + }); + + // Only create the setting if it doesn't already exist + if (!existing) { + await prisma.system_settings.create({ + data: setting, + }); + } + } +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/storage/README.md b/server/storage/README.md new file mode 100644 index 0000000..1282f4b --- /dev/null +++ b/server/storage/README.md @@ -0,0 +1,24 @@ +# AnythingLLM Storage + +This folder is for the local or disk storage of ready-to-embed documents, vector-cached embeddings, and the disk-storage of LanceDB and the local SQLite database. + +This folder should contain the following folders. +`documents` +`lancedb` (if using lancedb) +`vector-cache` +and a file named exactly `anythingllm.db` + + +### Common issues +**SQLITE_FILE_CANNOT_BE_OPENED** in the server log = The DB file does not exist probably because the node instance does not have the correct permissions to write a file to the disk. To solve this.. + +- Local dev + - Create a `anythingllm.db` empty file in this directory. Thats all. No need to reboot the server or anything. If your permissions are correct this should not ever occur since the server will create the file if it does not exist automatically. + +- Docker Instance + - Get your AnythingLLM docker container id with `docker ps -a`. The container must be running to execute the next commands. + - Run `docker container exec -u 0 -t mkdir -p /app/server/storage /app/server/storage/documents /app/server/storage/vector-cache /app/server/storage/lancedb` + - Run `docker container exec -u 0 -t touch /app/server/storage/anythingllm.db` + - Run `docker container exec -u 0 -t chown -R anythingllm:anythingllm /app/collector /app/server` + + - The above commands will create the appropriate folders inside of the docker container and will persist as long as you do not destroy the container and volume. This will also fix any ownership issues of folder files in the collector and the server. \ No newline at end of file diff --git a/server/storage/assets/anything-llm-invert.png b/server/storage/assets/anything-llm-invert.png new file mode 100644 index 0000000..ec14d17 Binary files /dev/null and b/server/storage/assets/anything-llm-invert.png differ diff --git a/server/storage/assets/anything-llm.png b/server/storage/assets/anything-llm.png new file mode 100644 index 0000000..96a079c Binary files /dev/null and b/server/storage/assets/anything-llm.png differ diff --git a/server/storage/models/.gitignore b/server/storage/models/.gitignore new file mode 100644 index 0000000..2e6b5c3 --- /dev/null +++ b/server/storage/models/.gitignore @@ -0,0 +1,17 @@ +Xenova +downloaded/* +!downloaded/.placeholder +openrouter +apipie +novita +mixedbread-ai* +gemini +togetherAi +tesseract +ppio +context-windows/* +MintplexLabs +cometapi +fireworks +giteeai +docker-model-runner \ No newline at end of file diff --git a/server/storage/models/README.md b/server/storage/models/README.md new file mode 100644 index 0000000..432f605 --- /dev/null +++ b/server/storage/models/README.md @@ -0,0 +1,45 @@ +# Native models used by AnythingLLM + +This folder is specifically created as a local cache and storage folder that is used for native models that can run on a CPU. + +Currently, AnythingLLM uses this folder for the following parts of the application. + +## Embedding +When your embedding engine preference is `native` we will use the ONNX **all-MiniLM-L6-v2** model built by [Xenova on HuggingFace.co](https://huggingface.co/Xenova/all-MiniLM-L6-v2). This model is a quantized and WASM version of the popular [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) which produces a 384-dimension vector. + +If you are using the `native` embedding engine your vector database should be configured to accept 384-dimension models if that parameter is directly editable (Pinecone only). + +## Audio/Video transcription +AnythingLLM allows you to upload various audio and video formats as source documents. In all cases the audio tracks will be transcribed by a locally running ONNX model **whisper-small** built by [Xenova on HuggingFace.co](https://huggingface.co/Xenova/whisper-small). The model is a smaller version of the OpenAI Whisper model. Given the model runs locally on CPU, larger files will result in longer transcription times. + +Once transcribed you can embed these transcriptions into your workspace like you would any other file! + +**Other external model/transcription providers are also live.** +- [OpenAI Whisper via API key.](https://openai.com/research/whisper) + +## Text generation (LLM selection) +> [!IMPORTANT] +> Use of a locally running LLM model is **experimental** and may behave unexpectedly, crash, or not function at all. +> We suggest for production-use of a local LLM model to use a purpose-built inference server like [LocalAI](https://localai.io) or [LMStudio](https://lmstudio.ai). + +> [!TIP] +> We recommend at _least_ using a 4-bit or 5-bit quantized model for your LLM. Lower quantization models tend to +> just output unreadable garbage. + +If you would like to use a local Llama compatible LLM model for chatting you can select any model from this [HuggingFace search filter](https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&other=text-generation-inference&sort=trending) + +**Requirements** +- Model must be in the latest `GGUF` format +- Model should be compatible with latest `llama.cpp` +- You should have the proper RAM to run such a model. Requirement depends on model size. + +### Where do I put my GGUF model? +> [!IMPORTANT] +> If running in Docker you should be running the container to a mounted storage location on the host machine so you +> can update the storage files directly without having to re-download or re-build your docker container. [See suggested Docker config](../../../README.md#recommended-usage-with-docker-easy) + +> [!NOTE] +> `/server/storage/models/downloaded` is the default location that your model files should be at. +> Your storage directory may differ if you changed the STORAGE_DIR environment variable. + +All local models you want to have available for LLM selection should be placed in the `server/storage/models/downloaded` folder. Only `.gguf` files will be allowed to be selected from the UI. \ No newline at end of file diff --git a/server/storage/models/downloaded/.placeholder b/server/storage/models/downloaded/.placeholder new file mode 100644 index 0000000..6121f69 --- /dev/null +++ b/server/storage/models/downloaded/.placeholder @@ -0,0 +1 @@ +All your .GGUF model file downloads you want to use for chatting should go into this folder. \ No newline at end of file diff --git a/server/swagger/dark-swagger.css b/server/swagger/dark-swagger.css new file mode 100644 index 0000000..574e1d9 --- /dev/null +++ b/server/swagger/dark-swagger.css @@ -0,0 +1,1722 @@ +@media only screen and (prefers-color-scheme: dark) { + + a { + color: #8c8cfa; + } + + ::-webkit-scrollbar-track-piece { + background-color: rgba(255, 255, 255, .2) !important; + } + + ::-webkit-scrollbar-track { + background-color: rgba(255, 255, 255, .3) !important; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, .5) !important; + } + + embed[type="application/pdf"] { + filter: invert(90%); + } + + html { + background: #1f1f1f !important; + box-sizing: border-box; + filter: contrast(100%) brightness(100%) saturate(100%); + overflow-y: scroll; + } + + body { + background: #1f1f1f; + background-color: #1f1f1f; + background-image: none !important; + } + + button, + input, + select, + textarea { + background-color: #1f1f1f; + color: #bfbfbf; + } + + font, + html { + color: #bfbfbf; + } + + .swagger-ui, + .swagger-ui section h3 { + color: #b5bac9; + } + + .swagger-ui a { + background-color: transparent; + } + + .swagger-ui mark { + background-color: #664b00; + color: #bfbfbf; + } + + .swagger-ui legend { + color: inherit; + } + + .swagger-ui .debug * { + outline: #e6da99 solid 1px; + } + + .swagger-ui .debug-white * { + outline: #fff solid 1px; + } + + .swagger-ui .debug-black * { + outline: #bfbfbf solid 1px; + } + + .swagger-ui .debug-grid { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) 0 0; + } + + .swagger-ui .debug-grid-16 { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) 0 0; + } + + .swagger-ui .debug-grid-8-solid { + background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) 0 0 #1c1c21; + } + + .swagger-ui .debug-grid-16-solid { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) 0 0 #1c1c21; + } + + .swagger-ui .b--black { + border-color: #000; + } + + .swagger-ui .b--near-black { + border-color: #121212; + } + + .swagger-ui .b--dark-gray { + border-color: #333; + } + + .swagger-ui .b--mid-gray { + border-color: #545454; + } + + .swagger-ui .b--gray { + border-color: #787878; + } + + .swagger-ui .b--silver { + border-color: #999; + } + + .swagger-ui .b--light-silver { + border-color: #6e6e6e; + } + + .swagger-ui .b--moon-gray { + border-color: #4d4d4d; + } + + .swagger-ui .b--light-gray { + border-color: #2b2b2b; + } + + .swagger-ui .b--near-white { + border-color: #242424; + } + + .swagger-ui .b--white { + border-color: #1c1c21; + } + + .swagger-ui .b--white-90 { + border-color: rgba(28, 28, 33, .9); + } + + .swagger-ui .b--white-80 { + border-color: rgba(28, 28, 33, .8); + } + + .swagger-ui .b--white-70 { + border-color: rgba(28, 28, 33, .7); + } + + .swagger-ui .b--white-60 { + border-color: rgba(28, 28, 33, .6); + } + + .swagger-ui .b--white-50 { + border-color: rgba(28, 28, 33, .5); + } + + .swagger-ui .b--white-40 { + border-color: rgba(28, 28, 33, .4); + } + + .swagger-ui .b--white-30 { + border-color: rgba(28, 28, 33, .3); + } + + .swagger-ui .b--white-20 { + border-color: rgba(28, 28, 33, .2); + } + + .swagger-ui .b--white-10 { + border-color: rgba(28, 28, 33, .1); + } + + .swagger-ui .b--white-05 { + border-color: rgba(28, 28, 33, .05); + } + + .swagger-ui .b--white-025 { + border-color: rgba(28, 28, 33, .024); + } + + .swagger-ui .b--white-0125 { + border-color: rgba(28, 28, 33, .01); + } + + .swagger-ui .b--black-90 { + border-color: rgba(0, 0, 0, .9); + } + + .swagger-ui .b--black-80 { + border-color: rgba(0, 0, 0, .8); + } + + .swagger-ui .b--black-70 { + border-color: rgba(0, 0, 0, .7); + } + + .swagger-ui .b--black-60 { + border-color: rgba(0, 0, 0, .6); + } + + .swagger-ui .b--black-50 { + border-color: rgba(0, 0, 0, .5); + } + + .swagger-ui .b--black-40 { + border-color: rgba(0, 0, 0, .4); + } + + .swagger-ui .b--black-30 { + border-color: rgba(0, 0, 0, .3); + } + + .swagger-ui .b--black-20 { + border-color: rgba(0, 0, 0, .2); + } + + .swagger-ui .b--black-10 { + border-color: rgba(0, 0, 0, .1); + } + + .swagger-ui .b--black-05 { + border-color: rgba(0, 0, 0, .05); + } + + .swagger-ui .b--black-025 { + border-color: rgba(0, 0, 0, .024); + } + + .swagger-ui .b--black-0125 { + border-color: rgba(0, 0, 0, .01); + } + + .swagger-ui .b--dark-red { + border-color: #bc2f36; + } + + .swagger-ui .b--red { + border-color: #c83932; + } + + .swagger-ui .b--light-red { + border-color: #ab3c2b; + } + + .swagger-ui .b--orange { + border-color: #cc6e33; + } + + .swagger-ui .b--purple { + border-color: #5e2ca5; + } + + .swagger-ui .b--light-purple { + border-color: #672caf; + } + + .swagger-ui .b--dark-pink { + border-color: #ab2b81; + } + + .swagger-ui .b--hot-pink { + border-color: #c03086; + } + + .swagger-ui .b--pink { + border-color: #8f2464; + } + + .swagger-ui .b--light-pink { + border-color: #721d4d; + } + + .swagger-ui .b--dark-green { + border-color: #1c6e50; + } + + .swagger-ui .b--green { + border-color: #279b70; + } + + .swagger-ui .b--light-green { + border-color: #228762; + } + + .swagger-ui .b--navy { + border-color: #0d1d35; + } + + .swagger-ui .b--dark-blue { + border-color: #20497e; + } + + .swagger-ui .b--blue { + border-color: #4380d0; + } + + .swagger-ui .b--light-blue { + border-color: #20517e; + } + + .swagger-ui .b--lightest-blue { + border-color: #143a52; + } + + .swagger-ui .b--washed-blue { + border-color: #0c312d; + } + + .swagger-ui .b--washed-green { + border-color: #0f3d2c; + } + + .swagger-ui .b--washed-red { + border-color: #411010; + } + + .swagger-ui .b--transparent { + border-color: transparent; + } + + .swagger-ui .b--gold, + .swagger-ui .b--light-yellow, + .swagger-ui .b--washed-yellow, + .swagger-ui .b--yellow { + border-color: #664b00; + } + + .swagger-ui .shadow-1 { + box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2 { + box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3 { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4 { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5 { + box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; + } + + @media screen and (min-width: 30em) { + .swagger-ui .shadow-1-ns { + box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-ns { + box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-ns { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-ns { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-ns { + box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; + } + } + + @media screen and (max-width: 60em) and (min-width: 30em) { + .swagger-ui .shadow-1-m { + box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-m { + box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-m { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-m { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-m { + box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; + } + } + + @media screen and (min-width: 60em) { + .swagger-ui .shadow-1-l { + box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-l { + box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-l { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-l { + box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-l { + box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; + } + } + + .swagger-ui .black-05 { + color: rgba(191, 191, 191, .05); + } + + .swagger-ui .bg-black-05 { + background-color: rgba(0, 0, 0, .05); + } + + .swagger-ui .black-90, + .swagger-ui .hover-black-90:focus, + .swagger-ui .hover-black-90:hover { + color: rgba(191, 191, 191, .9); + } + + .swagger-ui .black-80, + .swagger-ui .hover-black-80:focus, + .swagger-ui .hover-black-80:hover { + color: rgba(191, 191, 191, .8); + } + + .swagger-ui .black-70, + .swagger-ui .hover-black-70:focus, + .swagger-ui .hover-black-70:hover { + color: rgba(191, 191, 191, .7); + } + + .swagger-ui .black-60, + .swagger-ui .hover-black-60:focus, + .swagger-ui .hover-black-60:hover { + color: rgba(191, 191, 191, .6); + } + + .swagger-ui .black-50, + .swagger-ui .hover-black-50:focus, + .swagger-ui .hover-black-50:hover { + color: rgba(191, 191, 191, .5); + } + + .swagger-ui .black-40, + .swagger-ui .hover-black-40:focus, + .swagger-ui .hover-black-40:hover { + color: rgba(191, 191, 191, .4); + } + + .swagger-ui .black-30, + .swagger-ui .hover-black-30:focus, + .swagger-ui .hover-black-30:hover { + color: rgba(191, 191, 191, .3); + } + + .swagger-ui .black-20, + .swagger-ui .hover-black-20:focus, + .swagger-ui .hover-black-20:hover { + color: rgba(191, 191, 191, .2); + } + + .swagger-ui .black-10, + .swagger-ui .hover-black-10:focus, + .swagger-ui .hover-black-10:hover { + color: rgba(191, 191, 191, .1); + } + + .swagger-ui .hover-white-90:focus, + .swagger-ui .hover-white-90:hover, + .swagger-ui .white-90 { + color: rgba(255, 255, 255, .9); + } + + .swagger-ui .hover-white-80:focus, + .swagger-ui .hover-white-80:hover, + .swagger-ui .white-80 { + color: rgba(255, 255, 255, .8); + } + + .swagger-ui .hover-white-70:focus, + .swagger-ui .hover-white-70:hover, + .swagger-ui .white-70 { + color: rgba(255, 255, 255, .7); + } + + .swagger-ui .hover-white-60:focus, + .swagger-ui .hover-white-60:hover, + .swagger-ui .white-60 { + color: rgba(255, 255, 255, .6); + } + + .swagger-ui .hover-white-50:focus, + .swagger-ui .hover-white-50:hover, + .swagger-ui .white-50 { + color: rgba(255, 255, 255, .5); + } + + .swagger-ui .hover-white-40:focus, + .swagger-ui .hover-white-40:hover, + .swagger-ui .white-40 { + color: rgba(255, 255, 255, .4); + } + + .swagger-ui .hover-white-30:focus, + .swagger-ui .hover-white-30:hover, + .swagger-ui .white-30 { + color: rgba(255, 255, 255, .3); + } + + .swagger-ui .hover-white-20:focus, + .swagger-ui .hover-white-20:hover, + .swagger-ui .white-20 { + color: rgba(255, 255, 255, .2); + } + + .swagger-ui .hover-white-10:focus, + .swagger-ui .hover-white-10:hover, + .swagger-ui .white-10 { + color: rgba(255, 255, 255, .1); + } + + .swagger-ui .hover-moon-gray:focus, + .swagger-ui .hover-moon-gray:hover, + .swagger-ui .moon-gray { + color: #ccc; + } + + .swagger-ui .hover-light-gray:focus, + .swagger-ui .hover-light-gray:hover, + .swagger-ui .light-gray { + color: #ededed; + } + + .swagger-ui .hover-near-white:focus, + .swagger-ui .hover-near-white:hover, + .swagger-ui .near-white { + color: #f5f5f5; + } + + .swagger-ui .dark-red, + .swagger-ui .hover-dark-red:focus, + .swagger-ui .hover-dark-red:hover { + color: #e6999d; + } + + .swagger-ui .hover-red:focus, + .swagger-ui .hover-red:hover, + .swagger-ui .red { + color: #e69d99; + } + + .swagger-ui .hover-light-red:focus, + .swagger-ui .hover-light-red:hover, + .swagger-ui .light-red { + color: #e6a399; + } + + .swagger-ui .hover-orange:focus, + .swagger-ui .hover-orange:hover, + .swagger-ui .orange { + color: #e6b699; + } + + .swagger-ui .gold, + .swagger-ui .hover-gold:focus, + .swagger-ui .hover-gold:hover { + color: #e6d099; + } + + .swagger-ui .hover-yellow:focus, + .swagger-ui .hover-yellow:hover, + .swagger-ui .yellow { + color: #e6da99; + } + + .swagger-ui .hover-light-yellow:focus, + .swagger-ui .hover-light-yellow:hover, + .swagger-ui .light-yellow { + color: #ede6b6; + } + + .swagger-ui .hover-purple:focus, + .swagger-ui .hover-purple:hover, + .swagger-ui .purple { + color: #b99ae4; + } + + .swagger-ui .hover-light-purple:focus, + .swagger-ui .hover-light-purple:hover, + .swagger-ui .light-purple { + color: #bb99e6; + } + + .swagger-ui .dark-pink, + .swagger-ui .hover-dark-pink:focus, + .swagger-ui .hover-dark-pink:hover { + color: #e699cc; + } + + .swagger-ui .hot-pink, + .swagger-ui .hover-hot-pink:focus, + .swagger-ui .hover-hot-pink:hover, + .swagger-ui .hover-pink:focus, + .swagger-ui .hover-pink:hover, + .swagger-ui .pink { + color: #e699c7; + } + + .swagger-ui .hover-light-pink:focus, + .swagger-ui .hover-light-pink:hover, + .swagger-ui .light-pink { + color: #edb6d5; + } + + .swagger-ui .dark-green, + .swagger-ui .green, + .swagger-ui .hover-dark-green:focus, + .swagger-ui .hover-dark-green:hover, + .swagger-ui .hover-green:focus, + .swagger-ui .hover-green:hover { + color: #99e6c9; + } + + .swagger-ui .hover-light-green:focus, + .swagger-ui .hover-light-green:hover, + .swagger-ui .light-green { + color: #a1e8ce; + } + + .swagger-ui .hover-navy:focus, + .swagger-ui .hover-navy:hover, + .swagger-ui .navy { + color: #99b8e6; + } + + .swagger-ui .blue, + .swagger-ui .dark-blue, + .swagger-ui .hover-blue:focus, + .swagger-ui .hover-blue:hover, + .swagger-ui .hover-dark-blue:focus, + .swagger-ui .hover-dark-blue:hover { + color: #99bae6; + } + + .swagger-ui .hover-light-blue:focus, + .swagger-ui .hover-light-blue:hover, + .swagger-ui .light-blue { + color: #a9cbea; + } + + .swagger-ui .hover-lightest-blue:focus, + .swagger-ui .hover-lightest-blue:hover, + .swagger-ui .lightest-blue { + color: #d6e9f5; + } + + .swagger-ui .hover-washed-blue:focus, + .swagger-ui .hover-washed-blue:hover, + .swagger-ui .washed-blue { + color: #f7fdfc; + } + + .swagger-ui .hover-washed-green:focus, + .swagger-ui .hover-washed-green:hover, + .swagger-ui .washed-green { + color: #ebfaf4; + } + + .swagger-ui .hover-washed-yellow:focus, + .swagger-ui .hover-washed-yellow:hover, + .swagger-ui .washed-yellow { + color: #fbf9ef; + } + + .swagger-ui .hover-washed-red:focus, + .swagger-ui .hover-washed-red:hover, + .swagger-ui .washed-red { + color: #f9e7e7; + } + + .swagger-ui .color-inherit, + .swagger-ui .hover-inherit:focus, + .swagger-ui .hover-inherit:hover { + color: inherit; + } + + .swagger-ui .bg-black-90, + .swagger-ui .hover-bg-black-90:focus, + .swagger-ui .hover-bg-black-90:hover { + background-color: rgba(0, 0, 0, .9); + } + + .swagger-ui .bg-black-80, + .swagger-ui .hover-bg-black-80:focus, + .swagger-ui .hover-bg-black-80:hover { + background-color: rgba(0, 0, 0, .8); + } + + .swagger-ui .bg-black-70, + .swagger-ui .hover-bg-black-70:focus, + .swagger-ui .hover-bg-black-70:hover { + background-color: rgba(0, 0, 0, .7); + } + + .swagger-ui .bg-black-60, + .swagger-ui .hover-bg-black-60:focus, + .swagger-ui .hover-bg-black-60:hover { + background-color: rgba(0, 0, 0, .6); + } + + .swagger-ui .bg-black-50, + .swagger-ui .hover-bg-black-50:focus, + .swagger-ui .hover-bg-black-50:hover { + background-color: rgba(0, 0, 0, .5); + } + + .swagger-ui .bg-black-40, + .swagger-ui .hover-bg-black-40:focus, + .swagger-ui .hover-bg-black-40:hover { + background-color: rgba(0, 0, 0, .4); + } + + .swagger-ui .bg-black-30, + .swagger-ui .hover-bg-black-30:focus, + .swagger-ui .hover-bg-black-30:hover { + background-color: rgba(0, 0, 0, .3); + } + + .swagger-ui .bg-black-20, + .swagger-ui .hover-bg-black-20:focus, + .swagger-ui .hover-bg-black-20:hover { + background-color: rgba(0, 0, 0, .2); + } + + .swagger-ui .bg-white-90, + .swagger-ui .hover-bg-white-90:focus, + .swagger-ui .hover-bg-white-90:hover { + background-color: rgba(28, 28, 33, .9); + } + + .swagger-ui .bg-white-80, + .swagger-ui .hover-bg-white-80:focus, + .swagger-ui .hover-bg-white-80:hover { + background-color: rgba(28, 28, 33, .8); + } + + .swagger-ui .bg-white-70, + .swagger-ui .hover-bg-white-70:focus, + .swagger-ui .hover-bg-white-70:hover { + background-color: rgba(28, 28, 33, .7); + } + + .swagger-ui .bg-white-60, + .swagger-ui .hover-bg-white-60:focus, + .swagger-ui .hover-bg-white-60:hover { + background-color: rgba(28, 28, 33, .6); + } + + .swagger-ui .bg-white-50, + .swagger-ui .hover-bg-white-50:focus, + .swagger-ui .hover-bg-white-50:hover { + background-color: rgba(28, 28, 33, .5); + } + + .swagger-ui .bg-white-40, + .swagger-ui .hover-bg-white-40:focus, + .swagger-ui .hover-bg-white-40:hover { + background-color: rgba(28, 28, 33, .4); + } + + .swagger-ui .bg-white-30, + .swagger-ui .hover-bg-white-30:focus, + .swagger-ui .hover-bg-white-30:hover { + background-color: rgba(28, 28, 33, .3); + } + + .swagger-ui .bg-white-20, + .swagger-ui .hover-bg-white-20:focus, + .swagger-ui .hover-bg-white-20:hover { + background-color: rgba(28, 28, 33, .2); + } + + .swagger-ui .bg-black, + .swagger-ui .hover-bg-black:focus, + .swagger-ui .hover-bg-black:hover { + background-color: #000; + } + + .swagger-ui .bg-near-black, + .swagger-ui .hover-bg-near-black:focus, + .swagger-ui .hover-bg-near-black:hover { + background-color: #121212; + } + + .swagger-ui .bg-dark-gray, + .swagger-ui .hover-bg-dark-gray:focus, + .swagger-ui .hover-bg-dark-gray:hover { + background-color: #333; + } + + .swagger-ui .bg-mid-gray, + .swagger-ui .hover-bg-mid-gray:focus, + .swagger-ui .hover-bg-mid-gray:hover { + background-color: #545454; + } + + .swagger-ui .bg-gray, + .swagger-ui .hover-bg-gray:focus, + .swagger-ui .hover-bg-gray:hover { + background-color: #787878; + } + + .swagger-ui .bg-silver, + .swagger-ui .hover-bg-silver:focus, + .swagger-ui .hover-bg-silver:hover { + background-color: #999; + } + + .swagger-ui .bg-white, + .swagger-ui .hover-bg-white:focus, + .swagger-ui .hover-bg-white:hover { + background-color: #1c1c21; + } + + .swagger-ui .bg-transparent, + .swagger-ui .hover-bg-transparent:focus, + .swagger-ui .hover-bg-transparent:hover { + background-color: transparent; + } + + .swagger-ui .bg-dark-red, + .swagger-ui .hover-bg-dark-red:focus, + .swagger-ui .hover-bg-dark-red:hover { + background-color: #bc2f36; + } + + .swagger-ui .bg-red, + .swagger-ui .hover-bg-red:focus, + .swagger-ui .hover-bg-red:hover { + background-color: #c83932; + } + + .swagger-ui .bg-light-red, + .swagger-ui .hover-bg-light-red:focus, + .swagger-ui .hover-bg-light-red:hover { + background-color: #ab3c2b; + } + + .swagger-ui .bg-orange, + .swagger-ui .hover-bg-orange:focus, + .swagger-ui .hover-bg-orange:hover { + background-color: #cc6e33; + } + + .swagger-ui .bg-gold, + .swagger-ui .bg-light-yellow, + .swagger-ui .bg-washed-yellow, + .swagger-ui .bg-yellow, + .swagger-ui .hover-bg-gold:focus, + .swagger-ui .hover-bg-gold:hover, + .swagger-ui .hover-bg-light-yellow:focus, + .swagger-ui .hover-bg-light-yellow:hover, + .swagger-ui .hover-bg-washed-yellow:focus, + .swagger-ui .hover-bg-washed-yellow:hover, + .swagger-ui .hover-bg-yellow:focus, + .swagger-ui .hover-bg-yellow:hover { + background-color: #664b00; + } + + .swagger-ui .bg-purple, + .swagger-ui .hover-bg-purple:focus, + .swagger-ui .hover-bg-purple:hover { + background-color: #5e2ca5; + } + + .swagger-ui .bg-light-purple, + .swagger-ui .hover-bg-light-purple:focus, + .swagger-ui .hover-bg-light-purple:hover { + background-color: #672caf; + } + + .swagger-ui .bg-dark-pink, + .swagger-ui .hover-bg-dark-pink:focus, + .swagger-ui .hover-bg-dark-pink:hover { + background-color: #ab2b81; + } + + .swagger-ui .bg-hot-pink, + .swagger-ui .hover-bg-hot-pink:focus, + .swagger-ui .hover-bg-hot-pink:hover { + background-color: #c03086; + } + + .swagger-ui .bg-pink, + .swagger-ui .hover-bg-pink:focus, + .swagger-ui .hover-bg-pink:hover { + background-color: #8f2464; + } + + .swagger-ui .bg-light-pink, + .swagger-ui .hover-bg-light-pink:focus, + .swagger-ui .hover-bg-light-pink:hover { + background-color: #721d4d; + } + + .swagger-ui .bg-dark-green, + .swagger-ui .hover-bg-dark-green:focus, + .swagger-ui .hover-bg-dark-green:hover { + background-color: #1c6e50; + } + + .swagger-ui .bg-green, + .swagger-ui .hover-bg-green:focus, + .swagger-ui .hover-bg-green:hover { + background-color: #279b70; + } + + .swagger-ui .bg-light-green, + .swagger-ui .hover-bg-light-green:focus, + .swagger-ui .hover-bg-light-green:hover { + background-color: #228762; + } + + .swagger-ui .bg-navy, + .swagger-ui .hover-bg-navy:focus, + .swagger-ui .hover-bg-navy:hover { + background-color: #0d1d35; + } + + .swagger-ui .bg-dark-blue, + .swagger-ui .hover-bg-dark-blue:focus, + .swagger-ui .hover-bg-dark-blue:hover { + background-color: #20497e; + } + + .swagger-ui .bg-blue, + .swagger-ui .hover-bg-blue:focus, + .swagger-ui .hover-bg-blue:hover { + background-color: #4380d0; + } + + .swagger-ui .bg-light-blue, + .swagger-ui .hover-bg-light-blue:focus, + .swagger-ui .hover-bg-light-blue:hover { + background-color: #20517e; + } + + .swagger-ui .bg-lightest-blue, + .swagger-ui .hover-bg-lightest-blue:focus, + .swagger-ui .hover-bg-lightest-blue:hover { + background-color: #143a52; + } + + .swagger-ui .bg-washed-blue, + .swagger-ui .hover-bg-washed-blue:focus, + .swagger-ui .hover-bg-washed-blue:hover { + background-color: #0c312d; + } + + .swagger-ui .bg-washed-green, + .swagger-ui .hover-bg-washed-green:focus, + .swagger-ui .hover-bg-washed-green:hover { + background-color: #0f3d2c; + } + + .swagger-ui .bg-washed-red, + .swagger-ui .hover-bg-washed-red:focus, + .swagger-ui .hover-bg-washed-red:hover { + background-color: #411010; + } + + .swagger-ui .bg-inherit, + .swagger-ui .hover-bg-inherit:focus, + .swagger-ui .hover-bg-inherit:hover { + background-color: inherit; + } + + .swagger-ui .shadow-hover { + transition: all .5s cubic-bezier(.165, .84, .44, 1) 0s; + } + + .swagger-ui .shadow-hover::after { + border-radius: inherit; + box-shadow: rgba(0, 0, 0, .2) 0 0 16px 2px; + content: ""; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + transition: opacity .5s cubic-bezier(.165, .84, .44, 1) 0s; + width: 100%; + z-index: -1; + } + + .swagger-ui .bg-animate, + .swagger-ui .bg-animate:focus, + .swagger-ui .bg-animate:hover { + transition: background-color .15s ease-in-out 0s; + } + + .swagger-ui .nested-links a { + color: #99bae6; + transition: color .15s ease-in 0s; + } + + .swagger-ui .nested-links a:focus, + .swagger-ui .nested-links a:hover { + color: #a9cbea; + transition: color .15s ease-in 0s; + } + + .swagger-ui .opblock-tag { + border-bottom: 1px solid rgba(58, 64, 80, .3); + color: #b5bac9; + transition: all .2s ease 0s; + } + + .swagger-ui .opblock-tag svg, + .swagger-ui section.models h4 svg { + transition: all .4s ease 0s; + } + + .swagger-ui .opblock { + border: 1px solid #000; + border-radius: 4px; + box-shadow: rgba(0, 0, 0, .19) 0 0 3px; + margin: 0 0 15px; + } + + .swagger-ui .opblock .tab-header .tab-item.active h4 span::after { + background: gray; + } + + .swagger-ui .opblock.is-open .opblock-summary { + border-bottom: 1px solid #000; + } + + .swagger-ui .opblock .opblock-section-header { + background: rgba(28, 28, 33, .8); + box-shadow: rgba(0, 0, 0, .1) 0 1px 2px; + } + + .swagger-ui .opblock .opblock-section-header>label>span { + padding: 0 10px 0 0; + } + + .swagger-ui .opblock .opblock-summary-method { + background: #000; + color: #fff; + text-shadow: rgba(0, 0, 0, .1) 0 1px 0; + } + + .swagger-ui .opblock.opblock-post { + background: rgba(72, 203, 144, .1); + border-color: #48cb90; + } + + .swagger-ui .opblock.opblock-post .opblock-summary-method, + .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { + background: #48cb90; + } + + .swagger-ui .opblock.opblock-post .opblock-summary { + border-color: #48cb90; + } + + .swagger-ui .opblock.opblock-put { + background: rgba(213, 157, 88, .1); + border-color: #d59d58; + } + + .swagger-ui .opblock.opblock-put .opblock-summary-method, + .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after { + background: #d59d58; + } + + .swagger-ui .opblock.opblock-put .opblock-summary { + border-color: #d59d58; + } + + .swagger-ui .opblock.opblock-delete { + background: rgba(200, 50, 50, .1); + border-color: #c83232; + } + + .swagger-ui .opblock.opblock-delete .opblock-summary-method, + .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { + background: #c83232; + } + + .swagger-ui .opblock.opblock-delete .opblock-summary { + border-color: #c83232; + } + + .swagger-ui .opblock.opblock-get { + background: rgba(42, 105, 167, .1); + border-color: #2a69a7; + } + + .swagger-ui .opblock.opblock-get .opblock-summary-method, + .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { + background: #2a69a7; + } + + .swagger-ui .opblock.opblock-get .opblock-summary { + border-color: #2a69a7; + } + + .swagger-ui .opblock.opblock-patch { + background: rgba(92, 214, 188, .1); + border-color: #5cd6bc; + } + + .swagger-ui .opblock.opblock-patch .opblock-summary-method, + .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after { + background: #5cd6bc; + } + + .swagger-ui .opblock.opblock-patch .opblock-summary { + border-color: #5cd6bc; + } + + .swagger-ui .opblock.opblock-head { + background: rgba(140, 63, 207, .1); + border-color: #8c3fcf; + } + + .swagger-ui .opblock.opblock-head .opblock-summary-method, + .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after { + background: #8c3fcf; + } + + .swagger-ui .opblock.opblock-head .opblock-summary { + border-color: #8c3fcf; + } + + .swagger-ui .opblock.opblock-options { + background: rgba(36, 89, 143, .1); + border-color: #24598f; + } + + .swagger-ui .opblock.opblock-options .opblock-summary-method, + .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after { + background: #24598f; + } + + .swagger-ui .opblock.opblock-options .opblock-summary { + border-color: #24598f; + } + + .swagger-ui .opblock.opblock-deprecated { + background: rgba(46, 46, 46, .1); + border-color: #2e2e2e; + opacity: .6; + } + + .swagger-ui .opblock.opblock-deprecated .opblock-summary-method, + .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after { + background: #2e2e2e; + } + + .swagger-ui .opblock.opblock-deprecated .opblock-summary { + border-color: #2e2e2e; + } + + .swagger-ui .filter .operation-filter-input { + border: 2px solid #2b3446; + } + + .swagger-ui .tab li:first-of-type::after { + background: rgba(0, 0, 0, .2); + } + + .swagger-ui .download-contents { + background: #7c8192; + color: #fff; + } + + .swagger-ui .scheme-container { + background: #1c1c21; + box-shadow: rgba(0, 0, 0, .15) 0 1px 2px 0; + } + + .swagger-ui .loading-container .loading::before { + animation: 1s linear 0s infinite normal none running rotation, .5s ease 0s 1 normal none running opacity; + border-color: rgba(0, 0, 0, .6) rgba(84, 84, 84, .1) rgba(84, 84, 84, .1); + } + + .swagger-ui .response-control-media-type--accept-controller select { + border-color: #196619; + } + + .swagger-ui .response-control-media-type__accept-message { + color: #99e699; + } + + .swagger-ui .version-pragma__message code { + background-color: #3b3b3b; + } + + .swagger-ui .btn { + background: 0 0; + border: 2px solid gray; + box-shadow: rgba(0, 0, 0, .1) 0 1px 2px; + color: #b5bac9; + } + + .swagger-ui .btn:hover { + box-shadow: rgba(0, 0, 0, .3) 0 0 5px; + } + + .swagger-ui .btn.authorize, + .swagger-ui .btn.cancel { + background-color: transparent; + border-color: #a72a2a; + color: #e69999; + } + + .swagger-ui .btn.cancel:hover { + background-color: #a72a2a; + color: #fff; + } + + .swagger-ui .btn.authorize { + border-color: #48cb90; + color: #9ce3c3; + } + + .swagger-ui .btn.authorize svg { + fill: #9ce3c3; + } + + .btn.authorize.unlocked:hover { + background-color: #48cb90; + color: #fff; + } + + .btn.authorize.unlocked:hover svg { + fill: #fbfbfb; + } + + .swagger-ui .btn.execute { + background-color: #5892d5; + border-color: #5892d5; + color: #fff; + } + + .swagger-ui .copy-to-clipboard { + background: #7c8192; + } + + .swagger-ui .copy-to-clipboard button { + background: url("data:image/svg+xml;charset=utf-8,") 50% center no-repeat; + } + + .swagger-ui select { + background: url("data:image/svg+xml;charset=utf-8,") right 10px center/20px no-repeat #212121; + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) right 10px center/20px no-repeat #1c1c21; + border: 2px solid #41444e; + } + + .swagger-ui select[multiple] { + background: #212121; + } + + .swagger-ui button.invalid, + .swagger-ui input[type=email].invalid, + .swagger-ui input[type=file].invalid, + .swagger-ui input[type=password].invalid, + .swagger-ui input[type=search].invalid, + .swagger-ui input[type=text].invalid, + .swagger-ui select.invalid, + .swagger-ui textarea.invalid { + background: #390e0e; + border-color: #c83232; + } + + .swagger-ui input[type=email], + .swagger-ui input[type=file], + .swagger-ui input[type=password], + .swagger-ui input[type=search], + .swagger-ui input[type=text], + .swagger-ui textarea { + background: #1c1c21; + border: 1px solid #404040; + } + + .swagger-ui textarea { + background: rgba(28, 28, 33, .8); + color: #b5bac9; + } + + .swagger-ui input[disabled], + .swagger-ui select[disabled] { + background-color: #1f1f1f; + color: #bfbfbf; + } + + .swagger-ui textarea[disabled] { + background-color: #41444e; + color: #fff; + } + + .swagger-ui select[disabled] { + border-color: #878787; + } + + .swagger-ui textarea:focus { + border: 2px solid #2a69a7; + } + + .swagger-ui .checkbox input[type=checkbox]+label>.item { + background: #303030; + box-shadow: #303030 0 0 0 2px; + } + + .swagger-ui .checkbox input[type=checkbox]:checked+label>.item { + background: url("data:image/svg+xml;charset=utf-8,") 50% center no-repeat #303030; + } + + .swagger-ui .dialog-ux .backdrop-ux { + background: rgba(0, 0, 0, .8); + } + + .swagger-ui .dialog-ux .modal-ux { + background: #1c1c21; + border: 1px solid #2e2e2e; + box-shadow: rgba(0, 0, 0, .2) 0 10px 30px 0; + } + + .swagger-ui .dialog-ux .modal-ux-header .close-modal { + background: 0 0; + } + + .swagger-ui .model .deprecated span, + .swagger-ui .model .deprecated td { + color: #bfbfbf !important; + } + + .swagger-ui .model-toggle::after { + background: url("data:image/svg+xml;charset=utf-8,") 50% center/100% no-repeat; + } + + .swagger-ui .model-hint { + background: rgba(0, 0, 0, .7); + color: #ebebeb; + } + + .swagger-ui section.models { + border: 1px solid rgba(58, 64, 80, .3); + } + + .swagger-ui section.models.is-open h4 { + border-bottom: 1px solid rgba(58, 64, 80, .3); + } + + .swagger-ui section.models .model-container { + background: rgba(0, 0, 0, .05); + } + + .swagger-ui section.models .model-container:hover { + background: rgba(0, 0, 0, .07); + } + + .swagger-ui .model-box { + background: rgba(0, 0, 0, .1); + } + + .swagger-ui .prop-type { + color: #aaaad4; + } + + .swagger-ui table thead tr td, + .swagger-ui table thead tr th { + border-bottom: 1px solid rgba(58, 64, 80, .2); + color: #b5bac9; + } + + .swagger-ui .parameter__name.required::after { + color: rgba(230, 153, 153, .6); + } + + .swagger-ui .topbar .download-url-wrapper .select-label { + color: #f0f0f0; + } + + .swagger-ui .topbar .download-url-wrapper .download-url-button { + background: #63a040; + color: #fff; + } + + .swagger-ui .info .title small { + background: #7c8492; + } + + .swagger-ui .info .title small.version-stamp { + background-color: #7a9b27; + } + + .swagger-ui .auth-container .errors { + background-color: #350d0d; + color: #b5bac9; + } + + .swagger-ui .errors-wrapper { + background: rgba(200, 50, 50, .1); + border: 2px solid #c83232; + } + + .swagger-ui .markdown code, + .swagger-ui .renderedmarkdown code { + background: rgba(0, 0, 0, .05); + color: #c299e6; + } + + .swagger-ui .model-toggle:after { + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) 50% no-repeat; + } + + /* arrows for each operation and request are now white */ + .arrow, + #large-arrow-up { + fill: #fff; + } + + #unlocked { + fill: #fff; + } + + ::-webkit-scrollbar-track { + background-color: #646464 !important; + } + + ::-webkit-scrollbar-thumb { + background-color: #242424 !important; + border: 2px solid #3e4346 !important; + } + + ::-webkit-scrollbar-button:vertical:start:decrement { + background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:vertical:end:increment { + background: linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:end:increment { + background: linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:start:decrement { + background: linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button, + ::-webkit-scrollbar-track-piece { + background-color: #3e4346 !important; + } + + .swagger-ui .black, + .swagger-ui .checkbox, + .swagger-ui .dark-gray, + .swagger-ui .download-url-wrapper .loading, + .swagger-ui .errors-wrapper .errors small, + .swagger-ui .fallback, + .swagger-ui .filter .loading, + .swagger-ui .gray, + .swagger-ui .hover-black:focus, + .swagger-ui .hover-black:hover, + .swagger-ui .hover-dark-gray:focus, + .swagger-ui .hover-dark-gray:hover, + .swagger-ui .hover-gray:focus, + .swagger-ui .hover-gray:hover, + .swagger-ui .hover-light-silver:focus, + .swagger-ui .hover-light-silver:hover, + .swagger-ui .hover-mid-gray:focus, + .swagger-ui .hover-mid-gray:hover, + .swagger-ui .hover-near-black:focus, + .swagger-ui .hover-near-black:hover, + .swagger-ui .hover-silver:focus, + .swagger-ui .hover-silver:hover, + .swagger-ui .light-silver, + .swagger-ui .markdown pre, + .swagger-ui .mid-gray, + .swagger-ui .model .property, + .swagger-ui .model .property.primitive, + .swagger-ui .model-title, + .swagger-ui .near-black, + .swagger-ui .parameter__extension, + .swagger-ui .parameter__in, + .swagger-ui .prop-format, + .swagger-ui .renderedmarkdown pre, + .swagger-ui .response-col_links .response-undocumented, + .swagger-ui .response-col_status .response-undocumented, + .swagger-ui .silver, + .swagger-ui section.models h4, + .swagger-ui section.models h5, + .swagger-ui span.token-not-formatted, + .swagger-ui span.token-string, + .swagger-ui table.headers .header-example, + .swagger-ui table.model tr.description, + .swagger-ui table.model tr.extension { + color: #bfbfbf; + } + + .swagger-ui .hover-white:focus, + .swagger-ui .hover-white:hover, + .swagger-ui .info .title small pre, + .swagger-ui .topbar a, + .swagger-ui .white { + color: #fff; + } + + .swagger-ui .bg-black-10, + .swagger-ui .hover-bg-black-10:focus, + .swagger-ui .hover-bg-black-10:hover, + .swagger-ui .stripe-dark:nth-child(2n + 1) { + background-color: rgba(0, 0, 0, .1); + } + + .swagger-ui .bg-white-10, + .swagger-ui .hover-bg-white-10:focus, + .swagger-ui .hover-bg-white-10:hover, + .swagger-ui .stripe-light:nth-child(2n + 1) { + background-color: rgba(28, 28, 33, .1); + } + + .swagger-ui .bg-light-silver, + .swagger-ui .hover-bg-light-silver:focus, + .swagger-ui .hover-bg-light-silver:hover, + .swagger-ui .striped--light-silver:nth-child(2n + 1) { + background-color: #6e6e6e; + } + + .swagger-ui .bg-moon-gray, + .swagger-ui .hover-bg-moon-gray:focus, + .swagger-ui .hover-bg-moon-gray:hover, + .swagger-ui .striped--moon-gray:nth-child(2n + 1) { + background-color: #4d4d4d; + } + + .swagger-ui .bg-light-gray, + .swagger-ui .hover-bg-light-gray:focus, + .swagger-ui .hover-bg-light-gray:hover, + .swagger-ui .striped--light-gray:nth-child(2n + 1) { + background-color: #2b2b2b; + } + + .swagger-ui .bg-near-white, + .swagger-ui .hover-bg-near-white:focus, + .swagger-ui .hover-bg-near-white:hover, + .swagger-ui .striped--near-white:nth-child(2n + 1) { + background-color: #242424; + } + + .swagger-ui .opblock-tag:hover, + .swagger-ui section.models h4:hover { + background: rgba(0, 0, 0, .02); + } + + .swagger-ui .checkbox p, + .swagger-ui .dialog-ux .modal-ux-content h4, + .swagger-ui .dialog-ux .modal-ux-content p, + .swagger-ui .dialog-ux .modal-ux-header h3, + .swagger-ui .errors-wrapper .errors h4, + .swagger-ui .errors-wrapper hgroup h4, + .swagger-ui .info .base-url, + .swagger-ui .info .title, + .swagger-ui .info h1, + .swagger-ui .info h2, + .swagger-ui .info h3, + .swagger-ui .info h4, + .swagger-ui .info h5, + .swagger-ui .info li, + .swagger-ui .info p, + .swagger-ui .info table, + .swagger-ui .loading-container .loading::after, + .swagger-ui .model, + .swagger-ui .opblock .opblock-section-header h4, + .swagger-ui .opblock .opblock-section-header>label, + .swagger-ui .opblock .opblock-summary-description, + .swagger-ui .opblock .opblock-summary-operation-id, + .swagger-ui .opblock .opblock-summary-path, + .swagger-ui .opblock .opblock-summary-path__deprecated, + .swagger-ui .opblock-description-wrapper, + .swagger-ui .opblock-description-wrapper h4, + .swagger-ui .opblock-description-wrapper p, + .swagger-ui .opblock-external-docs-wrapper, + .swagger-ui .opblock-external-docs-wrapper h4, + .swagger-ui .opblock-external-docs-wrapper p, + .swagger-ui .opblock-tag small, + .swagger-ui .opblock-title_normal, + .swagger-ui .opblock-title_normal h4, + .swagger-ui .opblock-title_normal p, + .swagger-ui .parameter__name, + .swagger-ui .parameter__type, + .swagger-ui .response-col_links, + .swagger-ui .response-col_status, + .swagger-ui .responses-inner h4, + .swagger-ui .responses-inner h5, + .swagger-ui .scheme-container .schemes>label, + .swagger-ui .scopes h2, + .swagger-ui .servers>label, + .swagger-ui .tab li, + .swagger-ui label, + .swagger-ui select, + .swagger-ui table.headers td { + color: #b5bac9; + } + + .swagger-ui .download-url-wrapper .failed, + .swagger-ui .filter .failed, + .swagger-ui .model-deprecated-warning, + .swagger-ui .parameter__deprecated, + .swagger-ui .parameter__name.required span, + .swagger-ui table.model tr.property-row .star { + color: #e69999; + } + + .swagger-ui .opblock-body pre.microlight, + .swagger-ui textarea.curl { + background: #41444e; + border-radius: 4px; + color: #fff; + } + + .swagger-ui .expand-methods svg, + .swagger-ui .expand-methods:hover svg { + fill: #bfbfbf; + } + + .swagger-ui .auth-container, + .swagger-ui .dialog-ux .modal-ux-header { + border-bottom: 1px solid #2e2e2e; + } + + .swagger-ui .topbar .download-url-wrapper .select-label select, + .swagger-ui .topbar .download-url-wrapper input[type=text] { + border: 2px solid #63a040; + } + + .swagger-ui .info a, + .swagger-ui .info a:hover, + .swagger-ui .scopes h2 a { + color: #99bde6; + } + + /* Dark Scrollbar */ + ::-webkit-scrollbar { + width: 14px; + height: 14px; + } + + ::-webkit-scrollbar-button { + background-color: #3e4346 !important; + } + + ::-webkit-scrollbar-track { + background-color: #646464 !important; + } + + ::-webkit-scrollbar-track-piece { + background-color: #3e4346 !important; + } + + ::-webkit-scrollbar-thumb { + height: 50px; + background-color: #242424 !important; + border: 2px solid #3e4346 !important; + } + + ::-webkit-scrollbar-corner {} + + ::-webkit-resizer {} + + ::-webkit-scrollbar-button:vertical:start:decrement { + background: + linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), + linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:vertical:end:increment { + background: + linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:end:increment { + background: + linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:start:decrement { + background: + linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } +} \ No newline at end of file diff --git a/server/swagger/index.css b/server/swagger/index.css new file mode 100644 index 0000000..1d346cb --- /dev/null +++ b/server/swagger/index.css @@ -0,0 +1,3 @@ +.schemes.wrapper>div:first-of-type { + display: none; +} \ No newline at end of file diff --git a/server/swagger/index.js b/server/swagger/index.js new file mode 100644 index 0000000..3c6bd38 --- /dev/null +++ b/server/swagger/index.js @@ -0,0 +1,28 @@ +function waitForElm(selector) { + return new Promise((resolve) => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver((_mutations) => { + if (document.querySelector(selector)) { + resolve(document.querySelector(selector)); + observer.disconnect(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} + +// Force change the Swagger logo in the header +waitForElm(".topbar-wrapper").then((elm) => { + if (window.SWAGGER_DOCS_ENV === "development") { + elm.innerHTML = ``; + } else { + elm.innerHTML = ``; + } +}); diff --git a/server/swagger/init.js b/server/swagger/init.js new file mode 100644 index 0000000..0562230 --- /dev/null +++ b/server/swagger/init.js @@ -0,0 +1,79 @@ +const swaggerAutogen = require("swagger-autogen")({ openapi: "3.0.0" }); +const fs = require("fs"); +const path = require("path"); + +const doc = { + info: { + version: "1.0.0", + title: "AnythingLLM Developer API", + description: + "API endpoints that enable programmatic reading, writing, and updating of your AnythingLLM instance. UI supplied by Swagger.io.", + }, + // Swagger-autogen does not allow us to use relative paths as these will resolve to + // http:///api in the openapi.json file, so we need to monkey-patch this post-generation. + host: "/api", + schemes: ["http"], + securityDefinitions: { + BearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + }, + security: [{ BearerAuth: [] }], + definitions: { + InvalidAPIKey: { + message: "Invalid API Key", + }, + }, +}; + +const outputFile = path.resolve(__dirname, "./openapi.json"); +const endpointsFiles = [ + "../endpoints/api/auth/index.js", + "../endpoints/api/admin/index.js", + "../endpoints/api/document/index.js", + "../endpoints/api/workspace/index.js", + "../endpoints/api/system/index.js", + "../endpoints/api/workspaceThread/index.js", + "../endpoints/api/userManagement/index.js", + "../endpoints/api/openai/index.js", + "../endpoints/api/embed/index.js", +]; + +swaggerAutogen(outputFile, endpointsFiles, doc).then(({ data }) => { + // Remove Authorization parameters from arguments. + for (const path of Object.keys(data.paths)) { + if (data.paths[path].hasOwnProperty("get")) { + let parameters = data.paths[path].get?.parameters || []; + parameters = parameters.filter((arg) => arg.name !== "Authorization"); + data.paths[path].get.parameters = parameters; + } + + if (data.paths[path].hasOwnProperty("post")) { + let parameters = data.paths[path].post?.parameters || []; + parameters = parameters.filter((arg) => arg.name !== "Authorization"); + data.paths[path].post.parameters = parameters; + } + + if (data.paths[path].hasOwnProperty("delete")) { + let parameters = data.paths[path].delete?.parameters || []; + parameters = parameters.filter((arg) => arg.name !== "Authorization"); + data.paths[path].delete.parameters = parameters; + } + } + + const openApiSpec = { + ...data, + servers: [ + { + url: "/api", + }, + ], + }; + fs.writeFileSync(outputFile, JSON.stringify(openApiSpec, null, 2), { + encoding: "utf-8", + flag: "w", + }); + console.log(`Swagger-autogen: \x1b[32mPatched servers.url ✔\x1b[0m`); +}); diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json new file mode 100644 index 0000000..5468db3 --- /dev/null +++ b/server/swagger/openapi.json @@ -0,0 +1,4217 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "AnythingLLM Developer API", + "description": "API endpoints that enable programmatic reading, writing, and updating of your AnythingLLM instance. UI supplied by Swagger.io." + }, + "servers": [ + { + "url": "/api" + } + ], + "paths": { + "/v1/auth": { + "get": { + "tags": [ + "Authentication" + ], + "description": "Verify the attached Authentication header contains a valid API token.", + "parameters": [], + "responses": { + "200": { + "description": "Valid auth token was found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "authenticated": true + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + } + } + } + }, + "/v1/admin/is-multi-user-mode": { + "get": { + "tags": [ + "Admin" + ], + "description": "Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "isMultiUser": true + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + } + } + } + }, + "/v1/admin/users": { + "get": { + "tags": [ + "Admin" + ], + "description": "Check to see if the instance is in multi-user-mode first. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "users": [ + { + "username": "sample-sam", + "role": "default" + } + ] + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/admin/users/new": { + "post": { + "tags": [ + "Admin" + ], + "description": "Create a new user with username and password. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "user": { + "id": 1, + "username": "sample-sam", + "role": "default" + }, + "error": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Key pair object that will define the new user to add to the system.", + "required": true, + "content": { + "application/json": { + "example": { + "username": "sample-sam", + "password": "hunter2", + "role": "default | admin" + } + } + } + } + } + }, + "/v1/admin/users/{id}": { + "post": { + "tags": [ + "Admin" + ], + "description": "Update existing user settings. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "id of the user in the database." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Key pair object that will update the found user. All fields are optional and will not update unless specified.", + "required": true, + "content": { + "application/json": { + "example": { + "username": "sample-sam", + "password": "hunter2", + "role": "default | admin", + "suspended": 0 + } + } + } + } + }, + "delete": { + "tags": [ + "Admin" + ], + "description": "Delete existing user by id. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "id of the user in the database." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/admin/invites": { + "get": { + "tags": [ + "Admin" + ], + "description": "List all existing invitations to instance regardless of status. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "invites": [ + { + "id": 1, + "status": "pending", + "code": "abc-123", + "claimedBy": null + } + ] + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/admin/invite/new": { + "post": { + "tags": [ + "Admin" + ], + "description": "Create a new invite code for someone to use to register with instance. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "invite": { + "id": 1, + "status": "pending", + "code": "abc-123" + }, + "error": null + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Request body for creation parameters of the invitation", + "required": false, + "content": { + "application/json": { + "example": { + "workspaceIds": [ + 1, + 2, + 45 + ] + } + } + } + } + } + }, + "/v1/admin/invite/{id}": { + "delete": { + "tags": [ + "Admin" + ], + "description": "Deactivates (soft-delete) invite by id. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "id of the invite in the database." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/admin/workspaces/{workspaceId}/users": { + "get": { + "tags": [ + "Admin" + ], + "description": "Retrieve a list of users with permissions to access the specified workspace.", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "id of the workspace." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "users": [ + { + "userId": 1, + "role": "admin" + }, + { + "userId": 2, + "role": "member" + } + ] + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/admin/workspaces/{workspaceId}/update-users": { + "post": { + "tags": [ + "Admin" + ], + "description": "Overwrite workspace permissions to only be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "id of the workspace in the database." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Entire array of user ids who can access the workspace. All fields are optional and will not update unless specified.", + "required": true, + "content": { + "application/json": { + "example": { + "userIds": [ + 1, + 2, + 4, + 12 + ] + } + } + } + }, + "deprecated": true + } + }, + "/v1/admin/workspaces/{workspaceSlug}/manage-users": { + "post": { + "tags": [ + "Admin" + ], + "description": "Set workspace permissions to be accessible by the given user ids and admins. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [ + { + "name": "workspaceSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "slug of the workspace in the database" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null, + "users": [ + { + "userId": 1, + "username": "main-admin", + "role": "admin" + }, + { + "userId": 2, + "username": "sample-sam", + "role": "default" + } + ] + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Array of user ids who will be given access to the target workspace. reset will remove all existing users from the workspace and only add the new users - default false.", + "required": true, + "content": { + "application/json": { + "example": { + "userIds": [ + 1, + 2, + 4, + 12 + ], + "reset": false + } + } + } + } + } + }, + "/v1/admin/workspace-chats": { + "post": { + "tags": [ + "Admin" + ], + "description": "All chats in the system ordered by most recent. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Page offset to show of workspace chats. All fields are optional and will not update unless specified.", + "required": false, + "content": { + "application/json": { + "example": { + "offset": 2 + } + } + } + } + } + }, + "/v1/admin/preferences": { + "post": { + "tags": [ + "Admin" + ], + "description": "Update multi-user preferences for instance. Methods are disabled until multi user mode is enabled via the UI.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Method denied" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Object with setting key and new value to set. All keys are optional and will not update unless specified.", + "required": true, + "content": { + "application/json": { + "example": { + "support_email": "support@example.com" + } + } + } + } + } + }, + "/v1/document/upload": { + "post": { + "tags": [ + "Documents" + ], + "description": "Upload a new file to AnythingLLM to be parsed and prepared for embedding, with optional metadata.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null, + "documents": [ + { + "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "url": "file://Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt", + "title": "anythingllm.txt", + "docAuthor": "Unknown", + "description": "Unknown", + "docSource": "a text file uploaded by the user.", + "chunkSource": "anythingllm.txt", + "published": "1/16/2024, 3:07:00 PM", + "wordCount": 93, + "token_count_estimate": 115 + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "422": { + "description": "Unprocessable Entity" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "File to be uploaded.", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload" + }, + "addToWorkspaces": { + "type": "string", + "description": "comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2" + }, + "metadata": { + "type": "object", + "description": "Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.", + "example": { + "title": "Custom Title", + "docAuthor": "Author Name", + "description": "A brief description", + "docSource": "Source of the document" + } + } + } + } + } + } + } + } + }, + "/v1/document/upload/{folderName}": { + "post": { + "tags": [ + "Documents" + ], + "description": "Upload a new file to a specific folder in AnythingLLM to be parsed and prepared for embedding. If the folder does not exist, it will be created.", + "parameters": [ + { + "name": "folderName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target folder path (defaults to 'custom-documents' if not provided)", + "example": "my-folder" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null, + "documents": [ + { + "location": "custom-documents/anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "name": "anythingllm.txt-6e8be64c-c162-4b43-9997-b068c0071e8b.json", + "url": "file://Users/tim/Documents/anything-llm/collector/hotdir/anythingllm.txt", + "title": "anythingllm.txt", + "docAuthor": "Unknown", + "description": "Unknown", + "docSource": "a text file uploaded by the user.", + "chunkSource": "anythingllm.txt", + "published": "1/16/2024, 3:07:00 PM", + "wordCount": 93, + "token_count_estimate": 115 + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "422": { + "description": "Unprocessable Entity" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": false, + "error": "Document processing API is not online. Document will not be processed automatically." + } + } + } + } + } + }, + "requestBody": { + "description": "File to be uploaded, with optional metadata.", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload" + }, + "addToWorkspaces": { + "type": "string", + "description": "comma-separated text-string of workspace slugs to embed the document into post-upload. eg: workspace1,workspace2" + }, + "metadata": { + "type": "object", + "description": "Key:Value pairs of metadata to attach to the document in JSON Object format. Only specific keys are allowed - see example.", + "example": { + "title": "Custom Title", + "docAuthor": "Author Name", + "description": "A brief description", + "docSource": "Source of the document" + } + } + } + } + } + } + } + } + }, + "/v1/document/upload-link": { + "post": { + "tags": [ + "Documents" + ], + "description": "Upload a valid URL for AnythingLLM to scrape and prepare for embedding. Optionally, specify a comma-separated list of workspace slugs to embed the document into post-upload.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null, + "documents": [ + { + "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc", + "url": "file://useanything_com.html", + "title": "useanything_com.html", + "docAuthor": "no author found", + "description": "No description found.", + "docSource": "URL link uploaded by the user.", + "chunkSource": "https:anythingllm.com.html", + "published": "1/16/2024, 3:46:33 PM", + "wordCount": 252, + "pageContent": "AnythingLLM is the best....", + "token_count_estimate": 447, + "location": "custom-documents/url-useanything_com-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "422": { + "description": "Unprocessable Entity" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Link of web address to be scraped and optionally a comma-separated list of workspace slugs to embed the document into post-upload, and optional metadata.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "link": "https://anythingllm.com", + "addToWorkspaces": "workspace1,workspace2", + "scraperHeaders": { + "Authorization": "Bearer token123", + "My-Custom-Header": "value" + }, + "metadata": { + "title": "Custom Title", + "docAuthor": "Author Name", + "description": "A brief description", + "docSource": "Source of the document" + } + } + } + } + } + } + } + }, + "/v1/document/raw-text": { + "post": { + "tags": [ + "Documents" + ], + "description": "Upload a file by specifying its raw text content and metadata values without having to upload a file.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null, + "documents": [ + { + "id": "c530dbe6-bff1-4b9e-b87f-710d539d20bc", + "url": "file://my-document.txt", + "title": "hello-world.txt", + "docAuthor": "no author found", + "description": "No description found.", + "docSource": "My custom description set during upload", + "chunkSource": "no chunk source specified", + "published": "1/16/2024, 3:46:33 PM", + "wordCount": 252, + "pageContent": "AnythingLLM is the best....", + "token_count_estimate": 447, + "location": "custom-documents/raw-my-doc-text-c530dbe6-bff1-4b9e-b87f-710d539d20bc.json" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "422": { + "description": "Unprocessable Entity" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Text content and metadata of the file to be saved to the system. Use metadata-schema endpoint to get the possible metadata keys", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "textContent": "This is the raw text that will be saved as a document in AnythingLLM.", + "addToWorkspaces": "workspace1,workspace2", + "metadata": { + "title": "This key is required. See in /server/endpoints/api/document/index.js:287", + "keyOne": "valueOne", + "keyTwo": "valueTwo", + "etc": "etc" + } + } + } + } + } + } + } + }, + "/v1/documents": { + "get": { + "tags": [ + "Documents" + ], + "description": "List of all locally-stored documents in instance", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "localFiles": { + "name": "documents", + "type": "folder", + "items": [ + { + "name": "my-stored-document.json", + "type": "file", + "id": "bb07c334-4dab-4419-9462-9d00065a49a1", + "url": "file://my-stored-document.txt", + "title": "my-stored-document.txt", + "cached": false + } + ] + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/documents/folder/{folderName}": { + "get": { + "tags": [ + "Documents" + ], + "description": "Get all documents stored in a specific folder.", + "parameters": [ + { + "name": "folderName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Name of the folder to retrieve documents from" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "folder": "custom-documents", + "documents": [ + { + "name": "document1.json", + "type": "file", + "cached": false, + "pinnedWorkspaces": [], + "watched": false, + "more": "data" + }, + { + "name": "document2.json", + "type": "file", + "cached": false, + "pinnedWorkspaces": [], + "watched": false, + "more": "data" + } + ] + } + } + } + }, + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/document/accepted-file-types": { + "get": { + "tags": [ + "Documents" + ], + "description": "Check available filetypes and MIMEs that can be uploaded.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "types": { + "application/mbox": [ + ".mbox" + ], + "application/pdf": [ + ".pdf" + ], + "application/vnd.oasis.opendocument.text": [ + ".odt" + ], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [ + ".docx" + ], + "text/plain": [ + ".txt", + ".md" + ] + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/document/metadata-schema": { + "get": { + "tags": [ + "Documents" + ], + "description": "Get the known available metadata schema for when doing a raw-text upload and the acceptable type of value for each key.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "schema": { + "keyOne": "string | number | nullable", + "keyTwo": "string | number | nullable", + "specialKey": "number", + "title": "string" + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/document/{docName}": { + "get": { + "tags": [ + "Documents" + ], + "description": "Get a single document by its unique AnythingLLM document name", + "parameters": [ + { + "name": "docName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique document name to find (name in /documents)" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "localFiles": { + "name": "documents", + "type": "folder", + "items": [ + { + "name": "my-stored-document.txt-uuid1234.json", + "type": "file", + "id": "bb07c334-4dab-4419-9462-9d00065a49a1", + "url": "file://my-stored-document.txt", + "title": "my-stored-document.txt", + "cached": false + } + ] + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/document/create-folder": { + "post": { + "tags": [ + "Documents" + ], + "description": "Create a new folder inside the documents storage directory.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "message": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Name of the folder to create.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "string", + "example": { + "name": "new-folder" + } + } + } + } + } + } + }, + "/v1/document/remove-folder": { + "delete": { + "tags": [ + "Documents" + ], + "description": "Remove a folder and all its contents from the documents storage directory.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "message": "Folder removed successfully" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Name of the folder to remove.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "my-folder" + } + } + } + } + } + } + } + }, + "/v1/document/move-files": { + "post": { + "tags": [ + "Documents" + ], + "description": "Move files within the documents storage directory.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "message": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Array of objects containing source and destination paths of files to move.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "files": [ + { + "from": "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json", + "to": "folder/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json" + } + ] + } + } + } + } + } + } + }, + "/v1/document/generated-files/{filename}": { + "get": { + "tags": [ + "Documents" + ], + "description": "Download a file generated by an agent skill (e.g., PDF, DOCX, XLSX, PPTX). The filename is returned in the `outputs` array of a chat response when an agent generates a file. Use the `storageFilename` value from that response to download the file.", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The storage filename returned in the chat response outputs array (e.g., pdf-e9e14f28-d6b6-4f49-91a0-dd331517f567.pdf)" + } + ], + "responses": { + "200": { + "description": "File downloaded successfully", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "error": "Invalid filename format" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "error": "File not found" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/new": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Create a new workspace", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspace": { + "id": 79, + "name": "Sample workspace", + "slug": "sample-workspace", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null + }, + "message": "Workspace created" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object containing workspace configuration.", + "required": true, + "content": { + "application/json": { + "example": { + "name": "My New Workspace", + "similarityThreshold": 0.7, + "openAiTemp": 0.7, + "openAiHistory": 20, + "openAiPrompt": "Custom prompt for responses", + "queryRefusalResponse": "Custom refusal message", + "chatMode": "chat", + "topN": 4 + } + } + } + } + } + }, + "/v1/workspaces": { + "get": { + "tags": [ + "Workspaces" + ], + "description": "List all current workspaces", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspaces": [ + { + "id": 79, + "name": "Sample workspace", + "slug": "sample-workspace", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "threads": [] + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/{slug}": { + "get": { + "tags": [ + "Workspaces" + ], + "description": "Get a workspace by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to find" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspace": [ + { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [], + "threads": [] + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "delete": { + "tags": [ + "Workspaces" + ], + "description": "Deletes a workspace by its slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to delete" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/{slug}/update": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Update workspace settings by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to find" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspace": { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [] + }, + "message": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object containing new settings to update a workspace. All keys are optional and will not update unless provided", + "required": true, + "content": { + "application/json": { + "example": { + "name": "Updated Workspace Name", + "openAiTemp": 0.2, + "openAiHistory": 20, + "openAiPrompt": "Respond to all inquires and questions in binary - do not respond in any other format." + } + } + } + } + } + }, + "/v1/workspace/{slug}/chats": { + "get": { + "tags": [ + "Workspaces" + ], + "description": "Get a workspaces chats regardless of user by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to find" + }, + { + "name": "apiSessionId", + "in": "query", + "description": "Optional apiSessionId to filter by", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Optional number of chat messages to return (default: 100)", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "orderBy", + "in": "query", + "description": "Optional order of chat messages (asc or desc)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "history": [ + { + "role": "user", + "content": "What is AnythingLLM?", + "sentAt": 1692851630 + }, + { + "role": "assistant", + "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.", + "sources": [ + { + "source": "object about source document and snippets used" + } + ] + } + ] + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/{slug}/update-embeddings": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Add or remove documents from a workspace by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to find" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspace": { + "id": 79, + "name": "My workspace", + "slug": "my-workspace-123", + "createdAt": "2023-08-17 00:45:03", + "openAiTemp": null, + "lastUpdatedAt": "2023-08-17 00:45:03", + "openAiHistory": 20, + "openAiPrompt": null, + "documents": [] + }, + "message": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object of additions and removals of documents to add to update a workspace. The value should be the folder + filename with the exclusions of the top-level documents path.", + "required": true, + "content": { + "application/json": { + "example": { + "adds": [ + "custom-documents/my-pdf.pdf-hash.json" + ], + "deletes": [ + "custom-documents/anythingllm.txt-hash.json" + ] + } + } + } + } + } + }, + "/v1/workspace/{slug}/update-pin": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Add or remove pin from a document in a workspace by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to find" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "message": "Pin status updated successfully" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Document not found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object with the document path and pin status to update.", + "required": true, + "content": { + "application/json": { + "example": { + "docPath": "custom-documents/my-pdf.pdf-hash.json", + "pinStatus": true + } + } + } + } + } + }, + "/v1/workspace/{slug}/chat": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Execute a chat with a workspace", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "id": "chat-uuid", + "type": "abort | textResponse", + "textResponse": "Response to your query", + "sources": [ + { + "title": "anythingllm.txt", + "chunk": "This is a context chunk used in the answer of the prompt by the LLM," + } + ], + "close": true, + "error": "null | text string of the failure mode." + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Send a prompt to the workspace and the type of conversation (automatic, query or chat).
Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.
Automatic: Will use tool-calling if the provider supports native tool calling without needing to invoke @agent.
Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.
Attachments: Can include images and documents.
     Document attachments: must have the mime type application/anythingllm-document - otherwise it will be passed to the LLM as an image and may fail to process. This uses the built-in document processor to first parse the document to text before injecting it into the context window.", + "required": true, + "content": { + "application/json": { + "example": { + "message": "What is AnythingLLM?", + "mode": "automatic | query | chat", + "sessionId": "identifier-to-partition-chats-by-external-id", + "attachments": [ + { + "name": "image.png", + "mime": "image/png", + "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + "name": "this is a document.pdf", + "mime": "application/anythingllm-document", + "contentString": "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + "reset": false + } + } + } + } + } + }, + "/v1/workspace/{slug}/stream-chat": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Execute a streamable chat with a workspace", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "First chunk", + "sources": [], + "close": false, + "error": "null | text string of the failure mode." + }, + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "chunk two", + "sources": [], + "close": false, + "error": "null | text string of the failure mode." + }, + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "final chunk of LLM output!", + "sources": [ + { + "title": "anythingllm.txt", + "chunk": "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk." + } + ], + "close": true, + "error": "null | text string of the failure mode." + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + } + }, + "requestBody": { + "description": "Send a prompt to the workspace and the type of conversation (automatic, query or chat).
Query: Will not use LLM unless there are relevant sources from vectorDB & does not recall chat history.
Automatic: Will use tool-calling if the provider supports native tool calling without needing to invoke @agent.
Chat: Uses LLM general knowledge w/custom embeddings to produce output, uses rolling chat history.
Attachments: Can include images and documents.
     Document attachments: must have the mime type application/anythingllm-document - otherwise it will be passed to the LLM as an image and may fail to process. This uses the built-in document processor to first parse the document to text before injecting it into the context window.", + "required": true, + "content": { + "application/json": { + "example": { + "message": "What is AnythingLLM?", + "mode": "automatic | query | chat", + "sessionId": "identifier-to-partition-chats-by-external-id", + "attachments": [ + { + "name": "image.png", + "mime": "image/png", + "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + "name": "this is a document.pdf", + "mime": "application/anythingllm-document", + "contentString": "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + "reset": false + } + } + } + } + } + }, + "/v1/workspace/{slug}/vector-search": { + "post": { + "tags": [ + "Workspaces" + ], + "description": "Perform a vector similarity search in a workspace", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace to search in" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "results": [ + { + "id": "5a6bee0a-306c-47fc-942b-8ab9bf3899c4", + "text": "Document chunk content...", + "metadata": { + "url": "file://document.txt", + "title": "document.txt", + "author": "no author specified", + "description": "no description found", + "docSource": "post:123456", + "chunkSource": "document.txt", + "published": "12/1/2024, 11:39:39 AM", + "wordCount": 8, + "tokenCount": 9 + }, + "distance": 0.541887640953064, + "score": 0.45811235904693604 + } + ] + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Query to perform vector search with and optional parameters", + "required": true, + "content": { + "application/json": { + "example": { + "query": "What is the meaning of life?", + "topN": 4, + "scoreThreshold": 0.75 + } + } + } + } + } + }, + "/v1/system/env-dump": { + "get": { + "tags": [ + "System Settings" + ], + "description": "Dump all settings to file storage", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/system": { + "get": { + "tags": [ + "System Settings" + ], + "description": "Get all current system settings that are defined.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "settings": { + "VectorDB": "pinecone", + "PineConeKey": true, + "PineConeIndex": "my-pinecone-index", + "LLMProvider": "azure", + "[KEY_NAME]": "KEY_VALUE" + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/system/vector-count": { + "get": { + "tags": [ + "System Settings" + ], + "description": "Number of all vectors in connected vector database", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "vectorCount": 5450 + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/system/update-env": { + "post": { + "tags": [ + "System Settings" + ], + "description": "Update a system setting or preference.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "newValues": { + "[ENV_KEY]": "Value" + }, + "error": "error goes here, otherwise null" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Key pair object that matches a valid setting and value. Get keys from GET /v1/system or refer to codebase.", + "required": true, + "content": { + "application/json": { + "example": { + "VectorDB": "lancedb", + "AnotherKey": "updatedValue" + } + } + } + } + } + }, + "/v1/system/export-chats": { + "get": { + "tags": [ + "System Settings" + ], + "description": "Export all of the chats from the system in a known format. Output depends on the type sent. Will be send with the correct header for the output.", + "parameters": [ + { + "name": "type", + "in": "query", + "description": "Export format jsonl, json, csv, jsonAlpaca", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": [ + { + "role": "user", + "content": "What is AnythinglLM?" + }, + { + "role": "assistant", + "content": "AnythingLLM is a knowledge graph and vector database management system built using NodeJS express server. It provides an interface for handling all interactions, including vectorDB management and LLM (Language Model) interactions." + } + ] + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/system/remove-documents": { + "delete": { + "tags": [ + "System Settings" + ], + "description": "Permanently remove documents from the system.", + "parameters": [], + "responses": { + "200": { + "description": "Documents removed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "message": "Documents removed successfully" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Array of document names to be removed permanently.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "custom-documents/file.txt-fc4beeeb-e436-454d-8bb4-e5b8979cb48f.json" + ] + } + } + } + } + } + } + } + }, + "/v1/workspace/{slug}/thread/new": { + "post": { + "tags": [ + "Workspace Threads" + ], + "description": "Create a new workspace thread", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "thread": { + "id": 1, + "name": "Thread", + "slug": "thread-uuid", + "user_id": 1, + "workspace_id": 1 + }, + "message": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Optional userId associated with the thread, thread slug and thread name", + "required": false, + "content": { + "application/json": { + "example": { + "userId": 1, + "name": "Name", + "slug": "thread-slug" + } + } + } + } + } + }, + "/v1/workspace/{slug}/thread/{threadSlug}/update": { + "post": { + "tags": [ + "Workspace Threads" + ], + "description": "Update thread name by its unique slug.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + }, + { + "name": "threadSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of thread" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "thread": { + "id": 1, + "name": "Updated Thread Name", + "slug": "thread-uuid", + "user_id": 1, + "workspace_id": 1 + }, + "message": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object containing new name to update the thread.", + "required": true, + "content": { + "application/json": { + "example": { + "name": "Updated Thread Name" + } + } + } + } + } + }, + "/v1/workspace/{slug}/thread/{threadSlug}": { + "delete": { + "tags": [ + "Workspace Threads" + ], + "description": "Delete a workspace thread", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + }, + { + "name": "threadSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of thread" + } + ], + "responses": { + "200": { + "description": "Thread deleted successfully" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/{slug}/thread/{threadSlug}/chats": { + "get": { + "tags": [ + "Workspace Threads" + ], + "description": "Get chats for a workspace thread", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + }, + { + "name": "threadSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of thread" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "history": [ + { + "role": "user", + "content": "What is AnythingLLM?", + "sentAt": 1692851630 + }, + { + "role": "assistant", + "content": "AnythingLLM is a platform that allows you to convert notes, PDFs, and other source materials into a chatbot. It ensures privacy, cites its answers, and allows multiple people to interact with the same documents simultaneously. It is particularly useful for businesses to enhance the visibility and readability of various written communications such as SOPs, contracts, and sales calls. You can try it out with a free trial to see if it meets your business needs.", + "sources": [ + { + "source": "object about source document and snippets used" + } + ] + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/workspace/{slug}/thread/{threadSlug}/chat": { + "post": { + "tags": [ + "Workspace Threads" + ], + "description": "Chat with a workspace thread", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + }, + { + "name": "threadSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of thread" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "id": "chat-uuid", + "type": "abort | textResponse", + "textResponse": "Response to your query", + "sources": [ + { + "title": "anythingllm.txt", + "chunk": "This is a context chunk used in the answer of the prompt by the LLM." + } + ], + "close": true, + "error": "null | text string of the failure mode." + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Send a prompt to the workspace thread and the type of conversation (query or chat).", + "required": true, + "content": { + "application/json": { + "example": { + "message": "What is AnythingLLM?", + "mode": "query | chat", + "userId": 1, + "attachments": [ + { + "name": "image.png", + "mime": "image/png", + "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + "reset": false + } + } + } + } + } + }, + "/v1/workspace/{slug}/thread/{threadSlug}/stream-chat": { + "post": { + "tags": [ + "Workspace Threads" + ], + "description": "Stream chat with a workspace thread", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of workspace" + }, + { + "name": "threadSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Unique slug of thread" + } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "First chunk", + "sources": [], + "close": false, + "error": "null | text string of the failure mode." + }, + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "chunk two", + "sources": [], + "close": false, + "error": "null | text string of the failure mode." + }, + { + "id": "uuid-123", + "type": "abort | textResponseChunk", + "textResponse": "final chunk of LLM output!", + "sources": [ + { + "title": "anythingllm.txt", + "chunk": "This is a context chunk used in the answer of the prompt by the LLM. This will only return in the final chunk." + } + ], + "close": true, + "error": "null | text string of the failure mode." + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + } + }, + "requestBody": { + "description": "Send a prompt to the workspace thread and the type of conversation (query or chat).", + "required": true, + "content": { + "application/json": { + "example": { + "message": "What is AnythingLLM?", + "mode": "query | chat", + "userId": 1, + "attachments": [ + { + "name": "image.png", + "mime": "image/png", + "contentString": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + { + "name": "this is a document.pdf", + "mime": "application/anythingllm-document", + "contentString": "data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + ], + "reset": false + } + } + } + } + } + }, + "/v1/users": { + "get": { + "tags": [ + "User Management" + ], + "description": "List all users", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "users": [ + { + "id": 1, + "username": "john_doe", + "role": "admin" + }, + { + "id": 2, + "username": "jane_smith", + "role": "default" + } + ] + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Permission denied." + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/users/{id}/issue-auth-token": { + "get": { + "tags": [ + "User Management" + ], + "description": "Issue a temporary auth token for a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the user to issue a temporary auth token for" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "token": "1234567890", + "loginPath": "/sso/simple?token=1234567890" + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Permission denied." + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/openai/models": { + "get": { + "tags": [ + "OpenAI Compatible Endpoints" + ], + "description": "Get all available \"models\" which are workspaces you can use for chatting.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "object": "list", + "data": [ + { + "id": "model-id-0", + "object": "model", + "created": 1686935002, + "owned_by": "organization-owner" + }, + { + "id": "model-id-1", + "object": "model", + "created": 1686935002, + "owned_by": "organization-owner" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/openai/chat/completions": { + "post": { + "tags": [ + "OpenAI Compatible Endpoints" + ], + "description": "Execute a chat with a workspace with OpenAI compatibility. Supports streaming as well. Model must be a workspace slug from /models.", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "Send a prompt to the workspace with full use of documents as if sending a chat in AnythingLLM. Only supports some values of OpenAI API. See example below.", + "required": true, + "content": { + "application/json": { + "example": { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant" + }, + { + "role": "user", + "content": "What is AnythingLLM?" + }, + { + "role": "assistant", + "content": "AnythingLLM is...." + }, + { + "role": "user", + "content": "Follow up question..." + } + ], + "model": "sample-workspace", + "stream": true, + "temperature": 0.7 + } + } + } + } + } + }, + "/v1/openai/embeddings": { + "post": { + "tags": [ + "OpenAI Compatible Endpoints" + ], + "description": "Get the embeddings of any arbitrary text string. This will use the embedder provider set in the system. Please ensure the token length of each string fits within the context of your embedder model.", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "The input string(s) to be embedded. If the text is too long for the embedder model context, it will fail to embed. The vector and associated chunk metadata will be returned in the array order provided", + "required": true, + "content": { + "application/json": { + "example": { + "input": [ + "This is my first string to embed", + "This is my second string to embed" + ], + "model": null + } + } + } + } + } + }, + "/v1/openai/vector_stores": { + "get": { + "tags": [ + "OpenAI Compatible Endpoints" + ], + "description": "List all the vector database collections connected to AnythingLLM. These are essentially workspaces but return their unique vector db identifier - this is the same as the workspace slug.", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "data": [ + { + "id": "slug-here", + "object": "vector_store", + "name": "My workspace", + "file_counts": { + "total": 3 + }, + "provider": "LanceDB" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/embed": { + "get": { + "tags": [ + "Embed" + ], + "description": "List all active embeds", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "embeds": [ + { + "id": 1, + "uuid": "embed-uuid-1", + "enabled": true, + "chat_mode": "query", + "createdAt": "2023-04-01T12:00:00Z", + "workspace": { + "id": 1, + "name": "Workspace 1" + }, + "chat_count": 10 + }, + { + "id": 2, + "uuid": "embed-uuid-2", + "enabled": false, + "chat_mode": "chat", + "createdAt": "2023-04-02T14:30:00Z", + "workspace": { + "id": 1, + "name": "Workspace 1" + }, + "chat_count": 10 + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/embed/{embedUuid}/chats": { + "get": { + "tags": [ + "Embed" + ], + "description": "Get all chats for a specific embed", + "parameters": [ + { + "name": "embedUuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the embed" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "chats": [ + { + "id": 1, + "session_id": "session-uuid-1", + "prompt": "Hello", + "response": "Hi there!", + "createdAt": "2023-04-01T12:00:00Z" + }, + { + "id": 2, + "session_id": "session-uuid-2", + "prompt": "How are you?", + "response": "I'm doing well, thank you!", + "createdAt": "2023-04-02T14:30:00Z" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Embed not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/embed/{embedUuid}/chats/{sessionUuid}": { + "get": { + "tags": [ + "Embed" + ], + "description": "Get chats for a specific embed and session", + "parameters": [ + { + "name": "embedUuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the embed" + }, + { + "name": "sessionUuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the session" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "chats": [ + { + "id": 1, + "prompt": "Hello", + "response": "Hi there!", + "createdAt": "2023-04-01T12:00:00Z" + } + ] + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Embed or session not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/v1/embed/new": { + "post": { + "tags": [ + "Embed" + ], + "description": "Create a new embed configuration", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "embed": { + "id": 1, + "uuid": "embed-uuid-1", + "enabled": true, + "chat_mode": "chat", + "allowlist_domains": [ + "example.com" + ], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10, + "createdAt": "2023-04-01T12:00:00Z", + "workspace_slug": "workspace-slug-1" + }, + "error": null + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Workspace not found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object containing embed configuration details", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "workspace_slug": "workspace-slug-1", + "chat_mode": "chat", + "allowlist_domains": [ + "example.com" + ], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10 + } + } + } + } + } + } + }, + "/v1/embed/{embedUuid}": { + "post": { + "tags": [ + "Embed" + ], + "description": "Update an existing embed configuration", + "parameters": [ + { + "name": "embedUuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the embed to update" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Embed not found" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "description": "JSON object containing embed configuration updates", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "enabled": true, + "chat_mode": "chat", + "allowlist_domains": [ + "example.com" + ], + "allow_model_override": false, + "allow_temperature_override": false, + "allow_prompt_override": false, + "max_chats_per_day": 100, + "max_chats_per_session": 10 + } + } + } + } + } + }, + "delete": { + "tags": [ + "Embed" + ], + "description": "Delete an existing embed configuration", + "parameters": [ + { + "name": "embedUuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "UUID of the embed to delete" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "success": true, + "error": null + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Embed not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + } + }, + "components": { + "schemas": { + "InvalidAPIKey": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid API Key" + } + }, + "xml": { + "name": "InvalidAPIKey" + } + } + }, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] +} \ No newline at end of file diff --git a/server/swagger/utils.js b/server/swagger/utils.js new file mode 100644 index 0000000..fc1ac78 --- /dev/null +++ b/server/swagger/utils.js @@ -0,0 +1,56 @@ +const fs = require("fs"); +const path = require("path"); +const swaggerUi = require("swagger-ui-express"); + +function faviconUrl() { + return process.env.NODE_ENV === "production" + ? "/public/favicon.png" + : "http://localhost:3000/public/favicon.png"; +} + +function useSwagger(app) { + if (process.env.DISABLE_SWAGGER_DOCS === "true") { + console.log( + `\x1b[33m[SWAGGER DISABLED]\x1b[0m Swagger documentation is disabled via DISABLE_SWAGGER_DOCS environment variable.` + ); + return; + } + app.use("/api/docs", swaggerUi.serve); + const options = { + customCss: [ + fs.readFileSync(path.resolve(__dirname, "index.css")), + fs.readFileSync(path.resolve(__dirname, "dark-swagger.css")), + ].join("\n\n\n"), + customSiteTitle: "AnythingLLM Developer API Documentation", + customfavIcon: faviconUrl(), + }; + + if (process.env.NODE_ENV === "production") { + const swaggerDocument = require("./openapi.json"); + app.get( + "/api/docs", + swaggerUi.setup(swaggerDocument, { + ...options, + customJsStr: + 'window.SWAGGER_DOCS_ENV = "production";\n\n' + + fs.readFileSync(path.resolve(__dirname, "index.js"), "utf8"), + }) + ); + } else { + // we regenerate the html page only in development mode to ensure it is up-to-date when the code is hot-reloaded. + app.get("/api/docs", async (_, response) => { + // #swagger.ignore = true + const swaggerDocument = require("./openapi.json"); + return response.send( + swaggerUi.generateHTML(swaggerDocument, { + ...options, + customJsStr: + 'window.SWAGGER_DOCS_ENV = "development";\n\n' + + fs.readFileSync(path.resolve(__dirname, "index.js"), "utf8"), + }) + ); + }); + } +} + +module.exports = { faviconUrl, useSwagger }; diff --git a/server/utils/AiProviders/anthropic/index.js b/server/utils/AiProviders/anthropic/index.js new file mode 100644 index 0000000..239602f --- /dev/null +++ b/server/utils/AiProviders/anthropic/index.js @@ -0,0 +1,408 @@ +const { v4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { MODEL_MAP } = require("../modelMap"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { getAnythingLLMUserAgent } = require("../../../endpoints/utils"); + +class AnthropicLLM { + /** + * List of Anthropic models that do not support the `temperature` inference parameter. + * These models reject `temperature`/`top_p`/`top_k` with a 400 error. + * @type {string[]} + */ + noTemperatureModels = [ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + // Add other models here if identified + ]; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.ANTHROPIC_API_KEY) + throw new Error("No Anthropic API key was set."); + + this.className = "AnthropicLLM"; + // Docs: https://www.npmjs.com/package/@anthropic-ai/sdk + const AnthropicAI = require("@anthropic-ai/sdk"); + const anthropic = new AnthropicAI({ + apiKey: process.env.ANTHROPIC_API_KEY, + defaultHeaders: { + "User-Agent": getAnythingLLMUserAgent(), + }, + }); + this.anthropic = anthropic; + this.model = + modelPreference || + process.env.ANTHROPIC_MODEL_PREF || + "claude-sonnet-4-6"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.maxTokens = null; + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized with ${this.model}. Cache ${this.cacheControl ? `enabled (${this.cacheControl.ttl})` : "disabled"}` + ); + + AnthropicLLM.fetchModelMaxTokens(this.model).then((maxTokens) => { + this.maxTokens = maxTokens; + this.log(`Model ${this.model} max tokens: ${this.maxTokens}`); + }); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("anthropic", modelName) ?? 100_000; + } + + promptWindowLimit() { + return MODEL_MAP.get("anthropic", this.model) ?? 100_000; + } + + isValidChatCompletionModel(_modelName = "") { + return true; + } + + async assertModelMaxTokens() { + if (this.maxTokens) return this.maxTokens; + this.maxTokens = await AnthropicLLM.fetchModelMaxTokens(this.model); + return this.maxTokens; + } + + /** + * Gets the temperature configuration for the Anthropic LLM. + * @param {number} temperature - The temperature to use. + * @returns {number|undefined} The temperature value or undefined if not supported. + */ + temperatureParam(temperature = this.defaultTemp) { + if (typeof temperature !== "number") return undefined; + if (this.noTemperatureModels.some((model) => this.model.includes(model))) + return undefined; + return parseFloat(temperature); + } + + /** + * Fetches the maximum number of tokens the model should generate in its response. + * This varies per model but will fallback to 4096 if the model is not found. + * @param {string} modelName - The name of the model to fetch the max tokens for + * @returns {Promise} The maximum output tokens limit for API calls. + */ + static async fetchModelMaxTokens( + modelName = process.env.ANTHROPIC_MODEL_PREF + ) { + try { + const AnthropicAI = require("@anthropic-ai/sdk"); + /** @type {import("@anthropic-ai/sdk").Anthropic} */ + const anthropic = new AnthropicAI({ + apiKey: process.env.ANTHROPIC_API_KEY, + }); + const model = await anthropic.models.retrieve(modelName); + return Number(model.max_tokens ?? 4096); + } catch (error) { + console.error(`Error fetching model max tokens for ${modelName}:`, error); + return 4096; + } + } + + /** + * Parses the cache control ENV variable + * + * If caching is enabled, we can pass less than 1024 tokens and Anthropic will just + * ignore it unless it is above the model's minimum. Since this feature is opt-in + * we can safely assume that if caching is enabled that we should just pass the content as is. + * https://docs.claude.com/en/docs/build-with-claude/prompt-caching#cache-limitations + * + * @param {string} value - The ENV value (5m or 1h) + * @returns {null|{type: "ephemeral", ttl: "5m" | "1h"}} Cache control configuration + */ + get cacheControl() { + // Store result in instance variable to avoid recalculating + if (this._cacheControl) return this._cacheControl; + + if (!process.env.ANTHROPIC_CACHE_CONTROL) this._cacheControl = null; + else { + const normalized = + process.env.ANTHROPIC_CACHE_CONTROL.toLowerCase().trim(); + if (["5m", "1h"].includes(normalized)) + this._cacheControl = { type: "ephemeral", ttl: normalized }; + else this._cacheControl = null; + } + return this._cacheControl; + } + + /** + * Builds system parameter with cache control if applicable + * @param {string} systemContent - The system prompt content + * @returns {string|array} System parameter for API call + */ + #buildSystemPrompt(systemContent) { + if (!systemContent || !this.cacheControl) return systemContent; + return [ + { + type: "text", + text: systemContent, + cache_control: this.cacheControl, + }, + ]; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image", + source: { + type: "base64", + media_type: attachment.mime, + data: attachment.contentString.split("base64,")[1], + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + await this.assertModelMaxTokens(); + try { + const systemContent = messages[0].content; + // We assemble the response from the streaming endpoint rather than the + // non-streaming `messages.create`. The Anthropic SDK throws + // (`_calculateNonstreamingTimeout`) for non-streaming requests whose + // `max_tokens` is large enough that the worst-case latency could exceed + // 10 minutes - which is the case for recent Claude models with large max + // output tokens. Streaming and resolving the final message avoids that + // guard while still returning a single, complete response so the REST API + // chat path stays at parity with the UI. See issue #5925. + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.anthropic.messages + .stream({ + model: this.model, + max_tokens: this.maxTokens, + system: this.#buildSystemPrompt(systemContent), + messages: messages.slice(1), // Pop off the system message + temperature: this.temperatureParam(temperature), + }) + .finalMessage() + ); + + const promptTokens = result.output.usage.input_tokens; + const completionTokens = result.output.usage.output_tokens; + + return { + textResponse: result.output.content[0].text, + metrics: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + outputTps: completionTokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } catch (error) { + console.error(error); + throw new Error( + `AnthropicLLM::getChatCompletion failed to communicate with Anthropic. ${error.message}` + ); + } + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + await this.assertModelMaxTokens(); + const systemContent = messages[0].content; + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.anthropic.messages.stream({ + model: this.model, + max_tokens: this.maxTokens, + system: this.#buildSystemPrompt(systemContent), + messages: messages.slice(1), // Pop off the system message + temperature: this.temperatureParam(temperature), + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + /** + * Handles the stream response from the Anthropic API. + * @param {Object} response - the response object + * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream - the stream response from the Anthropic API w/tracking + * @param {Object} responseProps - the response properties + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + return new Promise((resolve) => { + let fullText = ""; + const { uuid = v4(), sources = [] } = responseProps; + let usage = { + prompt_tokens: 0, + completion_tokens: 0, + }; + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + stream.on("error", (event) => { + const parseErrorMsg = (event) => { + const error = event?.error?.error; + if (!!error) + return `Anthropic Error:${error?.type || "unknown"} ${ + error?.message || "unknown error." + }`; + return event.message; + }; + + writeResponseChunk(response, { + uuid, + sources: [], + type: "abort", + textResponse: null, + close: true, + error: parseErrorMsg(event), + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + }); + + stream.on("streamEvent", (message) => { + const data = message; + + if (data.type === "message_start") + usage.prompt_tokens = data?.message?.usage?.input_tokens; + if (data.type === "message_delta") + usage.completion_tokens = data?.usage?.output_tokens; + + if ( + data.type === "content_block_delta" && + data.delta.type === "text_delta" + ) { + const text = data.delta.text; + fullText += text; + + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: text, + close: false, + error: false, + }); + } + + if ( + message.type === "message_stop" || + (data.stop_reason && data.stop_reason === "end_turn") + ) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + }); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageStringCompressor } = require("../../helpers/chat"); + const compressedPrompt = await messageStringCompressor( + this, + promptArgs, + rawHistory + ); + return compressedPrompt; + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } +} + +module.exports = { + AnthropicLLM, +}; diff --git a/server/utils/AiProviders/apipie/index.js b/server/utils/AiProviders/apipie/index.js new file mode 100644 index 0000000..6eb0629 --- /dev/null +++ b/server/utils/AiProviders/apipie/index.js @@ -0,0 +1,386 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { v4: uuidv4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "apipie") + : path.resolve(__dirname, `../../../storage/models/apipie`) +); + +class ApiPieLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.APIPIE_LLM_API_KEY) + throw new Error("No ApiPie LLM API key was set."); + + this.className = "ApiPieLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = "https://apipie.ai/v1"; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.APIPIE_LLM_API_KEY ?? null, + }); + this.model = + modelPreference || + process.env.APIPIE_LLM_MODEL_PREF || + "openrouter/mistral-7b-instruct"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + #cacheIsStale() { + const MAX_STALE = 6.048e8; // 1 Week in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + // This function fetches the models from the ApiPie API and caches them locally. + // We do this because the ApiPie API has a lot of models, and we need to get the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + // We used to have this as a chore, but given there is an API to get the info - this makes little sense. + // This might slow down the first request, but we need the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log("Model cache is not present or stale. Fetching from ApiPie API."); + await fetchApiPieModels(); + return; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + chatModels() { + const allModels = this.models(); + return Object.entries(allModels).reduce( + (chatModels, [modelId, modelInfo]) => { + // Filter for chat models + if ( + modelInfo.subtype && + (modelInfo.subtype.includes("chat") || + modelInfo.subtype.includes("chatx")) + ) { + chatModels[modelId] = modelInfo; + } + return chatModels; + }, + {} + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const availableModels = fs.existsSync(cacheModelPath) + ? safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + {} + ) + : {}; + return availableModels[modelName]?.maxLength || 4096; + } + + promptWindowLimit() { + const availableModels = this.chatModels(); + return availableModels[this.model]?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.chatModels(); + return availableModels.hasOwnProperty(model); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `ApiPie chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: + (result.output.usage?.completion_tokens || 0) / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `ApiPie chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + + if (token) { + fullText += token; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + if (message === undefined || message.finish_reason !== null) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + } + } catch (e) { + writeResponseChunk(response, { + uuid, + sources, + type: "abort", + textResponse: null, + close: true, + error: e.message, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function fetchApiPieModels(providedApiKey = null) { + const apiKey = providedApiKey || process.env.APIPIE_LLM_API_KEY || null; + return await fetch(`https://apipie.ai/v1/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + data.forEach((model) => { + models[`${model.provider}/${model.model}`] = { + id: `${model.provider}/${model.model}`, + name: `${model.provider}/${model.model}`, + organization: model.provider, + subtype: model.subtype, + maxLength: model.max_tokens, + }; + }); + + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(models), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + + return models; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + ApiPieLLM, + fetchApiPieModels, +}; diff --git a/server/utils/AiProviders/azureOpenAi/index.js b/server/utils/AiProviders/azureOpenAi/index.js new file mode 100644 index 0000000..527591f --- /dev/null +++ b/server/utils/AiProviders/azureOpenAi/index.js @@ -0,0 +1,232 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + formatChatHistory, + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +class AzureOpenAiLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI } = require("openai"); + if (!process.env.AZURE_OPENAI_ENDPOINT) + throw new Error("No Azure API endpoint was set."); + if (!process.env.AZURE_OPENAI_KEY) + throw new Error("No Azure API key was set."); + + this.className = "AzureOpenAiLLM"; + this.openai = new OpenAI({ + apiKey: process.env.AZURE_OPENAI_KEY, + baseURL: AzureOpenAiLLM.formatBaseUrl(process.env.AZURE_OPENAI_ENDPOINT), + }); + this.model = + modelPreference || + process.env.AZURE_OPENAI_MODEL_PREF || + process.env.OPEN_MODEL_PREF; + /* + Note: Azure OpenAI deployments do not expose model metadata that would allow us to + programmatically detect whether the deployment uses a reasoning model (o1, o1-mini, o3-mini, etc.). + As a result, we rely on the user to explicitly set AZURE_OPENAI_MODEL_TYPE="reasoning" + when using reasoning models, as incorrect configuration might result in chat errors. + */ + this.isOTypeModel = + process.env.AZURE_OPENAI_MODEL_TYPE === "reasoning" || false; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.#log( + `Initialized. Model "${this.model}" @ ${this.promptWindowLimit()} tokens.\nAPI-Version: ${this.apiVersion}.\nModel Type: ${this.isOTypeModel ? "reasoning" : "default"}` + ); + } + + /** + * Formats the Azure OpenAI endpoint URL to the correct format. + * @param {string} azureOpenAiEndpoint - The Azure OpenAI endpoint URL. + * @returns {string} The formatted URL. + */ + static formatBaseUrl(azureOpenAiEndpoint) { + try { + const url = new URL(azureOpenAiEndpoint); + url.pathname = "/openai/v1"; + url.protocol = "https"; + url.search = ""; + url.hash = ""; + return url.href; + } catch { + throw new Error( + `"${azureOpenAiEndpoint}" is not a valid URL. Check your settings for the Azure OpenAI provider and set a valid endpoint URL.` + ); + } + } + + #log(text, ...args) { + console.log(`\x1b[32m[AzureOpenAi]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + return !!process.env.AZURE_OPENAI_TOKEN_LIMIT + ? Number(process.env.AZURE_OPENAI_TOKEN_LIMIT) + : 4096; + } + + // Sure the user selected a proper value for the token limit + // could be any of these https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#gpt-4-models + // and if undefined - assume it is the lowest end. + promptWindowLimit() { + return !!process.env.AZURE_OPENAI_TOKEN_LIMIT + ? Number(process.env.AZURE_OPENAI_TOKEN_LIMIT) + : 4096; + } + + isValidChatCompletionModel(_modelName = "") { + // The Azure user names their "models" as deployments and they can be any name + // so we rely on the user to put in the correct deployment as only they would + // know it. + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const prompt = { + role: this.isOTypeModel ? "user" : "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = [], { temperature = 0.7 }) { + if (!this.model) + throw new Error( + "No AZURE_OPENAI_MODEL_PREF ENV defined. This must the name of a deployment on your Azure account for an LLM chat model like GPT-3.5." + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions.create({ + messages, + model: this.model, + ...(this.isOTypeModel ? {} : { temperature }), + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = [], { temperature = 0.7 }) { + if (!this.model) + throw new Error( + "No AZURE_OPENAI_MODEL_PREF ENV defined. This must the name of a deployment on your Azure account for an LLM chat model like GPT-3.5." + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: await this.openai.chat.completions.create({ + messages, + model: this.model, + ...(this.isOTypeModel ? {} : { temperature }), + n: 1, + stream: true, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + AzureOpenAiLLM, +}; diff --git a/server/utils/AiProviders/bedrock/index.js b/server/utils/AiProviders/bedrock/index.js new file mode 100644 index 0000000..3b54ac4 --- /dev/null +++ b/server/utils/AiProviders/bedrock/index.js @@ -0,0 +1,271 @@ +const { OpenAI: OpenAIApi } = require("openai"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +class AWSBedrockLLM { + /** + * List of Bedrock models observed to not support system prompts when using the Converse API. + * @type {string[]} + */ + noSystemPromptModels = [ + "amazon.titan-text-express-v1", + "amazon.titan-text-lite-v1", + "cohere.command-text-v14", + "cohere.command-light-text-v14", + "us.deepseek.r1-v1:0", + // Add other models here if identified + ]; + + /** + * List of Bedrock models observed to not support the `temperature` inference parameter. + * @type {string[]} + */ + noTemperatureModels = [ + "anthropic.claude-opus-4-7", + "anthropic.claude-opus-4-8", + "anthropic.claude-sonnet-5", + // Add other models here if identified + ]; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.AWS_BEDROCK_LLM_API_KEY) + throw new Error("AWS_BEDROCK_LLM_API_KEY is required for AWS Bedrock."); + if (!process.env.AWS_BEDROCK_LLM_REGION) + throw new Error("AWS_BEDROCK_LLM_REGION is required for AWS Bedrock."); + + this.className = "AWSBedrockLLM"; + this.model = + modelPreference || process.env.AWS_BEDROCK_LLM_MODEL_PREFERENCE; + this.region = process.env.AWS_BEDROCK_LLM_REGION; + + const contextWindowLimit = this.promptWindowLimit(); + this.limits = { + history: Math.floor(contextWindowLimit * 0.15), + system: Math.floor(contextWindowLimit * 0.15), + user: Math.floor(contextWindowLimit * 0.7), + }; + + this.openai = new OpenAIApi({ + apiKey: process.env.AWS_BEDROCK_LLM_API_KEY, + baseURL: `https://bedrock-mantle.${this.region}.api.aws/v1`, + }); + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.#log( + `Initialized with model: ${this.model}. Region: ${this.region}. Context Window: ${contextWindowLimit}.` + ); + } + + /** + * Gets the temperature configuration for the AWS Bedrock LLM. + * @param {number} temperature - The temperature to use. + * @returns {number|undefined} The temperature value or undefined if not supported. + */ + temperatureParam(temperature = this.defaultTemp) { + if (typeof temperature !== "number") return undefined; + if (this.noTemperatureModels.some((model) => this.model.includes(model))) + return undefined; + return parseFloat(temperature); + } + + #appendContext(contextTexts = []) { + if (!contextTexts?.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`) + .join("") + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[AWSBedrock]\x1b[0m ${text}`, ...args); + } + + static #slog(text, ...args) { + console.log(`\x1b[32m[AWSBedrock]\x1b[0m ${text}`, ...args); + } + + streamingEnabled() { + if (!!process.env.AWS_BEDROCK_STREAMING_DISABLED) return false; + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit() { + const limit = process.env.AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT ?? 8191; + const numericLimit = Number(limit); + if (isNaN(numericLimit) || numericLimit <= 0) { + this.#slog( + `Invalid AWS_BEDROCK_LLM_MODEL_TOKEN_LIMIT: "${limit}". Returning default 8191.` + ); + return 8191; + } + return numericLimit; + } + + promptWindowLimit() { + return AWSBedrockLLM.promptWindowLimit(); + } + + async isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Constructs the complete message array in the OpenAI chat format. + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const systemMessageContent = `${systemPrompt}${this.#appendContext(contextTexts)}`; + let messages = []; + + if (this.noSystemPromptModels.includes(this.model)) { + if (systemMessageContent.trim().length > 0) { + this.#log( + `Model ${this.model} doesn't support system prompts; simulating.` + ); + messages.push( + { role: "user", content: systemMessageContent }, + { role: "assistant", content: "Okay." } + ); + } + } else if (systemMessageContent.trim().length > 0) { + messages.push({ role: "system", content: systemMessageContent }); + } + + messages = messages.concat( + chatHistory.map((msg) => { + const content = this.#formatMessageContent( + msg.content, + msg.attachments + ); + return { role: msg.role, content }; + }) + ); + + const userContent = this.#formatMessageContent(userPrompt, attachments); + messages.push({ role: "user", content: userContent }); + + return messages; + } + + /** + * Formats message content with optional image attachments for the OpenAI API format. + * @param {string} text - The text content. + * @param {Array} attachments - Optional image attachments. + * @returns {string|Array} Plain string or multimodal content array. + */ + #formatMessageContent(text, attachments = []) { + if (!Array.isArray(attachments) || attachments.length === 0) return text; + + const content = [{ type: "text", text }]; + for (const attachment of attachments) { + if (!attachment?.contentString || !attachment?.mime) continue; + content.push({ + type: "image_url", + image_url: { url: attachment.contentString }, + }); + } + return content; + } + + async getChatCompletion(messages = null, { temperature }) { + if (!messages?.length) + throw new Error( + "AWSBedrock::getChatCompletion requires a non-empty messages array." + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature: this.temperatureParam(temperature), + }) + .catch((e) => { + this.#log(`Bedrock API Error (getChatCompletion): ${e.message}`, e); + throw new Error(`AWSBedrock::getChatCompletion failed. ${e.message}`); + }) + ); + + const response = result.output; + if (!response?.choices?.[0]?.message) { + this.#log("Bedrock response missing expected structure.", response); + return null; + } + + return { + textResponse: response.choices[0].message.content, + metrics: { + prompt_tokens: response.usage?.prompt_tokens ?? 0, + completion_tokens: response.usage?.completion_tokens ?? 0, + total_tokens: response.usage?.total_tokens ?? 0, + outputTps: + response.usage?.completion_tokens && result.duration + ? response.usage.completion_tokens / (result.duration / 1000) + : 0, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature }) { + if (!Array.isArray(messages) || messages.length === 0) { + throw new Error( + "AWSBedrock::streamGetChatCompletion requires a non-empty messages array." + ); + } + + const stream = await this.openai.chat.completions.create({ + model: this.model, + messages, + temperature: this.temperatureParam(temperature), + stream: true, + }); + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: stream, + messages, + modelTag: this.model, + provider: this.className, + // AWS OpenAI SDK does not return usage - even with include_usage set to true + // when streaming, so we need to calculate it manually (it will be wrong but its better than nothing) + runPromptTokenCalculation: true, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + AWSBedrockLLM, +}; diff --git a/server/utils/AiProviders/cerebras/index.js b/server/utils/AiProviders/cerebras/index.js new file mode 100644 index 0000000..36d3f8d --- /dev/null +++ b/server/utils/AiProviders/cerebras/index.js @@ -0,0 +1,314 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class CerebrasLLM { + static modelContextWindows = {}; + + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.CEREBRAS_API_KEY) + throw new Error("No Cerebras API key was set."); + + this.className = "CerebrasLLM"; + this.openai = new OpenAIApi({ + baseURL: "https://api.cerebras.ai/v1", + apiKey: process.env.CEREBRAS_API_KEY, + }); + this.model = + modelPreference || process.env.CEREBRAS_MODEL_PREF || "gpt-oss-120b"; + // Lazy load the limits to avoid blocking the main thread on cacheContextWindows + this.limits = null; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0; + + CerebrasLLM.cacheContextWindows(true); + this.#log(`Initialized with model: ${this.model}`); + } + + #log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + static #slog(text, ...args) { + console.log(`\x1b[36m[CerebrasLLM]\x1b[0m ${text}`, ...args); + } + + async assertModelContextLimits() { + if (this.limits !== null) return; + await CerebrasLLM.cacheContextWindows(); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + } + + /** + * Cache the context windows for the LMStudio models. + * This is done once and then cached for the lifetime of the server. This is absolutely necessary to ensure that the context windows are correct. + * + * This is a convenience to ensure that the context windows are correct and that the user + * does not have to manually set the context window for each model. + * @param {boolean} force - Force the cache to be refreshed. + * @returns {Promise} - A promise that resolves when the cache is refreshed. + */ + static async cacheContextWindows(force = false) { + try { + // Skip if we already have cached context windows and we're not forcing a refresh + if (Object.keys(CerebrasLLM.modelContextWindows).length > 0 && !force) + return; + + await fetch("https://api.cerebras.ai/public/v1/models") + .then((res) => { + if (!res.ok) + throw new Error(`Cerebras:cacheContextWindows - ${res.statusText}`); + return res.json(); + }) + .then(({ data: models }) => { + models.forEach((model) => { + if (!model.limits.max_context_length) return; + if (isNaN(model.limits.max_context_length)) return; + CerebrasLLM.modelContextWindows[model.id] = + model.limits.max_context_length; + }); + }) + .catch((e) => { + CerebrasLLM.#slog(`Error caching context windows`, e); + return; + }); + + CerebrasLLM.#slog(`Context windows cached for all models!`); + } catch (e) { + CerebrasLLM.#slog(`Error caching context windows`, e); + return; + } + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + if (Object.keys(CerebrasLLM.modelContextWindows).length === 0) { + this.#slog( + "No context windows cached - Context window may be inaccurately reported." + ); + return ( + Number(process.env.CEREBRAS_MODEL_TOKEN_LIMIT) || // Legacy support for old ENV + MODEL_MAP.get("cerebras", modelName) || // Use the model map for the context window + 128000 // Default to 128000 if no context window is found and modelMap is not available + ); + } + return Number(CerebrasLLM.modelContextWindows[modelName]) || 128000; + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + // The Cerebras inference API is OpenAI-compatible, so any model the account + // has access to can be used. The available models are surfaced to the UI via + // the customModels helper, so we only need to ensure a model is present here. + async isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments: _attachments = [] }) { + return userPrompt; + + /** @dev-note + * There are not cerebras models that support vision - so this can be stubbed out for now. + * If the provider ever figures out how to support vision, this can be uncommented so images can be supported. + */ + + // if (!attachments.length) { + // return userPrompt; + // } + + // const content = [{ type: "text", text: userPrompt }]; + // for (let attachment of attachments) { + // content.push({ + // type: "image_url", + // image_url: { + // url: attachment.contentString, + // detail: "auto", + // }, + // }); + // } + // return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...chatHistory, + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Do we need this? + * Parses and prepends reasoning from the response and returns the full text response. + * Used for getChatCompletions to render thinking text if present in full response. + * @param {Object} message - The message object from the LMStudio response. + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content ?? ""; + if (!!message?.reasoning && message.reasoning.trim().length > 0) + textResponse = `${message.reasoning}${textResponse}`; + return textResponse; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + console.error(e); + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: + result.output.usage.completion_tokens / + result.output.time_info.completion_time, + duration: result.output.time_info.completion_time, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + /** + * Returns the capabilities of the model. + * This uses the new /public/v1/models endpoint, which returns the model capabilities. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + const capabilities = + (await fetch(`https://api.cerebras.ai/public/v1/models/${this.model}`, { + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + if (!res.ok) + throw new Error( + `Cerebras:getModelCapabilities - ${res.statusText}` + ); + return res.json(); + }) + .then(({ capabilities }) => capabilities)) || {}; + + return { + tools: capabilities?.tools, + reasoning: capabilities?.reasoning, + imageGeneration: false, + vision: capabilities?.vision, + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + CerebrasLLM, +}; diff --git a/server/utils/AiProviders/cohere/index.js b/server/utils/AiProviders/cohere/index.js new file mode 100644 index 0000000..14265cd --- /dev/null +++ b/server/utils/AiProviders/cohere/index.js @@ -0,0 +1,198 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { MODEL_MAP } = require("../modelMap"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); + +class CohereLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.COHERE_API_KEY) + throw new Error("No Cohere API key was set."); + this.className = "CohereLLM"; + + // Cohere exposes an OpenAI-compatible API which lets us reuse the OpenAI SDK + // across the app instead of the cohere-ai package. https://docs.cohere.com/docs/compatibility-api + this.openai = new OpenAIApi({ + baseURL: "https://api.cohere.ai/compatibility/v1", + apiKey: process.env.COHERE_API_KEY, + }); + this.model = modelPreference || process.env.COHERE_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.#log( + `Initialized with model ${this.model}. ctx: ${this.promptWindowLimit()}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("cohere", modelName) ?? 4_096; + } + + promptWindowLimit() { + return MODEL_MAP.get("cohere", this.model) ?? 4_096; + } + + async isValidChatCompletionModel() { + return true; + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + const promptTokens = result.output.usage?.prompt_tokens || 0; + const completionTokens = result.output.usage?.completion_tokens || 0; + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + outputTps: completionTokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + stream_options: { include_usage: true }, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + /** + * Returns the capabilities of the model by querying Cohere's models endpoint. + * A model supports tool calling when its `features` array includes `tools` or `tool_choice`. + * The OpenAI-compatible route does not expose this, so we hit the native REST API. + * @returns {Promise<{tools: boolean, reasoning: boolean, imageGeneration: boolean, vision: boolean}>} + */ + async getModelCapabilities() { + try { + if (!process.env.COHERE_API_KEY) + throw new Error("No Cohere API key was set."); + const features = await fetch( + `https://api.cohere.com/v1/models/${this.model}`, + { + method: "GET", + headers: { Authorization: `Bearer ${process.env.COHERE_API_KEY}` }, + } + ) + .then((res) => { + if (!res.ok) + throw new Error(`Cohere:getModelCapabilities - ${res.statusText}`); + return res.json(); + }) + .then((data) => data?.features || []); + + return { + tools: features.includes("tools"), + reasoning: features.includes("reasoning"), + imageGeneration: false, + vision: features.includes("vision"), + }; + } catch (error) { + console.error("Cohere:getModelCapabilities", error.message); + return { + tools: false, + reasoning: false, + imageGeneration: false, + vision: false, + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + CohereLLM, +}; diff --git a/server/utils/AiProviders/cometapi/constants.js b/server/utils/AiProviders/cometapi/constants.js new file mode 100644 index 0000000..2d7a32d --- /dev/null +++ b/server/utils/AiProviders/cometapi/constants.js @@ -0,0 +1,39 @@ +// TODO: When CometAPI's model list is upgraded, this operation needs to be removed +// Model filtering patterns from cometapi.md that are not supported by AnythingLLM +module.exports.COMETAPI_IGNORE_PATTERNS = [ + // Image generation models + "dall-e", + "dalle", + "midjourney", + "mj_", + "stable-diffusion", + "sd-", + "flux-", + "playground-v", + "ideogram", + "recraft-", + "black-forest-labs", + "/recraft-v3", + "recraftv3", + "stability-ai/", + "sdxl", + // Audio generation models + "suno_", + "tts", + "whisper", + // Video generation models + "runway", + "luma_", + "luma-", + "veo", + "kling_", + "minimax_video", + "hunyuan-t1", + // Utility models + "embedding", + "search-gpts", + "files_retrieve", + "moderation", + // Deepl + "deepl", +]; diff --git a/server/utils/AiProviders/cometapi/index.js b/server/utils/AiProviders/cometapi/index.js new file mode 100644 index 0000000..85032ac --- /dev/null +++ b/server/utils/AiProviders/cometapi/index.js @@ -0,0 +1,445 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { v4: uuidv4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { COMETAPI_IGNORE_PATTERNS } = require("./constants"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "cometapi") + : path.resolve(__dirname, `../../../storage/models/cometapi`) +); + +class CometApiLLM { + defaultTimeout = 3_000; + constructor(embedder = null, modelPreference = null) { + if (!process.env.COMETAPI_LLM_API_KEY) + throw new Error("No CometAPI API key was set."); + + this.className = "CometApiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = "https://api.cometapi.com/v1"; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.COMETAPI_LLM_API_KEY ?? null, + defaultHeaders: { + "HTTP-Referer": "https://anythingllm.com", + "X-CometAPI-Source": "anythingllm", + }, + }); + this.model = + modelPreference || process.env.COMETAPI_LLM_MODEL_PREF || "gpt-5-mini"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.timeout = this.#parseTimeout(); + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + + this.log(`Loaded with model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * CometAPI has various models that never return `finish_reasons` and thus leave the stream open + * which causes issues in subsequent messages. This timeout value forces us to close the stream after + * x milliseconds. This is a configurable value via the COMETAPI_LLM_TIMEOUT_MS value + * @returns {number} The timeout value in milliseconds (default: 3_000) + */ + #parseTimeout() { + this.log( + `CometAPI timeout is set to ${process.env.COMETAPI_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms` + ); + if (isNaN(Number(process.env.COMETAPI_LLM_TIMEOUT_MS))) + return this.defaultTimeout; + const setValue = Number(process.env.COMETAPI_LLM_TIMEOUT_MS); + if (setValue < 500) return 500; + return setValue; + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + #cacheIsStale() { + const MAX_STALE = 6.048e8; // 1 Week in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + // The CometAPI model API has a lot of models, so we cache this locally in the directory + // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it. + // This might slow down the first request, but we need the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + // We used to have this as a chore, but given there is an API to get the info - this makes little sense. + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log( + "Model cache is not present or stale. Fetching from CometAPI API." + ); + await fetchCometApiModels(); + return; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const availableModels = fs.existsSync(cacheModelPath) + ? safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + {} + ) + : {}; + return availableModels[modelName]?.maxLength || 4096; + } + + promptWindowLimit() { + const availableModels = this.models(); + return availableModels[this.model]?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.models(); + return availableModels.hasOwnProperty(model); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `CometAPI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `CometAPI chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + /** + * Handles the default stream response for a chat. + * @param {import("express").Response} response + * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream + * @param {Object} responseProps + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + const timeoutThresholdMs = this.timeout; + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + let lastChunkTime = null; // null when first token is still not received. + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + // NOTICE: Not all CometAPI models will return a stop reason + // which keeps the connection open and so the model never finalizes the stream + // like the traditional OpenAI response schema does. So in the case the response stream + // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with + // no new chunks then we kill the stream and assume it to be complete. CometAPI is quite fast + // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if + // we find it is too aggressive. + const timeoutCheck = setInterval(() => { + if (lastChunkTime === null) return; + + const now = Number(new Date()); + const diffMs = now - lastChunkTime; + if (diffMs >= timeoutThresholdMs) { + this.log( + `CometAPI stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.` + ); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + clearInterval(timeoutCheck); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }, 500); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + lastChunkTime = Number(new Date()); + + if (token) { + fullText += token; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + if (message.finish_reason !== null) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + } + } catch (e) { + writeResponseChunk(response, { + uuid, + sources, + type: "abort", + textResponse: null, + close: true, + error: e.message, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Fetches available models from CometAPI and filters out non-chat models + * Based on cometapi.md specifications + */ +async function fetchCometApiModels() { + return await fetch(`https://api.cometapi.com/v1/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.COMETAPI_LLM_API_KEY}`, + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + + // Filter out non-chat models using patterns from cometapi.md + const chatModels = data.filter((model) => { + const modelId = model.id.toLowerCase(); + return !COMETAPI_IGNORE_PATTERNS.some((pattern) => + modelId.includes(pattern.toLowerCase()) + ); + }); + + chatModels.forEach((model) => { + models[model.id] = { + id: model.id, + name: model.id, // CometAPI has limited model info according to cometapi.md + organization: + model.id.split("/")[0] || model.id.split("-")[0] || "CometAPI", + maxLength: model.context_length || 4096, // Conservative default + }; + }); + + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(models), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + return models; + }) + .catch((e) => { + console.error("Error fetching CometAPI models:", e); + return {}; + }); +} + +module.exports = { + CometApiLLM, + fetchCometApiModels, +}; diff --git a/server/utils/AiProviders/deepseek/index.js b/server/utils/AiProviders/deepseek/index.js new file mode 100644 index 0000000..b85c765 --- /dev/null +++ b/server/utils/AiProviders/deepseek/index.js @@ -0,0 +1,180 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { MODEL_MAP } = require("../modelMap"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); + +class DeepSeekLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.DEEPSEEK_API_KEY) + throw new Error("No DeepSeek API key was set."); + this.className = "DeepSeekLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + apiKey: process.env.DEEPSEEK_API_KEY, + baseURL: "https://api.deepseek.com/v1", + }); + this.model = + modelPreference || process.env.DEEPSEEK_MODEL_PREF || "deepseek-chat"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("deepseek", modelName) ?? 8192; + } + + promptWindowLimit() { + return MODEL_MAP.get("deepseek", this.model) ?? 8192; + } + + async isValidChatCompletionModel(modelName = "") { + const models = await this.openai.models.list().catch(() => ({ data: [] })); + return models.data.some((model) => model.id === modelName); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * @param {Object} response + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content; + if ( + !!message?.reasoning_content && + message.reasoning_content.trim().length > 0 + ) + textResponse = `${message.reasoning_content}${textResponse}`; + return textResponse; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `DeepSeek chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result?.output?.hasOwnProperty("choices") || + result?.output?.choices?.length === 0 + ) + throw new Error( + `Invalid response body returned from DeepSeek: ${JSON.stringify(result.output)}` + ); + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `DeepSeek chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + DeepSeekLLM, +}; diff --git a/server/utils/AiProviders/dockerModelRunner/index.js b/server/utils/AiProviders/dockerModelRunner/index.js new file mode 100644 index 0000000..094fbf6 --- /dev/null +++ b/server/utils/AiProviders/dockerModelRunner/index.js @@ -0,0 +1,520 @@ +const fs = require("fs"); +const path = require("path"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { OpenAI: OpenAIApi } = require("openai"); +const { humanFileSize } = require("../../helpers"); +const { safeJsonParse } = require("../../http"); + +class DockerModelRunnerLLM { + static cacheTime = 1000 * 60 * 60 * 24; // 24 hours + static cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "docker-model-runner") + : path.resolve(__dirname, `../../../storage/models/docker-model-runner`) + ); + + constructor(embedder = null, modelPreference = null) { + if (!process.env.DOCKER_MODEL_RUNNER_BASE_PATH) + throw new Error("No Docker Model Runner API Base Path was set."); + if (!process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_PREF && !modelPreference) + throw new Error("No Docker Model Runner Model Pref was set."); + + this.className = "DockerModelRunnerLLM"; + this.dmr = new OpenAIApi({ + baseURL: parseDockerModelRunnerEndpoint( + process.env.DOCKER_MODEL_RUNNER_BASE_PATH + ), + apiKey: null, + }); + + this.model = + modelPreference || process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_PREF; + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.#log(`initialized with model: ${this.model}`); + } + + #log(text, ...args) { + console.log(`\x1b[32m[Docker Model Runner]\x1b[0m ${text}`, ...args); + } + + static slog(text, ...args) { + console.log(`\x1b[32m[Docker Model Runner]\x1b[0m ${text}`, ...args); + } + + async assertModelContextLimits() { + if (this.limits !== null) return; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + /** DMR does not support curling the context window limit from the API, so we return the system defined limit. */ + static promptWindowLimit(_) { + const systemDefinedLimit = + Number(process.env.DOCKER_MODEL_RUNNER_LLM_MODEL_TOKEN_LIMIT) || 8192; + return systemDefinedLimit; + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Docker Model Runner chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.dmr.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Docker Model Runner chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.dmr.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + /** + * Returns the capabilities of the model. + * Note: This is a heuristic approach to get the capabilities of the model based on the model metadata. + * It is not perfect, but works since every model metadata is different and may not have key values we rely on. + * There is no "capabilities" key in the metadata via any API endpoint - so we do this. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + const endpoint = new URL( + parseDockerModelRunnerEndpoint( + process.env.DOCKER_MODEL_RUNNER_BASE_PATH, + "dmr" + ) + ); + // eg: /models/ai/qwen3:4B-UD-Q4_K_XL + endpoint.pathname = `/models/${this.model}`; + const response = await fetch(endpoint.toString()); + const data = await response.text(); + + const tools = /tools|tool|tool_use|tool_call/.test(data); + const reasoning = /thinking|reason|reasoning|think/.test(data); + const imageGeneration = /diffusion/.test(data); + const vision = /vision|vllm|image/.test(data); + return { + tools: tools, + reasoning: reasoning, + imageGeneration: imageGeneration, + vision: vision, + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Parse the base path of the Docker Model Runner endpoint and return the host and port. + * @param {string} basePath - The base path of the Docker Model Runner endpoint. + * @param {'openai' | 'dmr'} to - The provider to parse the endpoint for (internal DMR or openai-compatible) + * @returns {string | null} + */ +function parseDockerModelRunnerEndpoint(basePath = null, to = "openai") { + if (!basePath) return null; + try { + const url = new URL(basePath); + if (to === "openai") url.pathname = "engines/v1"; + else if (to === "ollama") url.pathname = "api"; + else if (to === "dmr") url.pathname = ""; + return url.toString(); + } catch { + return basePath; + } +} + +/** + * @typedef {Object} DockerRunnerInstalledModel + * @property {string} id - The SHA256 identifier of the model layer/blob. + * @property {string[]} tags - List of tags or aliases associated with this model (e.g., "ai/qwen3:4B-UD-Q4_K_XL"). + * @property {number} created - The Unix timestamp (seconds) when the model was created. + * @property {string} config - The configuration of the model. + * @property {string} config.format - The file format (e.g., "gguf"). + * @property {string} config.quantization - The quantization level (e.g., "MOSTLY_Q4_K_M", "Q4_0"). + * @property {string} config.parameters - The parameter count formatted as a string (e.g., "4.02 B"). + * @property {string} config.architecture - The base architecture of the model (e.g., "qwen3", "llama"). + * @property {string} config.size - The physical file size formatted as a string (e.g., "2.37 GiB"). + * @property {string} config?.gguf - Raw GGUF metadata headers containing tokenizer, architecture details, and licensing. + * @property {string} config?.gguf['general.base_model.0.organization'] - The tokenizer of the model. + * @property {string} config?.gguf['general.basename'] - The base name of the model (the real name of the model, not the tag) + * @property {string} config?.gguf['*.context_length'] - The context length of the model. will be something like qwen3.context_length + */ + +function filterByTask(task = "chat", models = {}) { + const possibleEmbed = [{ pattern: /^all-mini/i }, { pattern: /embed/i }]; + const isEmbedModel = (strTag) => + possibleEmbed.some((p) => p.pattern.test(strTag)); + const filteredModels = {}; + for (const [modelName, tags] of Object.entries(models)) { + if (task === "chat") { + if (isEmbedModel(modelName)) continue; + filteredModels[modelName] = tags; + } else if (task === "embedding") { + if (!isEmbedModel(modelName)) continue; + filteredModels[modelName] = tags; + } + } + return filteredModels; +} + +/** + * Fetch the remote models from the Docker Hub and cache the results. + * @param {'chat' | 'embedding'} task - The task to fetch the models for. + * @returns {Promise>} + */ +async function fetchRemoteModels(task = "chat") { + const cachePath = path.resolve( + DockerModelRunnerLLM.cacheFolder, + "models.json" + ); + const cachedAtPath = path.resolve( + DockerModelRunnerLLM.cacheFolder, + ".cached_at" + ); + let cacheTime = 0; + + if (fs.existsSync(cachePath) && fs.existsSync(cachedAtPath)) { + cacheTime = Number(fs.readFileSync(cachedAtPath, "utf8")); + if (Date.now() - cacheTime < DockerModelRunnerLLM.cacheTime) + return filterByTask( + task, + safeJsonParse(fs.readFileSync(cachePath, "utf8")) + ); + } + + DockerModelRunnerLLM.slog(`Refreshing remote models from Docker Hub`); + // Now hit the Docker Hub API to get the remote model namespace and root tags + const availableNamespaces = []; // array of strings like ai/mistral, ai/qwen3, etc + let nextPage = + "https://hub.docker.com/v2/namespaces/ai/repositories?page_size=100&page=1"; + while (nextPage) { + const response = await fetch(nextPage) + .then((res) => res.json()) + .then((data) => { + const namespaces = data.results + .filter( + (result) => + result.namespace && + result.name && + result.content_types.includes("model") && + result.namespace === "ai" + ) + .map((result) => result.namespace + "/" + result.name); + availableNamespaces.push(...namespaces); + }) + .catch((e) => { + DockerModelRunnerLLM.slog( + `Error fetching remote models from Docker Hub`, + e + ); + return []; + }); + if (!response) break; + if (!response || !response.next) break; + nextPage = response.next; + } + + const availableRemoteModels = {}; + const BATCH_SIZE = 10; + + // Run batch requests to avoid rate limiting but also + // improve the speed of the total request time. + for (let i = 0; i < availableNamespaces.length; i += BATCH_SIZE) { + const batch = availableNamespaces.slice(i, i + BATCH_SIZE); + DockerModelRunnerLLM.slog( + `Fetching tags for batch ${Math.floor(i / BATCH_SIZE) + 1} of ${Math.ceil(availableNamespaces.length / BATCH_SIZE)}` + ); + + await Promise.all( + batch.map(async (namespace) => { + const [organization, model] = namespace.split("/"); + const namespaceUrl = new URL( + "https://hub.docker.com/v2/namespaces/ai/repositories/" + + model + + "/tags" + ); + + DockerModelRunnerLLM.slog( + `Fetching tags for ${namespaceUrl.toString()}` + ); + await fetch(namespaceUrl.toString()) + .then((res) => res.json()) + .then((data) => { + const tags = data.results.map((result) => { + return { + id: `${organization}/${model}:${result.name}`, + name: `${model}:${result.name}`, + size: humanFileSize(result.full_size), + organization: model, + }; + }); + availableRemoteModels[model] = tags; + }) + .catch((e) => { + DockerModelRunnerLLM.slog( + `Error fetching tags for ${namespaceUrl.toString()}`, + e + ); + }); + }) + ); + } + + if (Object.keys(availableRemoteModels).length === 0) { + DockerModelRunnerLLM.slog( + `No remote models found - API may be down or not available` + ); + return {}; + } + + if (!fs.existsSync(DockerModelRunnerLLM.cacheFolder)) + fs.mkdirSync(DockerModelRunnerLLM.cacheFolder, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(availableRemoteModels), { + encoding: "utf8", + }); + fs.writeFileSync(cachedAtPath, String(Number(new Date())), { + encoding: "utf8", + }); + return filterByTask(task, availableRemoteModels); +} + +/** + * This function will fetch the remote models from the Docker Hub as well + * as the local models installed on the system. + * @param {string} basePath - The base path of the Docker Model Runner endpoint. + * @param {'chat' | 'embedding'} task - The task to fetch the models for. + */ +async function getDockerModels(basePath = null, task = "chat") { + let availableModels = {}; + /** @type {Array} */ + let installedModels = {}; + + try { + // Grab the locally installed models from the Docker Model Runner API + const dmrUrl = new URL( + parseDockerModelRunnerEndpoint( + basePath ?? process.env.DOCKER_MODEL_RUNNER_BASE_PATH, + "dmr" + ) + ); + dmrUrl.pathname = "/models"; + + await fetch(dmrUrl.toString()) + .then((res) => res.json()) + .then((data) => { + data?.forEach((model) => { + const id = model.tags.at(0); + // eg: ai/qwen3:latest -> qwen3 + const tag = + id?.split("/").pop()?.split(":")?.at(1) ?? + id?.split(":").at(1) ?? + "latest"; + const organization = id?.split("/").pop()?.split(":")?.at(0) ?? id; + installedModels[id] = { + id: id, + name: `${organization}:${tag}`, + size: model.config?.size ?? "Unknown size", + organization: organization, + }; + }); + }); + + // Now hit the Docker Hub API to get the remote model namespace and root tags + const remoteModels = await fetchRemoteModels(task); + for (const [modelName, tags] of Object.entries(remoteModels)) { + availableModels[modelName] = { tags: [] }; + for (const tag of tags) { + if (!installedModels[tag.id]) + availableModels[modelName].tags.push({ ...tag, downloaded: false }); + else { + availableModels[modelName].tags.push({ ...tag, downloaded: true }); + // remove the model from the installed models list so we dont double append it to the available models list + // when checking for custom models + delete installedModels[tag.id]; + } + } + } + + // For any models that are still in the installed models list, we need to append them to the available models list as downloaded + for (const model of Object.values(installedModels)) { + const organization = model.id.split("/").pop(); + const name = model.id.split("/").pop(); + if (!availableModels[organization]) + availableModels[organization] = { tags: [] }; + availableModels[organization].tags.push({ + ...model, + downloaded: true, + name: name, + }); + } + } catch (e) { + DockerModelRunnerLLM.slog(`Error getting Docker models`, e); + } finally { + // eslint-disable-next-line + return Object.values(availableModels).flatMap((m) => m.tags); + } +} + +module.exports = { + DockerModelRunnerLLM, + parseDockerModelRunnerEndpoint, + getDockerModels, +}; diff --git a/server/utils/AiProviders/fireworksAi/index.js b/server/utils/AiProviders/fireworksAi/index.js new file mode 100644 index 0000000..48cb08c --- /dev/null +++ b/server/utils/AiProviders/fireworksAi/index.js @@ -0,0 +1,273 @@ +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); + +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "fireworks") + : path.resolve(__dirname, `../../../storage/models/fireworks`) +); + +class FireworksAiLLM { + constructor(embedder = null, modelPreference = null) { + this.className = "FireworksAiLLM"; + + if (!process.env.FIREWORKS_AI_LLM_API_KEY) + throw new Error("No FireworksAI API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.fireworks.ai/inference/v1", + apiKey: process.env.FIREWORKS_AI_LLM_API_KEY ?? null, + }); + this.model = modelPreference || process.env.FIREWORKS_AI_LLM_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = !embedder ? new NativeEmbedder() : embedder; + this.defaultTemp = 0.7; + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + #cacheIsStale() { + const MAX_STALE = 6.048e8; // 1 Week in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + // This function fetches the models from the ApiPie API and caches them locally. + // We do this because the ApiPie API has a lot of models, and we need to get the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + // We used to have this as a chore, but given there is an API to get the info - this makes little sense. + // This might slow down the first request, but we need the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log( + "Model cache is not present or stale. Fetching from FireworksAI API." + ); + await fireworksAiModels(); + return; + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const availableModels = fs.existsSync(cacheModelPath) + ? safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + {} + ) + : {}; + return availableModels[modelName]?.maxLength || 4096; + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const availableModels = this.models(); + return availableModels[this.model]?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.models(); + return availableModels.hasOwnProperty(model); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `FireworksAI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `FireworksAI chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function fireworksAiModels(providedApiKey = null) { + const apiKey = providedApiKey || process.env.FIREWORKS_AI_LLM_API_KEY || null; + const { OpenAI: OpenAIApi } = require("openai"); + const client = new OpenAIApi({ + baseURL: "https://api.fireworks.ai/inference/v1", + apiKey: apiKey, + }); + + return await client.models + .list() + .then((res) => res.data) + .then((models = []) => { + const validModels = {}; + models.forEach((model) => { + // There are many models - the ones without a context length are not chat models + if (!model.hasOwnProperty("context_length")) return; + + validModels[model.id] = { + id: model.id, + name: model.id.split("/").pop(), + organization: model.owned_by, + subtype: model.type, + maxLength: model.context_length ?? 4096, + }; + }); + + if (Object.keys(validModels).length === 0) { + console.log("fireworksAi: No models found"); + return {}; + } + + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(validModels), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + + return validModels; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + FireworksAiLLM, + fireworksAiModels, +}; diff --git a/server/utils/AiProviders/foundry/index.js b/server/utils/AiProviders/foundry/index.js new file mode 100644 index 0000000..98eb02e --- /dev/null +++ b/server/utils/AiProviders/foundry/index.js @@ -0,0 +1,475 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { v4: uuidv4 } = require("uuid"); +const { + formatChatHistory, + writeResponseChunk, + clientAbortedHandler, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +const { OpenAI: OpenAIApi } = require("openai"); + +class FoundryLLM { + /** @see FoundryLLM.cacheContextWindows */ + static modelContextWindows = {}; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.FOUNDRY_BASE_PATH) + throw new Error("No Foundry Base Path was set."); + + this.className = "FoundryLLM"; + this.model = modelPreference || process.env.FOUNDRY_MODEL_PREF; + this.openai = new OpenAIApi({ + baseURL: parseFoundryBasePath(process.env.FOUNDRY_BASE_PATH), + apiKey: null, + }); + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.limits = null; + FoundryLLM.cacheContextWindows(true); + this.#log(`Loaded with model: ${this.model}`); + } + + static #slog(text, ...args) { + console.log(`\x1b[36m[FoundryLLM]\x1b[0m ${text}`, ...args); + } + + #log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async assertModelContextLimits() { + if (this.limits !== null) return; + await FoundryLLM.cacheContextWindows(); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + /** + * Cache the context windows for the Foundry models. + * This is done once and then cached for the lifetime of the server. This is absolutely necessary to ensure that the context windows are correct. + * Foundry Local has a weird behavior that when max_completion_tokens is unset it will only allow the output to be 1024 tokens. + * + * If you pass in too large of a max_completion_tokens, it will throw an error. + * If you pass in too little of a max_completion_tokens, you will get stubbed outputs before you reach a real "stop" token. + * So we need to cache the context windows and use them for the lifetime of the server. + * @param {boolean} force + * @returns + */ + static async cacheContextWindows(force = false) { + try { + // Skip if we already have cached context windows and we're not forcing a refresh + if (Object.keys(FoundryLLM.modelContextWindows).length > 0 && !force) + return; + + const openai = new OpenAIApi({ + baseURL: parseFoundryBasePath(process.env.FOUNDRY_BASE_PATH), + apiKey: null, + }); + (await openai.models.list().then((result) => result.data)).map( + (model) => { + const contextWindow = + Number(model.maxInputTokens) + Number(model.maxOutputTokens); + FoundryLLM.modelContextWindows[model.id] = contextWindow; + } + ); + FoundryLLM.#slog(`Context windows cached for all models!`); + } catch (e) { + FoundryLLM.#slog(`Error caching context windows: ${e.message}`); + return; + } + } + + /** + * Unload a model from the Foundry engine forcefully + * If the model is invalid, we just ignore the error. This is a util + * simply to have the foundry engine drop the resources for the model. + * + * @param {string} modelName + * @returns {Promise} + */ + static async unloadModelFromEngine(modelName) { + const basePath = parseFoundryBasePath(process.env.FOUNDRY_BASE_PATH); + const baseUrl = new URL(basePath); + baseUrl.pathname = `/openai/unload/${modelName}`; + baseUrl.searchParams.set("force", "true"); + return await fetch(baseUrl.toString()) + .then((res) => res.json()) + .catch(() => null); + } + + static promptWindowLimit(modelName) { + if (Object.keys(FoundryLLM.modelContextWindows).length === 0) { + this.#slog( + "No context windows cached - Context window may be inaccurately reported." + ); + return process.env.FOUNDRY_MODEL_TOKEN_LIMIT || 4096; + } + + let userDefinedLimit = null; + const systemDefinedLimit = + Number(this.modelContextWindows[modelName]) || 4096; + + if ( + process.env.FOUNDRY_MODEL_TOKEN_LIMIT && + !isNaN(Number(process.env.FOUNDRY_MODEL_TOKEN_LIMIT)) && + Number(process.env.FOUNDRY_MODEL_TOKEN_LIMIT) > 0 + ) + userDefinedLimit = Number(process.env.FOUNDRY_MODEL_TOKEN_LIMIT); + + // The user defined limit is always higher priority than the context window limit, but it cannot be higher than the context window limit + // so we return the minimum of the two, if there is no user defined limit, we return the system defined limit as-is. + if (userDefinedLimit !== null) + return Math.min(userDefinedLimit, systemDefinedLimit); + return systemDefinedLimit; + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Foundry chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + max_completion_tokens: this.promptWindowLimit(), + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Foundry chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + max_completion_tokens: this.promptWindowLimit(), + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + /** + * The timeout for the Foundry stream in milliseconds. + * This is because Foundry does not self-close the stream and so we need to timeout the stream after a certain amount of time. + * @returns {number} + */ + get timeout() { + return 500; + } + + /** + * Handles the default stream response for a chat. + * @param {import("express").Response} response + * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream + * @param {Object} responseProps + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + const timeoutThresholdMs = this.timeout; + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + let reasoningText = ""; + let lastChunkTime = null; // null when first token is still not received. + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + // NOTICE: As of Foundry 0.8.119 the stream will never return a finish_reason + // nor will it self-close or send a final chunk. So we need to maintain an interval timer that if we go >=timeoutThresholdMs with + // no new chunks then we kill the stream and assume it to be complete. + const timeoutCheck = setInterval(() => { + if (lastChunkTime === null) return; + + const now = Number(new Date()); + const diffMs = now - lastChunkTime; + + if (diffMs >= timeoutThresholdMs) { + console.log( + `Foundry stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.` + ); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + clearInterval(timeoutCheck); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }, 500); + + try { + for await (const chunk of stream) { + // console.log(JSON.stringify(chunk, null, 2)); + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + const reasoningToken = message?.delta?.reasoning; + lastChunkTime = Number(new Date()); + + // Reasoning models will always return the reasoning text before the token text. + // can be null or '' + if (reasoningToken) { + // If the reasoning text is empty (''), we need to initialize it + // and send the first chunk of reasoning text. + if (reasoningText.length === 0) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: `${reasoningToken}`, + close: false, + error: false, + }); + reasoningText += `${reasoningToken}`; + continue; + } else { + // If the reasoning text is not empty, we need to append the reasoning text + // to the existing reasoning text. + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: reasoningToken, + close: false, + error: false, + }); + reasoningText += reasoningToken; + } + } + + // If the reasoning text is not empty, but the reasoning token is empty + // and the token text is not empty we need to close the reasoning text and begin sending the token text. + if (!!reasoningText && !reasoningToken && token) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: ``, + close: false, + error: false, + }); + fullText += `${reasoningText}`; + reasoningText = ""; + } + + if (token) { + fullText += token; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + // finish_reason can be "stop", "length", etc. when complete + // Must check for truthy value since undefined !== null is true + if (message?.finish_reason) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + clearInterval(timeoutCheck); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + return; // Exit the loop after resolving + } + } + } catch (e) { + writeResponseChunk(response, { + uuid, + sources, + type: "abort", + textResponse: null, + close: true, + error: e.message, + }); + response.removeListener("close", handleAbort); + clearInterval(timeoutCheck); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Parse the base path for the Foundry container API. Since the base path must end in /v1 and cannot have a trailing slash, + * and the user can possibly set it to anything and likely incorrectly due to pasting behaviors, we need to ensure it is in the correct format. + * @param {string} basePath + * @returns {string} + */ +function parseFoundryBasePath(providedBasePath = "") { + try { + const baseURL = new URL(providedBasePath); + const basePath = `${baseURL.origin}/v1`; + return basePath; + } catch { + return providedBasePath; + } +} + +module.exports = { + FoundryLLM, + parseFoundryBasePath, +}; diff --git a/server/utils/AiProviders/gemini/defaultModels.js b/server/utils/AiProviders/gemini/defaultModels.js new file mode 100644 index 0000000..4a52dc9 --- /dev/null +++ b/server/utils/AiProviders/gemini/defaultModels.js @@ -0,0 +1,74 @@ +const { MODEL_MAP } = require("../modelMap"); + +const stableModels = [ + // %STABLE_MODELS% - updated 2025-05-13T23:13:58.920Z + "gemini-1.5-pro-001", + "gemini-1.5-pro-002", + "gemini-1.5-pro", + "gemini-1.5-flash-001", + "gemini-1.5-flash", + "gemini-1.5-flash-002", + "gemini-1.5-flash-8b", + "gemini-1.5-flash-8b-001", + "gemini-2.0-flash", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.0-flash-lite", + "gemini-2.0-flash-preview-image-generation", + // %EOC_STABLE_MODELS% +]; + +// There are some models that are only available in the v1beta API +// and some models that are only available in the v1 API +// generally, v1beta models have `exp` in the name, but not always +// so we check for both against a static list as well via API. +const v1BetaModels = [ + // %V1BETA_MODELS% - updated 2025-05-13T23:13:58.920Z + "gemini-1.5-pro-latest", + "gemini-1.5-flash-latest", + "gemini-1.5-flash-8b-latest", + "gemini-1.5-flash-8b-exp-0827", + "gemini-1.5-flash-8b-exp-0924", + "gemini-2.5-pro-exp-03-25", + "gemini-2.5-pro-preview-03-25", + "gemini-2.5-flash-preview-04-17", + "gemini-2.5-flash-preview-04-17-thinking", + "gemini-2.5-pro-preview-05-06", + "gemini-2.0-flash-exp", + "gemini-2.0-flash-exp-image-generation", + "gemini-2.0-flash-lite-preview-02-05", + "gemini-2.0-flash-lite-preview", + "gemini-2.0-pro-exp", + "gemini-2.0-pro-exp-02-05", + "gemini-exp-1206", + "gemini-2.0-flash-thinking-exp-01-21", + "gemini-2.0-flash-thinking-exp", + "gemini-2.0-flash-thinking-exp-1219", + "learnlm-1.5-pro-experimental", + "learnlm-2.0-flash-experimental", + "gemma-3-1b-it", + "gemma-3-4b-it", + "gemma-3-12b-it", + "gemma-3-27b-it", + // %EOC_V1BETA_MODELS% +]; + +const defaultGeminiModels = () => [ + ...stableModels.map((model) => ({ + id: model, + name: model, + contextWindow: MODEL_MAP.get("gemini", model), + experimental: false, + })), + ...v1BetaModels.map((model) => ({ + id: model, + name: model, + contextWindow: MODEL_MAP.get("gemini", model), + experimental: true, + })), +]; + +module.exports = { + defaultGeminiModels, + v1BetaModels, +}; diff --git a/server/utils/AiProviders/gemini/index.js b/server/utils/AiProviders/gemini/index.js new file mode 100644 index 0000000..9ac2b9f --- /dev/null +++ b/server/utils/AiProviders/gemini/index.js @@ -0,0 +1,457 @@ +const fs = require("fs"); +const path = require("path"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + formatChatHistory, + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); +const { defaultGeminiModels, v1BetaModels } = require("./defaultModels"); +const { safeJsonParse } = require("../../http"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "gemini") + : path.resolve(__dirname, `../../../storage/models/gemini`) +); + +const NO_SYSTEM_PROMPT_MODELS = [ + "gemma-3-1b-it", + "gemma-3-4b-it", + "gemma-3-12b-it", + "gemma-3-27b-it", +]; + +class GeminiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.GEMINI_API_KEY) + throw new Error("No Gemini API key was set."); + + this.className = "GeminiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.model = + modelPreference || + process.env.GEMINI_LLM_MODEL_PREF || + "gemini-2.0-flash-lite"; + + const isExperimental = this.isExperimentalModel(this.model); + this.openai = new OpenAIApi({ + apiKey: process.env.GEMINI_API_KEY, + // Even models that are v1 in gemini API can be used with v1beta/openai/ endpoint and nobody knows why. + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", + }); + + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + this.#log( + `Initialized with model: ${this.model} ${isExperimental ? "[Experimental v1beta]" : "[Stable v1]"} - ctx: ${this.promptWindowLimit()}` + ); + } + + /** + * Checks if the model supports system prompts + * This is a static list of models that are known to not support system prompts + * since this information is not available in the API model response. + * @returns {boolean} + */ + get supportsSystemPrompt() { + return !NO_SYSTEM_PROMPT_MODELS.includes(this.model); + } + + #log(text, ...args) { + console.log(`\x1b[32m[${this.className}]\x1b[0m ${text}`, ...args); + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + static cacheIsStale() { + const MAX_STALE = 8.64e7; // 1 day in MS + if (!fs.existsSync(path.resolve(cacheFolder, ".cached_at"))) return true; + const now = Number(new Date()); + const timestampMs = Number( + fs.readFileSync(path.resolve(cacheFolder, ".cached_at")) + ); + return now - timestampMs > MAX_STALE; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + try { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + if (!fs.existsSync(cacheModelPath)) + return MODEL_MAP.get("gemini", modelName) ?? 30_720; + + const models = safeJsonParse(fs.readFileSync(cacheModelPath)); + const model = models.find((model) => model.id === modelName); + if (!model) + throw new Error( + "Model not found in cache - falling back to default model." + ); + return model.contextWindow; + } catch (e) { + console.error(`GeminiLLM:promptWindowLimit`, e.message); + return MODEL_MAP.get("gemini", modelName) ?? 30_720; + } + } + + promptWindowLimit() { + try { + if (!fs.existsSync(this.cacheModelPath)) + return MODEL_MAP.get("gemini", this.model) ?? 30_720; + const models = safeJsonParse(fs.readFileSync(this.cacheModelPath)); + const model = models.find((model) => model.id === this.model); + if (!model) + throw new Error( + "Model not found in cache - falling back to default model." + ); + return model.contextWindow; + } catch (e) { + console.error(`GeminiLLM:promptWindowLimit`, e.message); + return MODEL_MAP.get("gemini", this.model) ?? 30_720; + } + } + + /** + * Checks if a model is experimental by reading from the cache if available, otherwise it will perform + * a blind check against the v1BetaModels list - which is manually maintained and updated. + * @param {string} modelName - The name of the model to check + * @returns {boolean} A boolean indicating if the model is experimental + */ + isExperimentalModel(modelName) { + if ( + fs.existsSync(cacheFolder) && + fs.existsSync(path.resolve(cacheFolder, "models.json")) + ) { + const models = safeJsonParse( + fs.readFileSync(path.resolve(cacheFolder, "models.json")) + ); + const model = models.find((model) => model.id === modelName); + if (!model) return false; + return model.experimental; + } + + return modelName.includes("exp") || v1BetaModels.includes(modelName); + } + + /** + * Fetches Gemini models from the Google Generative AI API + * @param {string} apiKey - The API key to use for the request + * @param {number} limit - The maximum number of models to fetch + * @param {string} pageToken - The page token to use for pagination + * @returns {Promise<[{id: string, name: string, contextWindow: number, experimental: boolean}]>} A promise that resolves to an array of Gemini models + */ + static async fetchModels(apiKey, limit = 1_000, pageToken = null) { + if (!apiKey) return []; + if (fs.existsSync(cacheFolder) && !this.cacheIsStale()) { + console.log( + `\x1b[32m[GeminiLLM]\x1b[0m Using cached models API response.` + ); + return safeJsonParse( + fs.readFileSync(path.resolve(cacheFolder, "models.json")) + ); + } + + const stableModels = []; + const allModels = []; + + // Fetch from v1 + try { + const url = new URL( + "https://generativelanguage.googleapis.com/v1/models" + ); + url.searchParams.set("pageSize", limit); + url.searchParams.set("key", apiKey); + if (pageToken) url.searchParams.set("pageToken", pageToken); + await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }) + .then((res) => res.json()) + .then((data) => { + if (data.error) throw new Error(data.error.message); + return data.models ?? []; + }) + .then((models) => { + return models + .filter( + (model) => !model.displayName?.toLowerCase()?.includes("tuning") + ) // remove tuning models + .filter( + (model) => + !model.description?.toLowerCase()?.includes("deprecated") + ) // remove deprecated models (in comment) + .filter((model) => + // Only generateContent is supported + model.supportedGenerationMethods.includes("generateContent") + ) + .map((model) => { + stableModels.push(model.name); + allModels.push({ + id: model.name.split("/").pop(), + name: model.displayName, + contextWindow: model.inputTokenLimit, + experimental: false, + }); + }); + }) + .catch((e) => { + console.error(`Gemini:getGeminiModelsV1`, e.message); + return; + }); + } catch (e) { + console.error(`Gemini:getGeminiModelsV1`, e.message); + } + + // Fetch from v1beta + try { + const url = new URL( + "https://generativelanguage.googleapis.com/v1beta/models" + ); + url.searchParams.set("pageSize", limit); + url.searchParams.set("key", apiKey); + if (pageToken) url.searchParams.set("pageToken", pageToken); + await fetch(url.toString(), { + method: "GET", + headers: { "Content-Type": "application/json" }, + }) + .then((res) => res.json()) + .then((data) => { + if (data.error) throw new Error(data.error.message); + return data.models ?? []; + }) + .then((models) => { + return models + .filter((model) => !stableModels.includes(model.name)) // remove stable models that are already in the v1 list + .filter( + (model) => !model.displayName?.toLowerCase()?.includes("tuning") + ) // remove tuning models + .filter( + (model) => + !model.description?.toLowerCase()?.includes("deprecated") + ) // remove deprecated models (in comment) + .filter((model) => + // Only generateContent is supported + model.supportedGenerationMethods.includes("generateContent") + ) + .map((model) => { + allModels.push({ + id: model.name.split("/").pop(), + name: model.displayName, + contextWindow: model.inputTokenLimit, + experimental: true, + }); + }); + }) + .catch((e) => { + console.error(`Gemini:getGeminiModelsV1beta`, e.message); + return; + }); + } catch (e) { + console.error(`Gemini:getGeminiModelsV1beta`, e.message); + } + + if (allModels.length === 0) { + console.error(`Gemini:getGeminiModels - No models found`); + return defaultGeminiModels(); + } + + console.log( + `\x1b[32m[GeminiLLM]\x1b[0m Writing cached models API response to disk.` + ); + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(allModels) + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + new Date().getTime().toString() + ); + + return allModels; + } + + /** + * Checks if a model is valid for chat completion (unused) + * @deprecated + * @param {string} modelName - The name of the model to check + * @returns {Promise} A promise that resolves to a boolean indicating if the model is valid + */ + async isValidChatCompletionModel(modelName = "") { + const models = await this.fetchModels(process.env.GEMINI_API_KEY); + return models.some((model) => model.id === modelName); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "high", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + let prompt = []; + if (this.supportsSystemPrompt) { + prompt.push({ + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }); + } else { + this.#log( + `${this.model} - does not support system prompts - emulating...` + ); + prompt.push( + { + role: "user", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }, + { + role: "assistant", + content: "Okay.", + } + ); + } + + return [ + ...prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature: temperature, + }) + .catch((e) => { + console.error(e); + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature: temperature, + stream_options: { + include_usage: true, + }, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } +} + +module.exports = { + GeminiLLM, + NO_SYSTEM_PROMPT_MODELS, +}; diff --git a/server/utils/AiProviders/gemini/syncStaticLists.mjs b/server/utils/AiProviders/gemini/syncStaticLists.mjs new file mode 100644 index 0000000..b276995 --- /dev/null +++ b/server/utils/AiProviders/gemini/syncStaticLists.mjs @@ -0,0 +1,48 @@ +/** + * This is a script that syncs the static lists of models from the Gemini API + * so that maintainers can keep the fallback lists up to date. + * + * To run, cd into this directory and run: + * node syncStaticLists.mjs + */ + +import fs from "fs"; +import path from "path"; +import dotenv from "dotenv"; + +dotenv.config({ path: `../../../.env.development` }); +const existingCachePath = path.resolve('../../../storage/models/gemini') + +// This will fetch all of the models from the Gemini API as well as post-process them +// to remove any models that are deprecated or experimental. +import { GeminiLLM } from "./index.js"; + +if (fs.existsSync(existingCachePath)) { + console.log("Removing existing cache so we can fetch fresh models from Gemini endpoints..."); + fs.rmSync(existingCachePath, { recursive: true, force: true }); +} + +const models = await GeminiLLM.fetchModels(process.env.GEMINI_API_KEY); + +function updateDefaultModelsFile(models) { + const stableModelKeys = models.filter((model) => !model.experimental).map((model) => model.id); + const v1BetaModelKeys = models.filter((model) => model.experimental).map((model) => model.id); + + let defaultModelFileContents = fs.readFileSync(path.join("./defaultModels.js"), "utf8"); + + // Update the stable models between %STABLE_MODELS% and %EOC_STABLE_MODELS% comments + defaultModelFileContents = defaultModelFileContents.replace( + /%STABLE_MODELS%[\s\S]*?%EOC_STABLE_MODELS%/, + `%STABLE_MODELS% - updated ${new Date().toISOString()}\n"${stableModelKeys.join('",\n"')}",\n// %EOC_STABLE_MODELS%` + ); + + // Update the v1beta models between %V1BETA_MODELS% and %EOC_V1BETA_MODELS% comments + defaultModelFileContents = defaultModelFileContents.replace( + /%V1BETA_MODELS%[\s\S]*?%EOC_V1BETA_MODELS%/, + `%V1BETA_MODELS% - updated ${new Date().toISOString()}\n"${v1BetaModelKeys.join('",\n"')}",\n// %EOC_V1BETA_MODELS%` + ); + + fs.writeFileSync(path.join("./defaultModels.js"), defaultModelFileContents); + console.log("Updated defaultModels.js. Dont forget to `yarn lint` and commit!"); +} +updateDefaultModelsFile(models); diff --git a/server/utils/AiProviders/genericOpenAi/index.js b/server/utils/AiProviders/genericOpenAi/index.js new file mode 100644 index 0000000..4ef0b6e --- /dev/null +++ b/server/utils/AiProviders/genericOpenAi/index.js @@ -0,0 +1,493 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + formatChatHistory, + writeResponseChunk, + clientAbortedHandler, +} = require("../../helpers/chat/responses"); +const { v4: uuidv4 } = require("uuid"); +const { toValidNumber } = require("../../http"); +const { getAnythingLLMUserAgent } = require("../../../endpoints/utils"); + +class GenericOpenAiLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.GENERIC_OPEN_AI_BASE_PATH) + throw new Error( + "GenericOpenAI must have a valid base path to use for the api." + ); + + this.className = "GenericOpenAiLLM"; + this.basePath = process.env.GENERIC_OPEN_AI_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.GENERIC_OPEN_AI_API_KEY ?? null, + defaultHeaders: { + "User-Agent": getAnythingLLMUserAgent(), + ...GenericOpenAiLLM.parseCustomHeaders(), + }, + }); + this.model = + modelPreference ?? process.env.GENERIC_OPEN_AI_MODEL_PREF ?? null; + this.maxTokens = process.env.GENERIC_OPEN_AI_MAX_TOKENS + ? toValidNumber(process.env.GENERIC_OPEN_AI_MAX_TOKENS, 1024) + : 1024; + if (!this.model) + throw new Error("GenericOpenAI must have a valid model set."); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log(`Inference API: ${this.basePath} Model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * Parses custom headers from a CSV-formatted environment variable. + * Format: "Header-Name:value,Another-Header:value2" + * @returns {Object} Object with header key-value pairs + */ + static parseCustomHeaders() { + const customHeadersEnv = process.env.GENERIC_OPEN_AI_CUSTOM_HEADERS; + if (!customHeadersEnv) return {}; + + const headers = {}; + const pairs = customHeadersEnv.split(","); + + for (const pair of pairs) { + const colonIndex = pair.indexOf(":"); // only split on first colon for key/value separation + if (colonIndex === -1) continue; + + const key = pair.substring(0, colonIndex).trim(); + const value = pair.substring(colonIndex + 1).trim(); + + if (key && value) headers[key] = value; + } + + return headers; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + if (process.env.GENERIC_OPENAI_STREAMING_DISABLED === "true") return false; + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Short circuit since we have no idea if the model is valid or not + // in pre-flight for generic endpoints + isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * + * ## Developer Note + * This function assumes the generic OpenAI provider is _actually_ OpenAI compatible. + * For example, Ollama is "OpenAI compatible" but does not support images as a content array. + * The contentString also is the base64 string WITH `data:image/xxx;base64,` prefix, which may not be the case for all providers. + * If your provider does not work exactly this way, then attachments will not function or potentially break vision requests. + * If you encounter this issue, you are welcome to open an issue asking for your specific provider to be supported. + * + * This function will **not** be updated for providers that **do not** support images as a content array like OpenAI does. + * Do not open issues to update this function due to your specific provider not being compatible. Open an issue to request support for your specific provider. + * @param {Object} props + * @param {string} props.userPrompt - the user prompt to be sent to the model + * @param {import("../../helpers").Attachment[]} props.attachments - the array of attachments to be sent to the model + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "high", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Extracts accurate generation-only timing and token count from a llama.cpp + * response or streaming chunk. Mutates the provided usage object in place + * so it can be used by both streaming and non-streaming code paths. + * @param {Object} response - the API response or final streaming chunk + * @param {Object} usage - the usage object to mutate + */ + #extractLlamaCppTimings(response, usage) { + if (!response || !response.timings) return; + + if (response.timings.hasOwnProperty("predicted_n")) + usage.completion_tokens = Number(response.timings.predicted_n); + + if (response.timings.hasOwnProperty("predicted_ms")) + usage.duration = Number(response.timings.predicted_ms) / 1000; + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * @param {Object} response + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content; + if ( + !!message?.reasoning_content && + message.reasoning_content.trim().length > 0 + ) + textResponse = `${message.reasoning_content}${textResponse}`; + return textResponse; + } + + /** + * Includes the usage in the response if the ENV flag is set + * using the stream_options: { include_usage: true } option. This is available via ENV + * because some providers will crash with invalid options. + * @returns {Object} + */ + #includeStreamOptionsUsage() { + if (!("GENERIC_OPEN_AI_REPORT_USAGE" in process.env)) return {}; + if (process.env.GENERIC_OPEN_AI_REPORT_USAGE !== "true") return {}; + return { + stream_options: { + include_usage: true, + }, + }; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + max_tokens: this.maxTokens, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + const usage = { + prompt_tokens: result.output?.usage?.prompt_tokens || 0, + completion_tokens: result.output?.usage?.completion_tokens || 0, + total_tokens: result.output?.usage?.total_tokens || 0, + duration: result.duration, + }; + this.#extractLlamaCppTimings(result.output, usage); + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + ...usage, + outputTps: usage.completion_tokens / usage.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + max_tokens: this.maxTokens, + ...this.#includeStreamOptionsUsage(), + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + // TODO: This is a copy of the generic handleStream function in responses.js + // to specifically handle the DeepSeek reasoning model `reasoning_content` field. + // When or if ever possible, we should refactor this to be in the generic function. + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + let hasUsageMetrics = false; + let usage = { + completion_tokens: 0, + }; + + return new Promise(async (resolve) => { + let fullText = ""; + let reasoningText = ""; + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + const reasoningToken = message?.delta?.reasoning_content; + + if ( + chunk.hasOwnProperty("usage") && // exists + !!chunk.usage && // is not null + Object.values(chunk.usage).length > 0 // has values + ) { + if (chunk.usage.hasOwnProperty("prompt_tokens")) { + usage.prompt_tokens = Number(chunk.usage.prompt_tokens); + } + + if (chunk.usage.hasOwnProperty("completion_tokens")) { + hasUsageMetrics = true; // to stop estimating counter + usage.completion_tokens = Number(chunk.usage.completion_tokens); + } + } + + // Reasoning models will always return the reasoning text before the token text. + if (reasoningToken) { + // If the reasoning text is empty (''), we need to initialize it + // and send the first chunk of reasoning text. + if (reasoningText.length === 0) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: `${reasoningToken}`, + close: false, + error: false, + }); + reasoningText += `${reasoningToken}`; + continue; + } else { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: reasoningToken, + close: false, + error: false, + }); + reasoningText += reasoningToken; + } + } + + // If the reasoning text is not empty, but the reasoning token is empty + // and the token text is not empty we need to close the reasoning text and begin sending the token text. + if (!!reasoningText && !reasoningToken && token) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: ``, + close: false, + error: false, + }); + fullText += `${reasoningText}`; + reasoningText = ""; + } + + if (token) { + fullText += token; + // If we never saw a usage metric, we can estimate them by number of completion chunks + if (!hasUsageMetrics) usage.completion_tokens++; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + if ( + message?.hasOwnProperty("finish_reason") && // Got valid message and it is an object with finish_reason + message.finish_reason !== "" && + message.finish_reason !== null + ) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + this.#extractLlamaCppTimings(chunk, usage); + + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + break; // Break streaming when a valid finish_reason is first encountered + } + } + } catch (e) { + console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`); + writeResponseChunk(response, { + uuid, + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + } + + /** + * Check if a capability is supported via ENV configuration. + * `tools` is opt-out (enabled unless explicitly disabled), all others are opt-in. + * @param {'tools' | 'reasoning' | 'imageGeneration' | 'vision'} capability + * @returns {boolean} + */ + #supportsCapabilityFromENV(capability = "") { + switch (capability) { + case "tools": + return !process.env.PROVIDER_DISABLE_NATIVE_TOOL_CALLING?.includes( + "generic-openai" + ); + case "reasoning": + return !!process.env.PROVIDER_SUPPORTS_REASONING?.includes( + "generic-openai" + ); + case "imageGeneration": + return !!process.env.PROVIDER_SUPPORTS_IMAGE_GENERATION?.includes( + "generic-openai" + ); + case "vision": + return !!process.env.PROVIDER_SUPPORTS_VISION?.includes( + "generic-openai" + ); + default: + return false; + } + } + + /** + * Returns the capabilities of the model. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + return { + tools: this.#supportsCapabilityFromENV("tools"), + reasoning: this.#supportsCapabilityFromENV("reasoning"), + imageGeneration: this.#supportsCapabilityFromENV("imageGeneration"), + vision: this.#supportsCapabilityFromENV("vision"), + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + GenericOpenAiLLM, +}; diff --git a/server/utils/AiProviders/giteeai/index.js b/server/utils/AiProviders/giteeai/index.js new file mode 100644 index 0000000..0679b3f --- /dev/null +++ b/server/utils/AiProviders/giteeai/index.js @@ -0,0 +1,245 @@ +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse, toValidNumber } = require("../../http"); +const LEGACY_MODEL_MAP = require("../modelMap/legacy"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "giteeai") + : path.resolve(__dirname, `../../../storage/models/giteeai`) +); + +class GiteeAILLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.GITEE_AI_API_KEY) + throw new Error("No Gitee AI API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + + this.className = "GiteeAILLM"; + this.openai = new OpenAIApi({ + apiKey: process.env.GITEE_AI_API_KEY, + baseURL: "https://ai.gitee.com/v1", + }); + this.model = modelPreference || process.env.GITEE_AI_MODEL_PREF || ""; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + this.log("Initialized with model:", this.model); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.constructor.name}]\x1b[0m ${text}`, ...args); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(model) { + return ( + toValidNumber(process.env.GITEE_AI_MODEL_TOKEN_LIMIT) || + LEGACY_MODEL_MAP.giteeai[model] || + 8192 + ); + } + + promptWindowLimit() { + return ( + toValidNumber(process.env.GITEE_AI_MODEL_TOKEN_LIMIT) || + LEGACY_MODEL_MAP.giteeai[this.model] || + 8192 + ); + } + + async isValidChatCompletionModel(_modelName = "") { + return true; + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * @param {Object} response + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content; + if ( + !!message?.reasoning_content && + message.reasoning_content.trim().length > 0 + ) + textResponse = `${message.reasoning_content}${textResponse}`; + return textResponse; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result?.output?.hasOwnProperty("choices") || + result?.output?.choices?.length === 0 + ) + throw new Error( + `Invalid response body returned from GiteeAI: ${JSON.stringify(result.output)}` + ); + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function giteeAiModels() { + const url = new URL("https://ai.gitee.com/v1/models"); + url.searchParams.set("type", "text2text"); + return await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.GITEE_AI_API_KEY}`, + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => data) + .then((models = []) => { + const validModels = {}; + models.forEach( + (model) => + (validModels[model.id] = { + id: model.id, + name: model.id, + organization: model.owned_by, + }) + ); + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(validModels), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + + return validModels; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + GiteeAILLM, + giteeAiModels, +}; diff --git a/server/utils/AiProviders/groq/index.js b/server/utils/AiProviders/groq/index.js new file mode 100644 index 0000000..b697ab1 --- /dev/null +++ b/server/utils/AiProviders/groq/index.js @@ -0,0 +1,257 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class GroqLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.GROQ_API_KEY) throw new Error("No Groq API key was set."); + this.className = "GroqLLM"; + + this.openai = new OpenAIApi({ + baseURL: "https://api.groq.com/openai/v1", + apiKey: process.env.GROQ_API_KEY, + }); + this.model = + modelPreference || process.env.GROQ_MODEL_PREF || "llama-3.1-8b-instant"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[GroqAi]\x1b[0m ${text}`, ...args); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("groq", modelName) ?? 8192; + } + + promptWindowLimit() { + return MODEL_MAP.get("groq", this.model) ?? 8192; + } + + async isValidChatCompletionModel(modelName = "") { + return !!modelName; // name just needs to exist + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Last Updated: October 21, 2024 + * According to https://console.groq.com/docs/vision + * the vision models supported all make a mess of prompting depending on the model. + * Currently the llama3.2 models are only in preview and subject to change and the llava model is deprecated - so we will not support attachments for that at all. + * + * Since we can only explicitly support the current models, this is a temporary solution. + * If the attachments are empty or the model is not a vision model, we will return the default prompt structure which will work for all models. + * If the attachments are present and the model is a vision model - we only return the user prompt with attachments - see comment at end of function for more. + * + * Historical attachments are also omitted from prompt chat history for the reasons above. (TDC: Dec 30, 2024) + */ + #conditionalPromptStruct({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const VISION_MODELS = [ + "llama-3.2-90b-vision-preview", + "llama-3.2-11b-vision-preview", + ]; + const DEFAULT_PROMPT_STRUCT = [ + { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }, + ...chatHistory, + { role: "user", content: userPrompt }, + ]; + + // If there are no attachments or model is not a vision model, return the default prompt structure + // as there is nothing to attach or do and no model limitations to consider + if (!attachments.length) return DEFAULT_PROMPT_STRUCT; + if (!VISION_MODELS.includes(this.model)) { + this.#log( + `${this.model} is not an explicitly supported vision model! Will omit attachments.` + ); + return DEFAULT_PROMPT_STRUCT; + } + + return [ + // Why is the system prompt and history commented out? + // The current vision models for Groq perform VERY poorly with ANY history or text prior to the image. + // In order to not get LLM refusals for every single message, we will not include the "system prompt" or even the chat history. + // This is a temporary solution until Groq fixes their vision models to be more coherent and also handle context prior to the image. + // Note for the future: + // Groq vision models also do not support system prompts - which is why you see the user/assistant emulation used instead of "system". + // This means any vision call is assessed independently of the chat context prior to the image. + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // { + // role: "user", + // content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + // }, + // { + // role: "assistant", + // content: "OK", + // }, + // ...chatHistory, + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + // NOTICE: SEE GroqLLM.#conditionalPromptStruct for more information on how attachments are handled with Groq. + return this.#conditionalPromptStruct({ + systemPrompt, + contextTexts, + chatHistory, + userPrompt, + attachments, + }); + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `GroqAI:chatCompletion: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: + result.output.usage.completion_tokens / + result.output.usage.completion_time, + duration: result.output.usage.total_time, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `GroqAI:streamChatCompletion: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + GroqLLM, +}; diff --git a/server/utils/AiProviders/koboldCPP/index.js b/server/utils/AiProviders/koboldCPP/index.js new file mode 100644 index 0000000..fa34923 --- /dev/null +++ b/server/utils/AiProviders/koboldCPP/index.js @@ -0,0 +1,264 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + clientAbortedHandler, + writeResponseChunk, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { v4: uuidv4 } = require("uuid"); + +class KoboldCPPLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.KOBOLD_CPP_BASE_PATH) + throw new Error( + "KoboldCPP must have a valid base path to use for the api." + ); + + this.className = "KoboldCPPLLM"; + this.basePath = process.env.KOBOLD_CPP_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: null, + }); + this.model = modelPreference ?? process.env.KOBOLD_CPP_MODEL_PREF ?? null; + if (!this.model) throw new Error("KoboldCPP must have a valid model set."); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.maxTokens = Number(process.env.KOBOLD_CPP_MAX_TOKENS) || 2048; + this.log(`Inference API: ${this.basePath} Model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.KOBOLD_CPP_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Short circuit since we have no idea if the model is valid or not + // in pre-flight for generic endpoints + isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + max_tokens: this.maxTokens, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + const promptTokens = LLMPerformanceMonitor.countTokens(messages); + const completionTokens = LLMPerformanceMonitor.countTokens([ + { content: result.output.choices[0].message.content }, + ]); + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + outputTps: completionTokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + max_tokens: this.maxTokens, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + let usage = { + prompt_tokens: LLMPerformanceMonitor.countTokens(stream.messages || []), + completion_tokens: 0, + }; + + const handleAbort = () => { + usage.completion_tokens = LLMPerformanceMonitor.countTokens([ + { content: fullText }, + ]); + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + + if (token) { + fullText += token; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + // KoboldCPP finishes with "length" or "stop" + if ( + message.finish_reason !== "null" && + (message.finish_reason === "length" || + message.finish_reason === "stop") + ) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + usage.completion_tokens = LLMPerformanceMonitor.countTokens([ + { content: fullText }, + ]); + stream?.endMeasurement(usage); + resolve(fullText); + } + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + KoboldCPPLLM, +}; diff --git a/server/utils/AiProviders/lemonade/index.js b/server/utils/AiProviders/lemonade/index.js new file mode 100644 index 0000000..9dee725 --- /dev/null +++ b/server/utils/AiProviders/lemonade/index.js @@ -0,0 +1,465 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { OpenAI: OpenAIApi } = require("openai"); +const { humanFileSize } = require("../../helpers"); + +class LemonadeLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.LEMONADE_LLM_BASE_PATH) + throw new Error("No Lemonade API Base Path was set."); + if (!process.env.LEMONADE_LLM_MODEL_PREF && !modelPreference) + throw new Error("No Lemonade Model Pref was set."); + + this.className = "LemonadeLLM"; + this.lemonade = new OpenAIApi({ + baseURL: parseLemonadeServerEndpoint( + process.env.LEMONADE_LLM_BASE_PATH, + "openai" + ), + apiKey: process.env.LEMONADE_LLM_API_KEY || null, + }); + + this.model = modelPreference || process.env.LEMONADE_LLM_MODEL_PREF; + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + // We can establish here since we cannot dynamically curl the context window limit from the API. + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + this.#log(`initialized with model: ${this.model}`); + } + + #log(text, ...args) { + console.log(`\x1b[32m[Lemonade]\x1b[0m ${text}`, ...args); + } + + static slog(text, ...args) { + console.log(`\x1b[32m[Lemonade]\x1b[0m ${text}`, ...args); + } + + async assertModelContextLimits() { + if (this.limits !== null) return; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + this.#log( + `${this.model} is using a max context window of ${this.promptWindowLimit()} tokens.` + ); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + /** Lemonade does not support curling the context window limit from the API, so we return the system defined limit. */ + static promptWindowLimit(_) { + return Number(process.env.LEMONADE_LLM_MODEL_TOKEN_LIMIT) || 8192; + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * Used for getChatCompletions to render thinking text if present in full response. + * @param {Object} message - The message object from the Lemonade response. + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content ?? ""; + if ( + !!message?.reasoning_content && + message.reasoning_content.trim().length > 0 + ) + textResponse = `${message.reasoning_content}${textResponse}`; + return textResponse; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + await LemonadeLLM.loadModel(this.model); + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.lemonade.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + await LemonadeLLM.loadModel(this.model); + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.lemonade.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + /** + * Returns the capabilities of the model. + * Note: This is a heuristic approach to get the capabilities of the model based on the model metadata. + * It is not perfect, but works since every model metadata is different and may not have key values we rely on. + * There is no "capabilities" key in the metadata via any API endpoint - so we do this. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + const client = new OpenAIApi({ + baseURL: parseLemonadeServerEndpoint( + process.env.LEMONADE_LLM_BASE_PATH, + "openai" + ), + apiKey: process.env.LEMONADE_LLM_API_KEY || null, + }); + + const { labels = [] } = await client.models.retrieve(this.model); + return { + tools: labels.includes("tool-calling"), + reasoning: labels.includes("reasoning"), + imageGeneration: "unknown", + vision: labels.includes("vision"), + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + /** + * Get the currently loaded models from the Lemonade server. + * @returns {Promise} + */ + static async getCurrentlyLoadedModels() { + const endpoint = new URL( + parseLemonadeServerEndpoint(process.env.LEMONADE_LLM_BASE_PATH, "openai") + ); + endpoint.pathname += "/health"; + const loadedModels = await fetch(endpoint.toString(), { + method: "GET", + headers: { + ...(process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + }, + }) + .then((response) => { + if (!response.ok) + throw new Error( + `Failed to get currently loaded models: ${response.statusText}` + ); + return response.json(); + }) + .then(({ all_models_loaded = [] } = {}) => { + return all_models_loaded.map((model) => { + return { + model_name: model.model_name, + ctx_size: model?.recipe_options?.ctx_size ?? 8192, + }; + }); + }); + return loadedModels; + } + + /** + * Utility function to load a model from the Lemonade server. + * Does not check if the model is already loaded or unloads any models. + * @param {*} model + */ + static async loadModel(model, basePath = process.env.LEMONADE_LLM_BASE_PATH) { + try { + const desiredCtxSize = Number(this.promptWindowLimit()); + const currentlyLoadedModels = + await LemonadeLLM.getCurrentlyLoadedModels(); + const modelAlreadyLoaded = currentlyLoadedModels.find( + (m) => m.model_name === model + ); + + if (modelAlreadyLoaded) { + if (modelAlreadyLoaded.ctx_size === desiredCtxSize) { + LemonadeLLM.slog( + `Model ${model} already loaded with ctx size ${desiredCtxSize}` + ); + return true; + } + + LemonadeLLM.slog( + `Model ${model} needs to be reloaded again with ctx size ${desiredCtxSize}` + ); + } + + const endpoint = new URL(parseLemonadeServerEndpoint(basePath, "openai")); + endpoint.pathname += "/load"; + + LemonadeLLM.slog( + `Loading model ${model} with context size ${desiredCtxSize}` + ); + + await fetch(endpoint.toString(), { + method: "POST", + headers: { + ...(process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_name: String(model), + ctx_size: desiredCtxSize, + }), + }) + .then((response) => { + if (!response.ok) + throw new Error( + `Failed to load model ${model}: ${response.statusText}` + ); + return response.json(); + }) + .then((data) => { + if (data.status !== "success") throw new Error(data.message); + LemonadeLLM.slog(`Model ${model} loaded successfully`); + return true; + }); + } catch (error) { + LemonadeLLM.slog(`Error loading model ${model}:`, error); + return false; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Extracts the model family/organization name from a model ID. + * For example: + * - "Qwen3-VL-8B-Instruct-GGUF" → "Qwen" + * - "SmolLM3-3B-GGUF" → "SmolLM" + * - "Llama-3.2-8B" → "Llama" + * - "DeepSeek-V3-GGUF" → "DeepSeek" + * @param {string} modelId - The model identifier + * @returns {string} The organization/family name + */ +function extractModelOrganization(modelId) { + const match = modelId.match(/^([A-Za-z]+)/); + return match ? match[1] : modelId; +} + +/** + * Parse the base path of the Docker Model Runner endpoint and return the host and port. + * @param {string} basePath - The base path of the Lemonade server endpoint. + * @param {'base' | 'openai' | 'ollama'} to - The provider to parse the endpoint for (internal DMR or openai-compatible) + * @returns {string | null} + */ +function parseLemonadeServerEndpoint(basePath = null, to = "openai") { + if (!basePath) return null; + try { + const url = new URL(basePath); + if (to === "openai") url.pathname = "api/v1"; + else if (to === "ollama") url.pathname = "api"; + else if (to === "base") url.pathname = ""; // only used for /live + return url.toString(); + } catch { + return basePath; + } +} + +/** + * This function will fetch the remote models from the Lemonade server as well + * as the local models installed on the system. + * @param {string} basePath - The base path of the Lemonade server endpoint. + * @param {'chat' | 'embedding' | 'reranking' | 'transcription' | 'all'} task - The task to fetch the models for. + */ +async function getAllLemonadeModels(basePath = null, task = "chat") { + const availableModels = {}; + + function isValidForTask(model) { + if (task === "reranking") return model.labels?.includes("reranking"); + if (task === "embedding") return model.labels?.includes("embeddings"); + if (task === "transcription") + return model.labels?.includes("transcription"); + if (task === "chat") + return !["embeddings", "reranking"].some((label) => + model.labels?.includes(label) + ); + return true; + } + + try { + // Grab the locally installed models from the Lemonade server API + const lemonadeUrl = new URL( + parseLemonadeServerEndpoint( + basePath ?? process.env.LEMONADE_LLM_BASE_PATH, + "openai" + ) + ); + lemonadeUrl.pathname += "/models"; + lemonadeUrl.searchParams.append("show_all", "true"); + + await fetch(lemonadeUrl.toString(), { + headers: { + ...(!!process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + }, + }) + .then((res) => res.json()) + .then(({ data }) => { + data?.forEach((model) => { + if (!isValidForTask(model)) return; + + const organization = extractModelOrganization(model.id); + const modelData = { + id: model.id, + name: organization + ":" + model.id, + // Reports in GB, convert to bytes + size: model?.size + ? humanFileSize(model.size * 1024 ** 3) + : "Unknown size", + downloaded: model?.downloaded ?? false, + organization, + }; + + if (!availableModels[organization]) + availableModels[organization] = { tags: [] }; + availableModels[organization].tags.push(modelData); + }); + }); + } catch (e) { + LemonadeLLM.slog(`Error getting Lemonade models`, e); + } finally { + // eslint-disable-next-line + return Object.values(availableModels).flatMap((m) => m.tags); + } +} + +module.exports = { + LemonadeLLM, + parseLemonadeServerEndpoint, + getAllLemonadeModels, +}; diff --git a/server/utils/AiProviders/liteLLM/index.js b/server/utils/AiProviders/liteLLM/index.js new file mode 100644 index 0000000..5fa1cb3 --- /dev/null +++ b/server/utils/AiProviders/liteLLM/index.js @@ -0,0 +1,201 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); + +class LiteLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.LITE_LLM_BASE_PATH) + throw new Error( + "LiteLLM must have a valid base path to use for the api." + ); + + this.className = "LiteLLM"; + this.basePath = process.env.LITE_LLM_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.LITE_LLM_API_KEY ?? null, + }); + this.model = modelPreference ?? process.env.LITE_LLM_MODEL_PREF ?? null; + + if (!this.model) throw new Error("LiteLLM must have a valid model set."); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log(`Inference API: ${this.basePath} Model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.LITE_LLM_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.LITE_LLM_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Short circuit since we have no idea if the model is valid or not + // in pre-flight for generic endpoints + isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: + (result.output.usage?.completion_tokens || 0) / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + LiteLLM, +}; diff --git a/server/utils/AiProviders/lmStudio/index.js b/server/utils/AiProviders/lmStudio/index.js new file mode 100644 index 0000000..9252098 --- /dev/null +++ b/server/utils/AiProviders/lmStudio/index.js @@ -0,0 +1,383 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { OpenAI: OpenAIApi } = require("openai"); + +// hybrid of openAi LLM chat completion for LMStudio +class LMStudioLLM { + /** @see LMStudioLLM.cacheContextWindows */ + static modelContextWindows = {}; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.LMSTUDIO_BASE_PATH) + throw new Error("No LMStudio API Base Path was set."); + + this.className = "LMStudioLLM"; + const apiKey = process.env.LMSTUDIO_AUTH_TOKEN ?? null; + this.lmstudio = new OpenAIApi({ + baseURL: parseLMStudioBasePath(process.env.LMSTUDIO_BASE_PATH), // here is the URL to your LMStudio instance + apiKey, + }); + + // Prior to LMStudio 0.2.17 the `model` param was not required and you could pass anything + // into that field and it would work. On 0.2.17 LMStudio introduced multi-model chat + // which now has a bug that reports the server model id as "Loaded from Chat UI" + // and any other value will crash inferencing. So until this is patched we will + // try to fetch the `/models` and have the user set it, or just fallback to "Loaded from Chat UI" + // which will not impact users with } - A promise that resolves when the cache is refreshed. + */ + static async cacheContextWindows(force = false) { + try { + // Skip if we already have cached context windows and we're not forcing a refresh + if (Object.keys(LMStudioLLM.modelContextWindows).length > 0 && !force) + return; + + const apiKey = process.env.LMSTUDIO_AUTH_TOKEN ?? null; + const endpoint = new URL( + parseLMStudioBasePath(process.env.LMSTUDIO_BASE_PATH) + ); + endpoint.pathname = "/api/v0/models"; + await fetch(endpoint.toString(), { + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }) + .then((res) => { + if (!res.ok) + throw new Error(`LMStudio:cacheContextWindows - ${res.statusText}`); + return res.json(); + }) + .then(({ data: models }) => { + models.forEach((model) => { + if (model.type === "embeddings") return; + LMStudioLLM.modelContextWindows[model.id] = + model.max_context_length; + }); + }) + .catch((e) => { + LMStudioLLM.#slog(`Error caching context windows`, e); + return; + }); + + LMStudioLLM.#slog(`Context windows cached for all models!`); + } catch (e) { + LMStudioLLM.#slog(`Error caching context windows`, e); + return; + } + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + if (Object.keys(LMStudioLLM.modelContextWindows).length === 0) { + this.#slog( + "No context windows cached - Context window may be inaccurately reported." + ); + return process.env.LMSTUDIO_MODEL_TOKEN_LIMIT || 16384; + } + + let userDefinedLimit = null; + const systemDefinedLimit = + Number(this.modelContextWindows[modelName]) || 16384; + + if ( + process.env.LMSTUDIO_MODEL_TOKEN_LIMIT && + !isNaN(Number(process.env.LMSTUDIO_MODEL_TOKEN_LIMIT)) && + Number(process.env.LMSTUDIO_MODEL_TOKEN_LIMIT) > 0 + ) + userDefinedLimit = Number(process.env.LMSTUDIO_MODEL_TOKEN_LIMIT); + + // The user defined limit is always higher priority than the context window limit, but it cannot be higher than the context window limit + // so we return the minimum of the two, if there is no user defined limit, we return the system defined limit as-is. + if (userDefinedLimit !== null) + return Math.min(userDefinedLimit, systemDefinedLimit); + return systemDefinedLimit; + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + async isValidChatCompletionModel(_ = "") { + // LMStudio may be anything. The user must do it correctly. + // See comment about this.model declaration in constructor + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * Used for getChatCompletions to render thinking text if present in full response. + * @param {Object} message - The message object from the LMStudio response. + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content ?? ""; + if ( + !!message?.reasoning_content && + message.reasoning_content.trim().length > 0 + ) + textResponse = `${message.reasoning_content}${textResponse}`; + return textResponse; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `LMStudio chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.lmstudio.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `LMStudio chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.lmstudio.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + /** + * Returns the capabilities of the model. + * This uses the new /api/v1 endpoint, which returns the model info in a different format. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + const endpoint = new URL( + parseLMStudioBasePath(process.env.LMSTUDIO_BASE_PATH, "v1") + ); + const apiKey = process.env.LMSTUDIO_AUTH_TOKEN ?? null; + endpoint.pathname += "/models"; + const modelInfo = + (await fetch(endpoint.toString(), { + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + }) + .then((res) => { + if (!res.ok) + throw new Error( + `LMStudio:getModelCapabilities - ${res.statusText}` + ); + return res.json(); + }) + .then(({ models = [] }) => + models.find((model) => model.key === this.model) + )) || {}; + + const capabilities = modelInfo.hasOwnProperty("capabilities") + ? modelInfo.capabilities + : { + trained_for_tool_use: "unknown", + vision: "unknown", + }; + + return { + tools: capabilities.trained_for_tool_use, + reasoning: "unknown", + imageGeneration: "unknown", // LM Studio does not support image generation yet. + vision: capabilities.vision, + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Parse the base path for the LMStudio API. Since the base path must end in /v1 and cannot have a trailing slash, + * and the user can possibly set it to anything and likely incorrectly due to pasting behaviors, we need to ensure it is in the correct format. + * @param {string} basePath + * @param {'legacy' | 'v1'} apiVersion + * @returns {string} + */ +function parseLMStudioBasePath(providedBasePath = "", apiVersion = "legacy") { + try { + const baseURL = new URL(providedBasePath); + let basePath = `${baseURL.origin}`; + if (apiVersion === "legacy") basePath += `/v1`; + if (apiVersion === "v1") basePath += `/api/v1`; + return basePath; + } catch { + return providedBasePath; + } +} + +module.exports = { + LMStudioLLM, + parseLMStudioBasePath, +}; diff --git a/server/utils/AiProviders/localAi/index.js b/server/utils/AiProviders/localAi/index.js new file mode 100644 index 0000000..bc723e7 --- /dev/null +++ b/server/utils/AiProviders/localAi/index.js @@ -0,0 +1,198 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); + +class LocalAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.LOCAL_AI_BASE_PATH) + throw new Error("No LocalAI Base Path was set."); + + this.className = "LocalAiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: process.env.LOCAL_AI_BASE_PATH, + apiKey: process.env.LOCAL_AI_API_KEY ?? null, + }); + this.model = modelPreference || process.env.LOCAL_AI_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.LOCAL_AI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No LocalAi token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.LOCAL_AI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No LocalAi token context limit was set."); + return Number(limit); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `LocalAI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + const promptTokens = LLMPerformanceMonitor.countTokens(messages); + const completionTokens = LLMPerformanceMonitor.countTokens( + result.output.choices[0].message.content + ); + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + outputTps: completionTokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `LocalAi chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + LocalAiLLM, +}; diff --git a/server/utils/AiProviders/minimax/index.js b/server/utils/AiProviders/minimax/index.js new file mode 100644 index 0000000..974fb69 --- /dev/null +++ b/server/utils/AiProviders/minimax/index.js @@ -0,0 +1,165 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class MinimaxLLM { + constructor(embedder = null, modelPreference = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.MINIMAX_API_KEY) + throw new Error("No Minimax API key was set."); + this.className = "MinimaxLLM"; + + this.openai = new OpenAIApi({ + baseURL: "https://api.minimax.io/v1", + apiKey: process.env.MINIMAX_API_KEY, + }); + this.model = + modelPreference || process.env.MINIMAX_MODEL_PREF || "MiniMax-M2.7"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("minimax", modelName) ?? 196000; + } + + promptWindowLimit() { + return MODEL_MAP.get("minimax", this.model) ?? 196000; + } + + async isValidChatCompletionModel(modelName = "") { + const models = await this.openai.models.list().catch(() => ({ data: [] })); + return models.data.some((model) => model.id === modelName); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Minimax chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result?.output?.hasOwnProperty("choices") || + result?.output?.choices?.length === 0 + ) + throw new Error( + `Invalid response body returned from Minimax: ${JSON.stringify(result.output)}` + ); + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Minimax stream: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + MinimaxLLM, +}; diff --git a/server/utils/AiProviders/mistral/index.js b/server/utils/AiProviders/mistral/index.js new file mode 100644 index 0000000..d14c5ce --- /dev/null +++ b/server/utils/AiProviders/mistral/index.js @@ -0,0 +1,191 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); + +class MistralLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.MISTRAL_API_KEY) + throw new Error("No Mistral API key was set."); + + this.className = "MistralLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.mistral.ai/v1", + apiKey: process.env.MISTRAL_API_KEY ?? null, + }); + this.model = + modelPreference || process.env.MISTRAL_MODEL_PREF || "mistral-tiny"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.0; + this.log("Initialized with model:", this.model); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit() { + return 32000; + } + + promptWindowLimit() { + return 32000; + } + + async isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: attachment.contentString, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Mistral chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Mistral chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + MistralLLM, +}; diff --git a/server/utils/AiProviders/modelMap/index.js b/server/utils/AiProviders/modelMap/index.js new file mode 100644 index 0000000..3ea594b --- /dev/null +++ b/server/utils/AiProviders/modelMap/index.js @@ -0,0 +1,213 @@ +const path = require("path"); +const fs = require("fs"); +const LEGACY_MODEL_MAP = require("./legacy"); + +class ContextWindowFinder { + static instance = null; + static modelMap = LEGACY_MODEL_MAP; + + /** + * Mapping for AnythingLLM provider <> LiteLLM provider + * @type {Record} + */ + static trackedProviders = { + anthropic: "anthropic", + openai: "openai", + cohere: "cohere_chat", + gemini: "vertex_ai-language-models", + groq: "groq", + xai: "xai", + deepseek: "deepseek", + moonshot: "moonshot", + zai: "vercel_ai_gateway", // Vercel has correct context windows for Z.AI models + sambanova: "sambanova", + minimax: "minimax", + cerebras: "cerebras", + }; + static expiryMs = 1000 * 60 * 60 * 24 * 3; // 3 days + static remoteUrl = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + + cacheLocation = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "context-windows") + : path.resolve(__dirname, `../../../storage/models/context-windows`) + ); + cacheFilePath = path.resolve(this.cacheLocation, "context-windows.json"); + cacheFileExpiryPath = path.resolve(this.cacheLocation, ".cached_at"); + seenStaleCacheWarning = false; + + constructor() { + if (ContextWindowFinder.instance) return ContextWindowFinder.instance; + ContextWindowFinder.instance = this; + if (!fs.existsSync(this.cacheLocation)) + fs.mkdirSync(this.cacheLocation, { recursive: true }); + + // If the cache is stale or not found at all, pull the model map from remote + if (this.isCacheStale || !fs.existsSync(this.cacheFilePath)) { + this.#pullRemoteModelMap().catch((err) => + this.log("Background model map pull failed:", err) + ); + } + } + + log(text, ...args) { + if (process.env.NODE_ENV === "test") return; + console.log(`\x1b[33m[ContextWindowFinder]\x1b[0m ${text}`, ...args); + } + + /** + * Checks if the cache is stale by checking if the cache file exists and if the cache file is older than the expiry time. + * @returns {boolean} + */ + get isCacheStale() { + if (!fs.existsSync(this.cacheFileExpiryPath)) return true; + const cachedAt = fs.readFileSync(this.cacheFileExpiryPath, "utf8"); + return Date.now() - cachedAt > ContextWindowFinder.expiryMs; + } + + /** + * Gets the cached model map. + * + * Always returns the available model map - even if it is expired since re-pulling + * the model map only occurs on container start/system start. + * @returns {Record> | null} - The cached model map + */ + get cachedModelMap() { + if (!fs.existsSync(this.cacheFilePath)) { + this.log(`\x1b[33m +-------------------------------- +[WARNING] Model map cache is not found! +Invalid context windows will be returned leading to inaccurate model responses +or smaller context windows than expected. +You can fix this by restarting AnythingLLM so the model map is re-pulled. +--------------------------------\x1b[0m`); + return null; + } + + if (this.isCacheStale && !this.seenStaleCacheWarning) { + this.log( + "Model map cache is stale - some model context windows may be incorrect. This is OK and the model map will be re-pulled on next boot." + ); + this.seenStaleCacheWarning = true; + } + + return JSON.parse( + fs.readFileSync(this.cacheFilePath, { encoding: "utf8" }) + ); + } + + /** + * Pulls the remote model map from the remote URL, formats it and caches it. + * @returns {Record>} - The formatted model map + */ + async #pullRemoteModelMap() { + try { + this.log("Pulling remote model map..."); + const response = await fetch(ContextWindowFinder.remoteUrl); + if (response.status !== 200) { + throw new Error( + "Failed to fetch remote model map - non 200 status code" + ); + } + + const data = await response.json(); + const modelMap = this.#validateModelMap(this.#formatModelMap(data)); + await Promise.all([ + fs.promises.writeFile( + this.cacheFilePath, + JSON.stringify(modelMap, null, 2) + ), + fs.promises.writeFile(this.cacheFileExpiryPath, Date.now().toString()), + ]); + + this.log("Remote model map synced and cached"); + return modelMap; + } catch (error) { + this.log("Error syncing remote model map", error); + return null; + } + } + + #validateModelMap(modelMap = {}) { + for (const [provider, models] of Object.entries(modelMap)) { + // If the models is null/falsey or has no keys, throw an error + if (typeof models !== "object") + throw new Error( + `Invalid model map for ${provider} - models is not an object` + ); + if (!models || Object.keys(models).length === 0) + throw new Error(`Invalid model map for ${provider} - no models found!`); + + // Validate that the context window is a number + for (const [model, contextWindow] of Object.entries(models)) { + if (isNaN(contextWindow) || contextWindow <= 0) { + this.log( + `${provider}:${model} - context window is not a positive number. Got ${contextWindow}.` + ); + delete models[model]; + continue; + } + } + } + return modelMap; + } + + /** + * Formats the remote model map to a format that is compatible with how we store the model map + * for all providers who use it. + * @param {Record} modelMap - The remote model map + * @returns {Record>} - The formatted model map + */ + #formatModelMap(modelMap = {}) { + const formattedModelMap = {}; + + for (const [provider, liteLLMProviderTag] of Object.entries( + ContextWindowFinder.trackedProviders + )) { + formattedModelMap[provider] = {}; + const matches = Object.entries(modelMap).filter( + ([_key, config]) => config.litellm_provider === liteLLMProviderTag + ); + for (const [key, config] of matches) { + const contextWindow = Number(config.max_input_tokens); + if (isNaN(contextWindow)) continue; + + // Some models have a provider/model-tag format, so we need to get the last part since we dont do paths + // for names with the exception of some router-providers like OpenRouter or Together. + const modelName = key.split("/").pop(); + formattedModelMap[provider][modelName] = contextWindow; + } + } + return formattedModelMap; + } + + /** + * Gets the context window for a given provider and model. + * + * If the provider is not found, null is returned. + * If the model is not found, the provider's entire model map is returned. + * + * if both provider and model are provided, the context window for the given model is returned. + * @param {string|null} provider - The provider to get the context window for + * @param {string|null} model - The model to get the context window for + * @returns {number|null} - The context window for the given provider and model + */ + get(provider = null, model = null) { + if (!provider || !this.cachedModelMap || !this.cachedModelMap[provider]) + return null; + if (!model) return this.cachedModelMap[provider]; + + const modelContextWindow = this.cachedModelMap[provider][model]; + if (!modelContextWindow) { + this.log("Invalid access to model context window - not found in cache", { + provider, + model, + }); + return null; + } + return Number(modelContextWindow); + } +} + +module.exports = { MODEL_MAP: new ContextWindowFinder() }; diff --git a/server/utils/AiProviders/modelMap/legacy.js b/server/utils/AiProviders/modelMap/legacy.js new file mode 100644 index 0000000..ddbde31 --- /dev/null +++ b/server/utils/AiProviders/modelMap/legacy.js @@ -0,0 +1,162 @@ +const LEGACY_MODEL_MAP = { + anthropic: { + "claude-instant-1.2": 100000, + "claude-2.0": 100000, + "claude-2.1": 200000, + "claude-3-haiku-20240307": 200000, + "claude-3-sonnet-20240229": 200000, + "claude-3-opus-20240229": 200000, + "claude-3-opus-latest": 200000, + "claude-3-5-haiku-latest": 200000, + "claude-3-5-haiku-20241022": 200000, + "claude-3-5-sonnet-latest": 200000, + "claude-3-5-sonnet-20241022": 200000, + "claude-3-5-sonnet-20240620": 200000, + "claude-3-7-sonnet-20250219": 200000, + "claude-3-7-sonnet-latest": 200000, + }, + cohere: { + "command-r": 128000, + "command-r-plus": 128000, + command: 4096, + "command-light": 4096, + "command-nightly": 8192, + "command-light-nightly": 8192, + "command-r-plus-08-2024": 132096, + "command-a-03-2025": 288000, + "c4ai-aya-vision-32b": 16384, + "command-a-reasoning-08-2025": 288768, + "command-r-08-2024": 132096, + "c4ai-aya-vision-8b": 16384, + "command-r7b-12-2024": 132000, + "command-r7b-arabic-02-2025": 128000, + "command-a-vision-07-2025": 128000, + "c4ai-aya-expanse-8b": 8192, + "c4ai-aya-expanse-32b": 128000, + "command-a-translate-08-2025": 8992, + }, + gemini: { + "gemini-1.5-pro-001": 2000000, + "gemini-1.5-pro-002": 2000000, + "gemini-1.5-pro": 2000000, + "gemini-1.5-flash-001": 1000000, + "gemini-1.5-flash": 1000000, + "gemini-1.5-flash-002": 1000000, + "gemini-1.5-flash-8b": 1000000, + "gemini-1.5-flash-8b-001": 1000000, + "gemini-2.0-flash": 1048576, + "gemini-2.0-flash-001": 1048576, + "gemini-2.0-flash-lite-001": 1048576, + "gemini-2.0-flash-lite": 1048576, + "gemini-1.5-pro-latest": 2000000, + "gemini-1.5-flash-latest": 1000000, + "gemini-1.5-flash-8b-latest": 1000000, + "gemini-1.5-flash-8b-exp-0827": 1000000, + "gemini-1.5-flash-8b-exp-0924": 1000000, + "gemini-2.5-pro-exp-03-25": 1048576, + "gemini-2.5-pro-preview-03-25": 1048576, + "gemini-2.0-flash-exp": 1048576, + "gemini-2.0-flash-exp-image-generation": 1048576, + "gemini-2.0-flash-lite-preview-02-05": 1048576, + "gemini-2.0-flash-lite-preview": 1048576, + "gemini-2.0-pro-exp": 1048576, + "gemini-2.0-pro-exp-02-05": 1048576, + "gemini-exp-1206": 1048576, + "gemini-2.0-flash-thinking-exp-01-21": 1048576, + "gemini-2.0-flash-thinking-exp": 1048576, + "gemini-2.0-flash-thinking-exp-1219": 1048576, + "learnlm-1.5-pro-experimental": 32767, + "gemma-3-1b-it": 32768, + "gemma-3-4b-it": 32768, + "gemma-3-12b-it": 32768, + "gemma-3-27b-it": 131072, + }, + groq: { + "gemma2-9b-it": 8192, + "gemma-7b-it": 8192, + "llama3-70b-8192": 8192, + "llama3-8b-8192": 8192, + "llama-3.1-70b-versatile": 8000, + "llama-3.1-8b-instant": 8000, + "mixtral-8x7b-32768": 32768, + }, + openai: { + "gpt-3.5-turbo": 16385, + "gpt-3.5-turbo-1106": 16385, + "gpt-4o": 128000, + "gpt-4o-2024-08-06": 128000, + "gpt-4o-2024-05-13": 128000, + "gpt-4o-mini": 128000, + "gpt-4o-mini-2024-07-18": 128000, + "gpt-4-turbo": 128000, + "gpt-4-1106-preview": 128000, + "gpt-4-turbo-preview": 128000, + "gpt-4": 8192, + "gpt-4-32k": 32000, + "gpt-4.1": 1047576, + "gpt-4.1-2025-04-14": 1047576, + "gpt-4.1-mini": 1047576, + "gpt-4.1-mini-2025-04-14": 1047576, + "gpt-4.1-nano": 1047576, + "gpt-4.1-nano-2025-04-14": 1047576, + "gpt-4.5-preview": 128000, + "gpt-4.5-preview-2025-02-27": 128000, + "o1-preview": 128000, + "o1-preview-2024-09-12": 128000, + "o1-mini": 128000, + "o1-mini-2024-09-12": 128000, + o1: 200000, + "o1-2024-12-17": 200000, + "o1-pro": 200000, + "o1-pro-2025-03-19": 200000, + "o3-mini": 200000, + "o3-mini-2025-01-31": 200000, + }, + deepseek: { + "deepseek-chat": 128000, + "deepseek-coder": 128000, + "deepseek-reasoner": 128000, + }, + minimax: { + "MiniMax-M2.7": 196000, + "MiniMax-M2.7-highspeed": 196000, + "MiniMax-M2.5": 196000, + "MiniMax-M2.5-highspeed": 196000, + "MiniMax-M2.1": 196000, + "MiniMax-M2.1-highspeed": 196000, + "MiniMax-M2": 196000, + }, + cerebras: { + "llama-3.3-70b": 128000, + "llama3.1-8b": 128000, + "gpt-oss-120b": 131072, + "qwen-3-32b": 128000, + "zai-glm-4.6": 128000, + }, + xai: { + "grok-beta": 131072, + }, + giteeai: { + "Qwen2.5-72B-Instruct": 16_384, + "Qwen2.5-14B-Instruct": 24_576, + "Qwen2-7B-Instruct": 24_576, + "Qwen2.5-32B-Instruct": 32_768, + "Qwen2-72B-Instruct": 32_768, + "Qwen2-VL-72B": 32_768, + "QwQ-32B-Preview": 32_768, + "Yi-34B-Chat": 4_096, + "glm-4-9b-chat": 32_768, + "deepseek-coder-33B-instruct": 8_192, + "codegeex4-all-9b": 32_768, + "InternVL2-8B": 32_768, + "InternVL2.5-26B": 32_768, + "InternVL2.5-78B": 32_768, + "DeepSeek-R1-Distill-Qwen-32B": 32_768, + "DeepSeek-R1-Distill-Qwen-1.5B": 32_768, + "DeepSeek-R1-Distill-Qwen-14B": 32_768, + "DeepSeek-R1-Distill-Qwen-7B": 32_768, + "DeepSeek-V3": 32_768, + "DeepSeek-R1": 32_768, + }, +}; +module.exports = LEGACY_MODEL_MAP; diff --git a/server/utils/AiProviders/modelRouter/index.js b/server/utils/AiProviders/modelRouter/index.js new file mode 100644 index 0000000..d54d3d9 --- /dev/null +++ b/server/utils/AiProviders/modelRouter/index.js @@ -0,0 +1,170 @@ +const { ModelRouterService } = require("../../router"); +const { getLLMProvider } = require("../../helpers"); + +class AnythingLLMModelRouter { + constructor(workspace, embedder = null) { + this.className = "AnythingLLMModelRouter"; + this.workspace = workspace; + this.embedder = embedder; + this.routerService = ModelRouterService.getInstance(); + this.router = null; + this.resolvedRoute = null; + this._routeKey = null; + this.delegateProvider = null; + this.defaultTemp = 0.7; + this.routerService.log( + `Initialized for workspace "${workspace?.name || workspace?.slug}"` + ); + } + + /** + * Resolve the route and instantiate the delegate LLM provider. + * Must be called before any chat methods. + * + * Flow: + * 1. Evaluate calculated rules (always — they're free) + * 2. Evaluate LLM rules (uses cache to avoid expensive calls) + * 3. If nothing matched, use the sticky route (previous model stays) + * 4. If sticky expired, fall back to the default model + * + * @param {Object} context - { prompt, conversationHistory, conversationTokenCount } + * @param {Object} opts - { user, thread } + */ + async resolve(context = {}, { user = null, thread = null } = {}) { + this.router = await this.routerService.resolveRouterForWorkspace( + this.workspace + ); + if (!this.router) + throw new Error("No model router found for this workspace."); + + const rules = this.router.rules || []; + const stickyMs = (this.router.cooldown_seconds ?? 300) * 1000; + this._routeKey = this.routerService.routeCacheKey( + user?.id, + this.workspace.slug, + thread?.slug + ); + + this.routerService.logRoutingContext(this.router, rules, context); + + // Step 1: Calculated rules (always re-evaluated, they're instant) + const calcResult = this.routerService.evaluateCalculatedRules( + rules, + context + ); + if (calcResult) { + this.resolvedRoute = calcResult; + this.routerService.setStickyRoute(this._routeKey, calcResult); + this.#finalize(); + return; + } + + // Step 2: LLM rules (cached to avoid expensive re-classification) + const { route: llmResult } = await this.routerService.evaluateLLMRules( + this._routeKey, + rules, + context, + this.router, + stickyMs + ); + if (llmResult) { + this.resolvedRoute = llmResult; + this.routerService.setStickyRoute(this._routeKey, llmResult); + this.#finalize(); + return; + } + + // Step 3: No rule matched — check sticky route + const sticky = this.routerService.getStickyRoute(this._routeKey, stickyMs); + if (sticky) { + this.resolvedRoute = sticky; + this.routerService.log( + `No rules matched → Sticky route active: ${sticky.provider}/${sticky.model} (rule: ${sticky.ruleTitle || "unknown"})` + ); + this.#finalize(); + return; + } + + // Step 4: Sticky expired — use fallback + this.resolvedRoute = { + provider: this.router.fallback_provider, + model: this.router.fallback_model, + ruleTitle: null, + ruleType: null, + isFallback: true, + }; + this.routerService.log( + `No rules matched, sticky expired → Fallback: ${this.router.fallback_provider}/${this.router.fallback_model}` + ); + this.#finalize(); + } + + #finalize() { + this.delegateProvider = getLLMProvider({ + provider: this.resolvedRoute.provider, + model: this.resolvedRoute.model, + }); + } + + get routingMetadata() { + if (!this.resolvedRoute) return null; + return { + routedTo: { + provider: this.resolvedRoute.provider, + model: this.resolvedRoute.model, + ruleTitle: this.resolvedRoute.ruleTitle, + ruleType: this.resolvedRoute.ruleType, + isFallback: this.resolvedRoute.isFallback, + shouldNotify: this.routerService.shouldNotify( + this._routeKey, + this.resolvedRoute + ), + routerName: this.router?.name, + fallbackProvider: this.router?.fallback_provider, + fallbackModel: this.router?.fallback_model, + }, + }; + } + + streamingEnabled() { + return this.delegateProvider?.streamingEnabled?.() ?? false; + } + + promptWindowLimit() { + return this.delegateProvider?.promptWindowLimit?.() ?? 4096; + } + + async isValidChatCompletionModel() { + return true; + } + + async constructPrompt(args) { + return this.delegateProvider.constructPrompt(args); + } + + async getChatCompletion(messages, opts = {}) { + return this.delegateProvider.getChatCompletion(messages, opts); + } + + async streamGetChatCompletion(messages, opts = {}) { + return this.delegateProvider.streamGetChatCompletion(messages, opts); + } + + async handleStream(response, stream, opts = {}) { + return this.delegateProvider.handleStream(response, stream, opts); + } + + async embedTextInput(textInput) { + return this.delegateProvider.embedTextInput(textInput); + } + + async embedChunks(textChunks) { + return this.delegateProvider.embedChunks(textChunks); + } + + async compressMessages(promptArgs, rawHistory) { + return this.delegateProvider.compressMessages(promptArgs, rawHistory); + } +} + +module.exports = { AnythingLLMModelRouter }; diff --git a/server/utils/AiProviders/moonshotAi/index.js b/server/utils/AiProviders/moonshotAi/index.js new file mode 100644 index 0000000..17e624f --- /dev/null +++ b/server/utils/AiProviders/moonshotAi/index.js @@ -0,0 +1,176 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class MoonshotAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.MOONSHOT_AI_API_KEY) + throw new Error("No Moonshot AI API key was set."); + this.className = "MoonshotAiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + baseURL: "https://api.moonshot.ai/v1", + apiKey: process.env.MOONSHOT_AI_API_KEY, + }); + this.model = + modelPreference || + process.env.MOONSHOT_AI_MODEL_PREF || + "moonshot-v1-32k"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + streamingEnabled() { + return true; + } + + promptWindowLimit() { + return MODEL_MAP.get("moonshot", this.model) ?? 8_192; + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !Object.prototype.hasOwnProperty.call(result.output, "choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } +} + +module.exports = { MoonshotAiLLM }; diff --git a/server/utils/AiProviders/novita/index.js b/server/utils/AiProviders/novita/index.js new file mode 100644 index 0000000..d2fb62d --- /dev/null +++ b/server/utils/AiProviders/novita/index.js @@ -0,0 +1,460 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { v4: uuidv4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "novita") + : path.resolve(__dirname, `../../../storage/models/novita`) +); + +class NovitaLLM { + defaultTimeout = 3_000; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.NOVITA_LLM_API_KEY) + throw new Error("No Novita API key was set."); + + this.className = "NovitaLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = "https://api.novita.ai/v3/openai"; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.NOVITA_LLM_API_KEY ?? null, + defaultHeaders: { + "HTTP-Referer": "https://anythingllm.com", + "X-Novita-Source": "anythingllm", + }, + }); + this.model = + modelPreference || + process.env.NOVITA_LLM_MODEL_PREF || + "deepseek/deepseek-r1"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.timeout = this.#parseTimeout(); + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + + this.log(`Loaded with model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * Novita has various models that never return `finish_reasons` and thus leave the stream open + * which causes issues in subsequent messages. This timeout value forces us to close the stream after + * x milliseconds. This is a configurable value via the NOVITA_LLM_TIMEOUT_MS value + * @returns {number} The timeout value in milliseconds (default: 3_000) + */ + #parseTimeout() { + this.log( + `Novita timeout is set to ${process.env.NOVITA_LLM_TIMEOUT_MS ?? this.defaultTimeout}ms` + ); + if (isNaN(Number(process.env.NOVITA_LLM_TIMEOUT_MS))) + return this.defaultTimeout; + const setValue = Number(process.env.NOVITA_LLM_TIMEOUT_MS); + if (setValue < 500) return 500; // 500ms is the minimum timeout + return setValue; + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + #cacheIsStale() { + const MAX_STALE = 2.592e8; // 3 days in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + // The Novita model API has a lot of models, so we cache this locally in the directory + // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it. + // This might slow down the first request, but we need the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + // We used to have this as a chore, but given there is an API to get the info - this makes little sense. + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log("Model cache is not present or stale. Fetching from Novita API."); + await fetchNovitaModels(); + return; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const availableModels = fs.existsSync(cacheModelPath) + ? safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + {} + ) + : {}; + return availableModels[modelName]?.maxLength || 4096; + } + + promptWindowLimit() { + const availableModels = this.models(); + return availableModels[this.model]?.maxLength || 4096; + } + + /** + * Get the capabilities of a model from the Novita API. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + await this.#syncModels(); + const availableModels = this.models(); + const modelInfo = availableModels[this.model]; + return { + tools: modelInfo.features.includes("function-calling"), + reasoning: modelInfo.features.includes("reasoning"), + imageGeneration: false, // no image generation capabilities for Novita yet. + vision: modelInfo.input_modalities.includes("image"), + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.models(); + return availableModels.hasOwnProperty(model); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Novita chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Novita chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + /** + * Handles the default stream response for a chat. + * @param {import("express").Response} response + * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream + * @param {Object} responseProps + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + const timeoutThresholdMs = this.timeout; + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + let lastChunkTime = null; // null when first token is still not received. + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + // NOTICE: Not all Novita models will return a stop reason + // which keeps the connection open and so the model never finalizes the stream + // like the traditional OpenAI response schema does. So in the case the response stream + // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with + // no new chunks then we kill the stream and assume it to be complete. Novita is quite fast + // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if + // we find it is too aggressive. + const timeoutCheck = setInterval(() => { + if (lastChunkTime === null) return; + + const now = Number(new Date()); + const diffMs = now - lastChunkTime; + if (diffMs >= timeoutThresholdMs) { + this.log( + `Novita stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.` + ); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + clearInterval(timeoutCheck); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }, 500); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + lastChunkTime = Number(new Date()); + + if (token) { + fullText += token; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + if (message?.finish_reason !== null) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + } + } catch (e) { + writeResponseChunk(response, { + uuid, + sources, + type: "abort", + textResponse: null, + close: true, + error: e.message, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement({ + completion_tokens: LLMPerformanceMonitor.countTokens(fullText), + }); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function fetchNovitaModels() { + return await fetch(`https://api.novita.ai/v3/openai/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + data.forEach((model) => { + models[model.id] = { + id: model.id, + name: model.title, + organization: + model.id.split("/")[0].charAt(0).toUpperCase() + + model.id.split("/")[0].slice(1), + maxLength: model.context_size, + features: model.features ?? [], + input_modalities: model.input_modalities ?? [], + }; + }); + + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(models), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + return models; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + NovitaLLM, + fetchNovitaModels, +}; diff --git a/server/utils/AiProviders/nvidiaNim/index.js b/server/utils/AiProviders/nvidiaNim/index.js new file mode 100644 index 0000000..46b113d --- /dev/null +++ b/server/utils/AiProviders/nvidiaNim/index.js @@ -0,0 +1,253 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); + +class NvidiaNimLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.NVIDIA_NIM_LLM_BASE_PATH) + throw new Error("No NVIDIA NIM API Base Path was set."); + + this.className = "NvidiaNimLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.nvidiaNim = new OpenAIApi({ + baseURL: parseNvidiaNimBasePath(process.env.NVIDIA_NIM_LLM_BASE_PATH), + apiKey: null, + }); + + this.model = modelPreference || process.env.NVIDIA_NIM_LLM_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.#log( + `Loaded with model: ${this.model} with context window: ${this.promptWindowLimit()}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + /** + * Set the model token limit `NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT` for the given model ID + * @param {string} modelId + * @param {string} basePath + * @returns {Promise} + */ + static async setModelTokenLimit(modelId, basePath = null) { + if (!modelId) return; + const { OpenAI: OpenAIApi } = require("openai"); + const openai = new OpenAIApi({ + baseURL: parseNvidiaNimBasePath( + basePath || process.env.NVIDIA_NIM_LLM_BASE_PATH + ), + apiKey: null, + }); + const model = await openai.models + .list() + .then((results) => results.data) + .catch(() => { + return []; + }); + + if (!model.length) return; + const modelInfo = model.find((model) => model.id === modelId); + if (!modelInfo) return; + process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT = Number( + modelInfo.max_model_len || 4096 + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No NVIDIA NIM token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.NVIDIA_NIM_LLM_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No NVIDIA NIM token context limit was set."); + return Number(limit); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `NVIDIA NIM chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.nvidiaNim.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `NVIDIA NIM chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.nvidiaNim.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +/** + * Parse the base path for the Nvidia NIM container API. Since the base path must end in /v1 and cannot have a trailing slash, + * and the user can possibly set it to anything and likely incorrectly due to pasting behaviors, we need to ensure it is in the correct format. + * @param {string} basePath + * @returns {string} + */ +function parseNvidiaNimBasePath(providedBasePath = "") { + try { + const baseURL = new URL(providedBasePath); + const basePath = `${baseURL.origin}/v1`; + return basePath; + } catch { + return providedBasePath; + } +} + +module.exports = { + NvidiaNimLLM, + parseNvidiaNimBasePath, +}; diff --git a/server/utils/AiProviders/ollama/index.js b/server/utils/AiProviders/ollama/index.js new file mode 100644 index 0000000..62e899c --- /dev/null +++ b/server/utils/AiProviders/ollama/index.js @@ -0,0 +1,516 @@ +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { Ollama } = require("ollama"); +const { v4: uuidv4 } = require("uuid"); + +// Docs: https://github.com/jmorganca/ollama/blob/main/docs/api.md +class OllamaAILLM { + /** @see OllamaAILLM.cacheContextWindows */ + static modelContextWindows = {}; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.OLLAMA_BASE_PATH) + throw new Error("No Ollama Base Path was set."); + + this.className = "OllamaAILLM"; + this.authToken = process.env.OLLAMA_AUTH_TOKEN; + this.basePath = process.env.OLLAMA_BASE_PATH; + this.model = modelPreference || process.env.OLLAMA_MODEL_PREF; + this.keepAlive = process.env.OLLAMA_KEEP_ALIVE_TIMEOUT + ? Number(process.env.OLLAMA_KEEP_ALIVE_TIMEOUT) + : 300; // Default 5-minute timeout for Ollama model loading. + + const headers = this.authToken + ? { Authorization: `Bearer ${this.authToken}` } + : {}; + this.client = new Ollama({ + host: this.basePath, + headers: headers, + fetch: OllamaAILLM.applyOllamaFetch(), + }); + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + // Lazy load the limits to avoid blocking the main thread on cacheContextWindows + this.limits = null; + + OllamaAILLM.cacheContextWindows(true); + this.#log(`initialized with model: ${this.model}`); + } + + #log(text, ...args) { + console.log(`\x1b[32m[Ollama]\x1b[0m ${text}`, ...args); + } + + static #slog(text, ...args) { + console.log(`\x1b[32m[Ollama]\x1b[0m ${text}`, ...args); + } + + async assertModelContextLimits() { + if (this.limits !== null) return; + await OllamaAILLM.cacheContextWindows(); + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + this.#log( + `model ${this.model} is using a max context window of ${this.promptWindowLimit()}/${OllamaAILLM.maxContextWindow(this.model)} tokens.` + ); + } + + /** + * Cache the context windows for the Ollama models. + * This is done once and then cached for the lifetime of the server. This is absolutely necessary to ensure that the context windows are correct. + * + * This is a convenience to ensure that the context windows are correct and that the user + * does not have to manually set the context window for each model. + * @param {boolean} force - Force the cache to be refreshed. + * @returns {Promise} - A promise that resolves when the cache is refreshed. + */ + static async cacheContextWindows(force = false) { + try { + // Skip if we already have cached context windows and we're not forcing a refresh + if (Object.keys(OllamaAILLM.modelContextWindows).length > 0 && !force) + return; + + const authToken = process.env.OLLAMA_AUTH_TOKEN; + const basePath = process.env.OLLAMA_BASE_PATH; + const client = new Ollama({ + host: basePath, + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); + + const { models } = await client.list().catch(() => ({ models: [] })); + if (!models.length) return; + + const infoPromises = models.map((model) => + client + .show({ model: model.name }) + .then((info) => ({ name: model.name, ...info })) + ); + const infos = await Promise.all(infoPromises); + infos.forEach((showInfo) => { + if (showInfo.capabilities.includes("embedding")) return; + const contextWindowKey = Object.keys(showInfo.model_info).find((key) => + key.endsWith(".context_length") + ); + if (!contextWindowKey) + return (OllamaAILLM.modelContextWindows[showInfo.name] = 4096); + OllamaAILLM.modelContextWindows[showInfo.name] = + showInfo.model_info[contextWindowKey]; + }); + OllamaAILLM.#slog(`Context windows cached for all models!`); + } catch (e) { + OllamaAILLM.#slog(`Error caching context windows`, e); + return; + } + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + /** + * Apply a custom fetch function to the Ollama client. + * This is useful when we want to bypass the default 5m timeout for global fetch + * for machines which run responses very slowly. + * @returns {Function} The custom fetch function. + */ + static applyOllamaFetch() { + try { + if (!("OLLAMA_RESPONSE_TIMEOUT" in process.env)) return fetch; + const { Agent } = require("undici"); + const moment = require("moment"); + let timeout = process.env.OLLAMA_RESPONSE_TIMEOUT; + + if (!timeout || isNaN(Number(timeout)) || Number(timeout) <= 5 * 60_000) { + OllamaAILLM.#slog( + "Timeout option was not set, is not a number, or is less than 5 minutes in ms - falling back to default", + { timeout } + ); + return fetch; + } else timeout = Number(timeout); + + const noTimeoutFetch = (input, init = {}) => { + return fetch(input, { + ...init, + dispatcher: new Agent({ headersTimeout: timeout }), + }); + }; + + const humanDiff = moment.duration(timeout).humanize(); + OllamaAILLM.#slog(`Applying custom fetch w/timeout of ${humanDiff}.`); + return noTimeoutFetch; + } catch (error) { + OllamaAILLM.#slog( + "Error applying custom fetch - using default fetch", + error + ); + return fetch; + } + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + if (Object.keys(OllamaAILLM.modelContextWindows).length === 0) { + this.#slog( + "No context windows cached - Context window may be inaccurately reported." + ); + return Number(process.env.OLLAMA_MODEL_TOKEN_LIMIT) || 4096; + } + + let userDefinedLimit = null; + const systemDefinedLimit = OllamaAILLM.maxContextWindow(modelName); + + if ( + process.env.OLLAMA_MODEL_TOKEN_LIMIT && + !isNaN(Number(process.env.OLLAMA_MODEL_TOKEN_LIMIT)) && + Number(process.env.OLLAMA_MODEL_TOKEN_LIMIT) > 0 + ) + userDefinedLimit = Number(process.env.OLLAMA_MODEL_TOKEN_LIMIT); + + // The user defined limit is always higher priority than the context window limit, but it cannot be higher than the context window limit + // so we return the minimum of the two, if there is no user defined limit, we return the system defined limit as-is. + if (userDefinedLimit !== null) + return Math.min(userDefinedLimit, systemDefinedLimit); + + // Cap the context window limit to 16,384 tokens if the model supports more than that and no value is specified by the user. + // This prevents super-large context windows from being used if the user does not specify a value + // as well as also having smaller context windows use the full context window limit. + return Math.min(systemDefinedLimit, 16384); + } + + promptWindowLimit() { + return this.constructor.promptWindowLimit(this.model); + } + + static maxContextWindow(modelName = null) { + if (Object.keys(OllamaAILLM.modelContextWindows).length === 0 || !modelName) + return 4096; + return Number(OllamaAILLM.modelContextWindows[modelName]) || 16384; + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {{content: string, images: string[]}} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return { content: userPrompt }; + const images = attachments.map( + (attachment) => attachment.contentString.split("base64,").slice(-1)[0] + ); + return { content: userPrompt, images }; + } + + /** + * Handles errors from the Ollama API to make them more user friendly. + * @param {Error} e + */ + #errorHandler(e) { + switch (e.message) { + case "fetch failed": + throw new Error( + "Your Ollama instance could not be reached or is not responding. Please make sure it is running the API server and your connection information is correct in AnythingLLM." + ); + default: + return e; + } + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent, "spread"), + { + role: "user", + ...this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.client + .chat({ + model: this.model, + stream: false, + messages, + keep_alive: this.keepAlive, + options: { + temperature, + num_ctx: this.promptWindowLimit(), + }, + }) + .then((res) => { + let content = res.message.content; + if (res.message.thinking) + content = `${res.message.thinking}${content}`; + return { + content, + usage: { + prompt_tokens: res.prompt_eval_count, + completion_tokens: res.eval_count, + total_tokens: res.prompt_eval_count + res.eval_count, + duration: res.eval_duration / 1e9, + }, + }; + }) + .catch((e) => { + throw new Error( + `Ollama::getChatCompletion failed to communicate with Ollama. ${this.#errorHandler(e).message}` + ); + }) + ); + + if (!result.output.content || !result.output.content.length) + throw new Error(`Ollama::getChatCompletion text response was empty.`); + + return { + textResponse: result.output.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens, + completion_tokens: result.output.usage.completion_tokens, + total_tokens: result.output.usage.total_tokens, + outputTps: + result.output.usage.completion_tokens / result.output.usage.duration, + duration: result.output.usage.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.client.chat({ + model: this.model, + stream: true, + messages, + keep_alive: this.keepAlive, + options: { + temperature, + num_ctx: this.promptWindowLimit(), + }, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }).catch((e) => { + throw this.#errorHandler(e); + }); + return measuredStreamRequest; + } + + /** + * Handles streaming responses from Ollama. + * @param {import("express").Response} response + * @param {import("../../helpers/chat/LLMPerformanceMonitor").MonitoredStream} stream + * @param {import("express").Request} request + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + + return new Promise(async (resolve) => { + let fullText = ""; + let reasoningText = ""; + let usage = { + prompt_tokens: 0, + completion_tokens: 0, + }; + + // Establish listener to early-abort a streaming response + // in case things go sideways or the user does not like the response. + // We preserve the generated text but continue as if chat was completed + // to preserve previously generated content. + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + if (chunk === undefined) + throw new Error( + "Stream returned undefined chunk. Aborting reply - check model provider logs." + ); + + if (chunk.done) { + usage.prompt_tokens = chunk.prompt_eval_count; + usage.completion_tokens = chunk.eval_count; + usage.duration = chunk.eval_duration / 1e9; + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + break; + } + + if (chunk.hasOwnProperty("message")) { + // As of Ollama v0.9.0+, thinking content comes in a separate property + // in the response object. If it exists, we need to handle it separately by wrapping it in tags. + const content = chunk.message.content; + const reasoningToken = chunk.message.thinking; + + if (reasoningToken) { + if (reasoningText.length === 0) { + const startTag = ""; + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: startTag + reasoningToken, + close: false, + error: false, + }); + reasoningText += startTag + reasoningToken; + } else { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: reasoningToken, + close: false, + error: false, + }); + reasoningText += reasoningToken; + } + } else if (content.length > 0) { + // If we have reasoning text, we need to close the reasoning tag and then append the content. + if (reasoningText.length > 0) { + const endTag = ""; + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: endTag, + close: false, + error: false, + }); + fullText += reasoningText + endTag; + reasoningText = ""; // Reset reasoning buffer + } + fullText += content; // Append regular text + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: content, + close: false, + error: false, + }); + } + } + } + } catch (error) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: "", + close: true, + error: `Ollama:streaming - could not stream chat. ${ + error?.cause ?? error.message + }`, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + } + + /** + * Returns the capabilities of the model. + * @returns {Promise<{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}>} + */ + async getModelCapabilities() { + try { + const { capabilities = [] } = await this.client.show({ + model: this.model, + }); + return { + tools: capabilities.includes("tools") ? true : false, + reasoning: capabilities.includes("thinking") ? true : false, + imageGeneration: false, // we dont have any image generation capabilities for Ollama or anywhere right now. + vision: capabilities.includes("vision") ? true : false, + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + await this.assertModelContextLimits(); + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + OllamaAILLM, +}; diff --git a/server/utils/AiProviders/openAi/index.js b/server/utils/AiProviders/openAi/index.js new file mode 100644 index 0000000..fcbad87 --- /dev/null +++ b/server/utils/AiProviders/openAi/index.js @@ -0,0 +1,302 @@ +const { v4: uuidv4 } = require("uuid"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + formatChatHistory, + writeResponseChunk, + clientAbortedHandler, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +class OpenAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set."); + this.className = "OpenAiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + apiKey: process.env.OPEN_AI_KEY, + }); + this.model = + modelPreference || process.env.OPEN_MODEL_PREF || "gpt-4.1-nano"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("openai", modelName) ?? 4_096; + } + + promptWindowLimit() { + return MODEL_MAP.get("openai", this.model) ?? 4_096; + } + + // Short circuit if name has 'gpt' since we now fetch models from OpenAI API + // via the user API key, so the model must be relevant and real. + // and if somehow it is not, chat will fail but that is caught. + // we don't want to hit the OpenAI api every chat because it will get spammed + // and introduce latency for no reason. + async isValidChatCompletionModel(modelName = "") { + const isPreset = + modelName.toLowerCase().includes("gpt") || + modelName.toLowerCase().startsWith("o"); + if (isPreset) return true; + + const model = await this.openai.models + .retrieve(modelName) + .then((modelObj) => modelObj) + .catch(() => null); + return !!model; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "input_text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "input_image", + image_url: attachment.contentString, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + /** + * Determine the appropriate temperature for the model. + * @param {string} modelName + * @param {number} temperature + * @returns {number} + */ + #temperature(modelName, temperature) { + // For models that don't support temperature + // OpenAI accepts temperature 1 + const NO_TEMP_MODELS = ["o", "gpt-5"]; + + if (NO_TEMP_MODELS.some((prefix) => modelName.startsWith(prefix))) { + return 1; + } + + return temperature; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `OpenAI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.responses + .create({ + model: this.model, + input: messages, + store: false, + temperature: this.#temperature(this.model, temperature), + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if (!result.output.hasOwnProperty("output_text")) return null; + + const usage = result.output.usage || {}; + return { + textResponse: result.output.output_text, + metrics: { + prompt_tokens: usage.input_tokens || 0, + completion_tokens: usage.output_tokens || 0, + total_tokens: usage.total_tokens || 0, + outputTps: usage.output_tokens + ? usage.output_tokens / result.duration + : 0, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `OpenAI chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.responses.create({ + model: this.model, + stream: true, + input: messages, + store: false, + temperature: this.#temperature(this.model, temperature), + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + + let hasUsageMetrics = false; + let usage = { + completion_tokens: 0, + }; + + return new Promise(async (resolve) => { + let fullText = ""; + + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + if (chunk.type === "response.output_text.delta") { + const token = chunk.delta; + if (token) { + fullText += token; + if (!hasUsageMetrics) usage.completion_tokens++; + + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + } else if (chunk.type === "response.completed") { + const { response: res } = chunk; + if (res.hasOwnProperty("usage") && !!res.usage) { + hasUsageMetrics = true; + usage = { + ...usage, + prompt_tokens: res.usage?.input_tokens || 0, + completion_tokens: res.usage?.output_tokens || 0, + total_tokens: res.usage?.total_tokens || 0, + }; + } + + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + break; + } + } + } catch (e) { + console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`); + writeResponseChunk(response, { + uuid, + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + OpenAiLLM, +}; diff --git a/server/utils/AiProviders/openRouter/index.js b/server/utils/AiProviders/openRouter/index.js new file mode 100644 index 0000000..28d4306 --- /dev/null +++ b/server/utils/AiProviders/openRouter/index.js @@ -0,0 +1,567 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { v4: uuidv4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "openrouter") + : path.resolve(__dirname, `../../../storage/models/openrouter`) +); + +class OpenRouterLLM { + /** + * Some openrouter models never send a finish_reason and thus leave the stream open in the UI. + * However, because OR is a middleware it can also wait an inordinately long time between chunks so we need + * to ensure that we dont accidentally close the stream too early. If the time between chunks is greater than this timeout + * we will close the stream and assume it to be complete. This is common for free models or slow providers they can + * possibly delegate to during invocation. + * @type {number} + */ + defaultTimeout = 3_000; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.OPENROUTER_API_KEY) + throw new Error("No OpenRouter API key was set."); + + this.className = "OpenRouterLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = "https://openrouter.ai/api/v1"; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.OPENROUTER_API_KEY ?? null, + defaultHeaders: { + "HTTP-Referer": "https://anythingllm.com", + "X-Title": "AnythingLLM", + }, + }); + this.model = + modelPreference || process.env.OPENROUTER_MODEL_PREF || "openrouter/auto"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.timeout = this.#parseTimeout(); + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + this.log("Initialized with model:", this.model); + } + + /** + * Returns true if the model is a Perplexity model. + * OpenRouter has support for a lot of models and we have some special handling for Perplexity models + * that support in-line citations. + * @returns {boolean} + */ + get isPerplexityModel() { + return this.model.startsWith("perplexity/"); + } + + /** + * Generic formatting of a token for the following use cases: + * - Perplexity models that return inline citations in the token text + * @param {{token: string, citations: string[]}} options - The token text and citations. + * @returns {string} - The formatted token text. + */ + enrichToken({ token, citations = [] }) { + if (!Array.isArray(citations) || citations.length === 0) return token; + return token.replace(/\[(\d+)\]/g, (match, index) => { + const citationIndex = parseInt(index) - 1; + return citations[citationIndex] + ? `[[${index}](${citations[citationIndex]})]` + : match; + }); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * OpenRouter has various models that never return `finish_reasons` and thus leave the stream open + * which causes issues in subsequent messages. This timeout value forces us to close the stream after + * x milliseconds. This is a configurable value via the OPENROUTER_TIMEOUT_MS value + * @returns {number} The timeout value in milliseconds (default: 3_000) + */ + #parseTimeout() { + this.log( + `OpenRouter timeout is set to ${process.env.OPENROUTER_TIMEOUT_MS ?? this.defaultTimeout}ms` + ); + if (isNaN(Number(process.env.OPENROUTER_TIMEOUT_MS))) + return this.defaultTimeout; + const setValue = Number(process.env.OPENROUTER_TIMEOUT_MS); + if (setValue < 500) return 500; // 500ms is the minimum timeout + return setValue; + } + + // This checks if the .cached_at file has a timestamp that is more than 1Week (in millis) + // from the current date. If it is, then we will refetch the API so that all the models are up + // to date. + #cacheIsStale() { + const MAX_STALE = 6.048e8; // 1 Week in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + // The OpenRouter model API has a lot of models, so we cache this locally in the directory + // as if the cache directory JSON file is stale or does not exist we will fetch from API and store it. + // This might slow down the first request, but we need the proper token context window + // for each model and this is a constructor property - so we can really only get it if this cache exists. + // We used to have this as a chore, but given there is an API to get the info - this makes little sense. + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log( + "Model cache is not present or stale. Fetching from OpenRouter API." + ); + await fetchOpenRouterModels(); + return; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const availableModels = fs.existsSync(cacheModelPath) + ? safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + {} + ) + : {}; + return availableModels[modelName]?.maxLength || 4096; + } + + promptWindowLimit() { + const availableModels = this.models(); + return availableModels[this.model]?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.models(); + return availableModels.hasOwnProperty(model); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Parses and prepends reasoning from the response and returns the full text response. + * @param {Object} response + * @returns {string} + */ + #parseReasoningFromResponse({ message }) { + let textResponse = message?.content; + if (!!message?.reasoning && message.reasoning.trim().length > 0) + textResponse = `${message.reasoning}${textResponse}`; + return textResponse; + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7, user = null }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `OpenRouter chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + // This is an OpenRouter specific option that allows us to get the reasoning text + // before the token text. + include_reasoning: true, + user: user?.id ? `user_${user.id}` : "", + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result?.output?.hasOwnProperty("choices") || + result?.output?.choices?.length === 0 + ) + throw new Error( + `Invalid response body returned from OpenRouter: ${result.output?.error?.message || "Unknown error"} ${result.output?.error?.code || "Unknown code"}` + ); + + return { + textResponse: this.#parseReasoningFromResponse(result.output.choices[0]), + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion( + messages = null, + { temperature = 0.7, user = null } + ) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `OpenRouter chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + // This is an OpenRouter specific option that allows us to get the reasoning text + // before the token text. + include_reasoning: true, + user: user?.id ? `user_${user.id}` : "", + }), + messages, + // OpenRouter returns the usage in the stream as the very last chunk **after** the finish reason. + // so we don't need to run the prompt token calculation. + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + /** + * Handles the default stream response for a chat. + * - Handle weird OR timeout behavior where the stream never self-closes. + * - Handle the usage metrics being returned in the stream as the very last chunk **after** the finish reason. + * @param {import("express").Response} response + * @param {import('../../helpers/chat/LLMPerformanceMonitor').MonitoredStream} stream + * @param {Object} responseProps + * @returns {Promise} + */ + handleStream(response, stream, responseProps) { + const timeoutThresholdMs = this.timeout; + const { uuid = uuidv4(), sources = [] } = responseProps; + let hasUsageMetrics = false; + let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + + return new Promise(async (resolve) => { + let fullText = ""; + let reasoningText = ""; + let lastChunkTime = null; // null when first token is still not received. + let pplxCitations = []; // Array of inline citations for Perplexity models (if applicable) + let isPerplexity = this.isPerplexityModel; + + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + // NOTICE: Not all OpenRouter models will return a stop reason + // which keeps the connection open and so the model never finalizes the stream + // like the traditional OpenAI response schema does. So in the case the response stream + // never reaches a formal close state we maintain an interval timer that if we go >=timeoutThresholdMs with + // no new chunks then we kill the stream and assume it to be complete. OpenRouter is quite fast + // so this threshold should permit most responses, but we can adjust `timeoutThresholdMs` if + // we find it is too aggressive. + const timeoutCheck = setInterval(() => { + if (lastChunkTime === null) return; + + const now = Number(new Date()); + const diffMs = now - lastChunkTime; + + if (diffMs >= timeoutThresholdMs) { + console.log( + `OpenRouter stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.` + ); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + clearInterval(timeoutCheck); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + } + }, 500); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + const reasoningToken = message?.delta?.reasoning; + lastChunkTime = Number(new Date()); + + if (chunk.hasOwnProperty("usage") && !hasUsageMetrics) { + hasUsageMetrics = true; + usage = { + prompt_tokens: chunk.usage.prompt_tokens, + completion_tokens: chunk.usage.completion_tokens, + total_tokens: chunk.usage.total_tokens, + }; + } + + // Some models will return citations (e.g. Perplexity) - we should preserve them for inline citations if applicable. + if ( + isPerplexity && + Array.isArray(chunk?.citations) && + chunk?.citations?.length !== 0 + ) + pplxCitations.push(...chunk.citations); + + // Reasoning models will always return the reasoning text before the token text. + // can be null or '' + if (reasoningToken) { + const formattedReasoningToken = this.enrichToken({ + token: reasoningToken, + citations: pplxCitations, + }); + + // If the reasoning text is empty (''), we need to initialize it + // and send the first chunk of reasoning text. + if (reasoningText.length === 0) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: `${formattedReasoningToken}`, + close: false, + error: false, + }); + reasoningText += `${formattedReasoningToken}`; + continue; + } else { + // If the reasoning text is not empty, we need to append the reasoning text + // to the existing reasoning text. + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: formattedReasoningToken, + close: false, + error: false, + }); + reasoningText += formattedReasoningToken; + } + } + + // If the reasoning text is not empty, but the reasoning token is empty + // and the token text is not empty we need to close the reasoning text and begin sending the token text. + if (!!reasoningText && !reasoningToken && token) { + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: ``, + close: false, + error: false, + }); + fullText += `${reasoningText}`; + reasoningText = ""; + } + + if (token) { + const formattedToken = this.enrichToken({ + token, + citations: pplxCitations, + }); + fullText += formattedToken; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: formattedToken, + close: false, + error: false, + }); + } + + if (message?.finish_reason) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + } + } + + // Stream completed naturally - resolve with final metrics + response.removeListener("close", handleAbort); + clearInterval(timeoutCheck); + stream?.endMeasurement(usage); + resolve(fullText); + } catch (e) { + writeResponseChunk(response, { + uuid, + sources, + type: "abort", + textResponse: null, + close: true, + error: e.message, + }); + response.removeListener("close", handleAbort); + clearInterval(timeoutCheck); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function fetchOpenRouterModels() { + return await fetch(`https://openrouter.ai/api/v1/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + data.forEach((model) => { + models[model.id] = { + id: model.id, + name: model.name, + organization: + model.id.split("/")[0].charAt(0).toUpperCase() + + model.id.split("/")[0].slice(1), + maxLength: model.context_length, + }; + }); + + // Cache all response information + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(models), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + + return models; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + OpenRouterLLM, + fetchOpenRouterModels, +}; diff --git a/server/utils/AiProviders/perplexity/index.js b/server/utils/AiProviders/perplexity/index.js new file mode 100644 index 0000000..2a1b4f2 --- /dev/null +++ b/server/utils/AiProviders/perplexity/index.js @@ -0,0 +1,304 @@ +const { v4: uuidv4 } = require("uuid"); +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + writeResponseChunk, + clientAbortedHandler, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +function perplexityModels() { + const { MODELS } = require("./models.js"); + return MODELS || {}; +} + +class PerplexityLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.PERPLEXITY_API_KEY) + throw new Error("No Perplexity API key was set."); + + this.className = "PerplexityLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.perplexity.ai", + apiKey: process.env.PERPLEXITY_API_KEY ?? null, + }); + this.model = + modelPreference || + process.env.PERPLEXITY_MODEL_PREF || + "llama-3-sonar-large-32k-online"; // Give at least a unique model to the provider as last fallback. + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + allModelInformation() { + return perplexityModels(); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + const availableModels = perplexityModels(); + return availableModels[modelName]?.maxLength || 4096; + } + + promptWindowLimit() { + const availableModels = this.allModelInformation(); + return availableModels[this.model]?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + const availableModels = this.allModelInformation(); + return availableModels.hasOwnProperty(model); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Perplexity chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `Perplexity chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + /** + * Enrich a token with citations if available for in-line citations. + * @param {string} token - The token to enrich. + * @param {Array} citations - The citations to enrich the token with. + * @returns {string} The enriched token. + */ + enrichToken(token, citations) { + if (!Array.isArray(citations) || citations.length === 0) return token; + return token.replace(/\[(\d+)\]/g, (match, index) => { + const citationIndex = parseInt(index) - 1; + return citations[citationIndex] + ? `[[${index}](${citations[citationIndex]})]` + : match; + }); + } + + handleStream(response, stream, responseProps) { + const timeoutThresholdMs = 800; + const { uuid = uuidv4(), sources = [] } = responseProps; + let hasUsageMetrics = false; + let pplxCitations = []; // Array of links + let usage = { + completion_tokens: 0, + }; + + return new Promise(async (resolve) => { + let fullText = ""; + let lastChunkTime = null; + + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + const timeoutCheck = setInterval(() => { + if (lastChunkTime === null) return; + + const now = Number(new Date()); + const diffMs = now - lastChunkTime; + if (diffMs >= timeoutThresholdMs) { + console.log( + `Perplexity stream did not self-close and has been stale for >${timeoutThresholdMs}ms. Closing response stream.` + ); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + clearInterval(timeoutCheck); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + } + }, 500); + + // Now handle the chunks from the streamed response and append to fullText. + try { + for await (const chunk of stream) { + lastChunkTime = Number(new Date()); + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + + if (Array.isArray(chunk.citations) && chunk.citations.length !== 0) { + pplxCitations = chunk.citations; + } + + // If we see usage metrics in the chunk, we can use them directly + // instead of estimating them, but we only want to assign values if + // the response object is the exact same key:value pair we expect. + if ( + chunk.hasOwnProperty("usage") && // exists + !!chunk.usage && // is not null + Object.values(chunk.usage).length > 0 // has values + ) { + if (chunk.usage.hasOwnProperty("prompt_tokens")) { + usage.prompt_tokens = Number(chunk.usage.prompt_tokens); + } + + if (chunk.usage.hasOwnProperty("completion_tokens")) { + hasUsageMetrics = true; // to stop estimating counter + usage.completion_tokens = Number(chunk.usage.completion_tokens); + } + } + + if (token) { + let enrichedToken = this.enrichToken(token, pplxCitations); + fullText += enrichedToken; + if (!hasUsageMetrics) usage.completion_tokens++; + + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: enrichedToken, + close: false, + error: false, + }); + } + + if (message?.finish_reason) { + console.log("closing"); + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + clearInterval(timeoutCheck); + resolve(fullText); + break; // Break streaming when a valid finish_reason is first encountered + } + } + } catch (e) { + console.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`); + writeResponseChunk(response, { + uuid, + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + stream?.endMeasurement(usage); + clearInterval(timeoutCheck); + resolve(fullText); // Return what we currently have - if anything. + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + PerplexityLLM, + perplexityModels, +}; diff --git a/server/utils/AiProviders/perplexity/models.js b/server/utils/AiProviders/perplexity/models.js new file mode 100644 index 0000000..f035ae2 --- /dev/null +++ b/server/utils/AiProviders/perplexity/models.js @@ -0,0 +1,24 @@ +const MODELS = { + "sonar-reasoning-pro": { + id: "sonar-reasoning-pro", + name: "sonar-reasoning-pro", + maxLength: 127072, + }, + "sonar-reasoning": { + id: "sonar-reasoning", + name: "sonar-reasoning", + maxLength: 127072, + }, + "sonar-pro": { + id: "sonar-pro", + name: "sonar-pro", + maxLength: 200000, + }, + sonar: { + id: "sonar", + name: "sonar", + maxLength: 127072, + }, +}; + +module.exports.MODELS = MODELS; diff --git a/server/utils/AiProviders/perplexity/scripts/.gitignore b/server/utils/AiProviders/perplexity/scripts/.gitignore new file mode 100644 index 0000000..94a2dd1 --- /dev/null +++ b/server/utils/AiProviders/perplexity/scripts/.gitignore @@ -0,0 +1 @@ +*.json \ No newline at end of file diff --git a/server/utils/AiProviders/perplexity/scripts/chat_models.txt b/server/utils/AiProviders/perplexity/scripts/chat_models.txt new file mode 100644 index 0000000..44ab9ac --- /dev/null +++ b/server/utils/AiProviders/perplexity/scripts/chat_models.txt @@ -0,0 +1,6 @@ +| Model | Parameter Count | Context Length | Model Type | +| :---------------------------------- | :-------------- | :------------- | :-------------- | +| `sonar-reasoning-pro` | 8B | 127,072 | Chat Completion | +| `sonar-reasoning` | 8B | 127,072 | Chat Completion | +| `sonar-pro` | 8B | 200,000 | Chat Completion | +| `sonar` | 8B | 127,072 | Chat Completion | \ No newline at end of file diff --git a/server/utils/AiProviders/perplexity/scripts/parse.mjs b/server/utils/AiProviders/perplexity/scripts/parse.mjs new file mode 100644 index 0000000..a5ba3af --- /dev/null +++ b/server/utils/AiProviders/perplexity/scripts/parse.mjs @@ -0,0 +1,49 @@ +// Perplexity does not provide a simple REST API to get models, +// so we have a table which we copy from their documentation +// https://docs.perplexity.ai/edit/model-cards that we can +// then parse and get all models from in a format that makes sense +// Why this does not exist is so bizarre, but whatever. + +// To run, cd into this directory and run `node parse.mjs` +// copy outputs into the export in ../models.js + +// Update the date below if you run this again because Perplexity added new models. +// Last Collected: Jan 23, 2025 + +// UPDATE: Jan 23, 2025 +// The table is no longer available on the website, but Perplexity has deprecated the +// old models so now we can just update the chat_models.txt file with the new models +// manually and then run this script to get the new models. + +import fs from "fs"; + +function parseChatModels() { + const models = {}; + const tableString = fs.readFileSync("chat_models.txt", { encoding: "utf-8" }); + const rows = tableString.split("\n").slice(2); + + rows.forEach((row) => { + let [model, _, contextLength] = row + .split("|") + .slice(1, -1) + .map((text) => text.trim()); + model = model.replace(/`|\s*\[\d+\]\s*/g, ""); + const maxLength = Number(contextLength.replace(/[^\d]/g, "")); + if (model && maxLength) { + models[model] = { + id: model, + name: model, + maxLength: maxLength, + }; + } + }); + + fs.writeFileSync( + "chat_models.json", + JSON.stringify(models, null, 2), + "utf-8" + ); + return models; +} + +parseChatModels(); diff --git a/server/utils/AiProviders/ppio/index.js b/server/utils/AiProviders/ppio/index.js new file mode 100644 index 0000000..f600525 --- /dev/null +++ b/server/utils/AiProviders/ppio/index.js @@ -0,0 +1,274 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "ppio") + : path.resolve(__dirname, `../../../storage/models/ppio`) +); + +class PPIOLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.PPIO_API_KEY) throw new Error("No PPIO API key was set."); + + this.className = "PPIOLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = "https://api.ppinfra.com/v3/openai/"; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.PPIO_API_KEY ?? null, + defaultHeaders: { + "HTTP-Referer": "https://anythingllm.com", + "X-API-Source": "anythingllm", + }, + }); + this.model = + modelPreference || + process.env.PPIO_MODEL_PREF || + "qwen/qwen2.5-32b-instruct"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + this.cacheModelPath = path.resolve(cacheFolder, "models.json"); + this.cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + + this.log(`Loaded with model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async #syncModels() { + if (fs.existsSync(this.cacheModelPath) && !this.#cacheIsStale()) + return false; + + this.log("Model cache is not present or stale. Fetching from PPIO API."); + await fetchPPIOModels(); + return; + } + + #cacheIsStale() { + const MAX_STALE = 6.048e8; // 1 Week in MS + if (!fs.existsSync(this.cacheAtPath)) return true; + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(this.cacheAtPath)); + return now - timestampMs > MAX_STALE; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + models() { + if (!fs.existsSync(this.cacheModelPath)) return {}; + return safeJsonParse( + fs.readFileSync(this.cacheModelPath, { encoding: "utf-8" }), + {} + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + promptWindowLimit() { + const model = this.models()[this.model]; + if (!model) return 4096; // Default to 4096 if we cannot find the model + return model?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + await this.#syncModels(); + const availableModels = this.models(); + return Object.prototype.hasOwnProperty.call(availableModels, model); + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + //eslint-disable-next-line + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + // attachments = [], - not supported + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [prompt, ...chatHistory, { role: "user", content: userPrompt }]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `PPIO chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !Object.prototype.hasOwnProperty.call(result.output, "choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `PPIO chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +async function fetchPPIOModels() { + return await fetch(`https://api.ppinfra.com/v3/openai/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.PPIO_API_KEY}`, + }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + data.forEach((model) => { + const organization = model.id?.split("/")?.[0] || "PPIO"; + models[model.id] = { + id: model.id, + name: model.display_name || model.title || model.id, + organization, + maxLength: model.context_size || 4096, + }; + }); + + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync( + path.resolve(cacheFolder, "models.json"), + JSON.stringify(models), + { + encoding: "utf-8", + } + ); + fs.writeFileSync( + path.resolve(cacheFolder, ".cached_at"), + String(Number(new Date())), + { + encoding: "utf-8", + } + ); + return models; + }) + .catch((e) => { + console.error(e); + return {}; + }); +} + +module.exports = { + PPIOLLM, + fetchPPIOModels, +}; diff --git a/server/utils/AiProviders/privatemode/index.js b/server/utils/AiProviders/privatemode/index.js new file mode 100644 index 0000000..ebbdfe6 --- /dev/null +++ b/server/utils/AiProviders/privatemode/index.js @@ -0,0 +1,220 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +class PrivatemodeLLM { + static contextWindows = { + "leon-se/gemma-3-27b-it-fp8-dynamic": 128000, + "gemma-3-27b": 128000, + "qwen3-coder-30b-a3b": 128000, + "gpt-oss-120b": 128000, + "openai/gpt-oss-120b": 128000, + }; + + constructor(embedder = null, modelPreference = null) { + if (!process.env.PRIVATEMODE_LLM_BASE_PATH) + throw new Error("No Privatemode Base Path was set."); + + this.className = "PrivatemodeLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + this.client = new OpenAIApi({ + baseURL: PrivatemodeLLM.parseBasePath(), + apiKey: null, + }); + + this.model = modelPreference || process.env.PRIVATEMODE_LLM_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Privatemode LLM initialized with ${this.model}. ctx: ${this.promptWindowLimit()}` + ); + } + + /** + * Parse the base path for the Privatemode API + * so we can use it for inference requests + * @param {string} providedBasePath + * @returns {string} + */ + static parseBasePath( + providedBasePath = process.env.PRIVATEMODE_LLM_BASE_PATH + ) { + try { + const baseURL = new URL(providedBasePath); + const basePath = `${baseURL.origin}/v1`; + return basePath; + } catch { + return null; + } + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = PrivatemodeLLM.contextWindows[_modelName] || 16384; + return Number(limit); + } + + promptWindowLimit() { + const limit = PrivatemodeLLM.contextWindows[this.model] || 16384; + return Number(limit); + } + + async isValidChatCompletionModel(_ = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "auto", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Privatemode chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.client.chat.completions.create({ + model: this.model, + messages, + temperature, + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.model) + throw new Error( + `Privatemode chat: ${this.model} is not valid or defined model for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.client.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + PrivatemodeLLM, +}; diff --git a/server/utils/AiProviders/sambanova/index.js b/server/utils/AiProviders/sambanova/index.js new file mode 100644 index 0000000..a3b9bf8 --- /dev/null +++ b/server/utils/AiProviders/sambanova/index.js @@ -0,0 +1,273 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { v4: uuidv4 } = require("uuid"); +const { + writeResponseChunk, + clientAbortedHandler, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class SambaNovaLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.SAMBANOVA_LLM_API_KEY) + throw new Error("No SambaNova API key was set."); + this.className = "SambaNovaLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + baseURL: "https://api.sambanova.ai/v1", + apiKey: process.env.SAMBANOVA_LLM_API_KEY, + }); + this.model = modelPreference || process.env.SAMBANOVA_LLM_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("sambanova", modelName) ?? 131072; + } + + promptWindowLimit() { + return MODEL_MAP.get("sambanova", this.model) ?? 131072; + } + + async isValidChatCompletionModel(modelName = "") { + return !!modelName; // name just needs to exist + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...chatHistory, + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.total_tokens_per_sec || 0, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + stream_options: { + include_usage: true, + }, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + const { uuid = uuidv4(), sources = [] } = responseProps; + let hasUsageMetrics = false; + let usage = { + prompt_tokens: 0, + total_tokens: 0, + outputTps: 0, + completion_tokens: 0, + }; + + return new Promise(async (resolve) => { + let fullText = ""; + const handleAbort = () => { + stream?.endMeasurement(usage); + clientAbortedHandler(resolve, fullText); + }; + response.on("close", handleAbort); + + try { + for await (const chunk of stream) { + const message = chunk?.choices?.[0]; + const token = message?.delta?.content; + + if ( + chunk.hasOwnProperty("usage") && // exists + !!chunk.usage && + Object.values(chunk.usage).length > 0 + ) { + if (chunk.usage.hasOwnProperty("prompt_tokens")) + usage.prompt_tokens = Number(chunk.usage.prompt_tokens); + if (chunk.usage.hasOwnProperty("completion_tokens")) + usage.completion_tokens = Number(chunk.usage.completion_tokens); + if (chunk.usage.hasOwnProperty("total_tokens")) + usage.total_tokens = Number(chunk.usage.total_tokens); + if (chunk.usage.hasOwnProperty("total_tokens_per_sec")) + usage.outputTps = Number(chunk.usage.total_tokens_per_sec); + hasUsageMetrics = true; + } + + if (token) { + fullText += token; + if (!hasUsageMetrics) usage.completion_tokens++; + writeResponseChunk(response, { + uuid, + sources: [], + type: "textResponseChunk", + textResponse: token, + close: false, + error: false, + }); + } + + if ( + message?.hasOwnProperty("finish_reason") && + message.finish_reason !== "" && + message.finish_reason !== null + ) { + writeResponseChunk(response, { + uuid, + sources, + type: "textResponseChunk", + textResponse: "", + close: true, + error: false, + }); + } + } + + response.removeListener("close", handleAbort); + stream?.endMeasurement(usage); + resolve(fullText); + } catch (e) { + this.log(`\x1b[43m\x1b[34m[STREAMING ERROR]\x1b[0m ${e.message}`); + writeResponseChunk(response, { + uuid, + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + stream?.endMeasurement(usage); + resolve(fullText); + } + }); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + SambaNovaLLM, +}; diff --git a/server/utils/AiProviders/textGenWebUI/index.js b/server/utils/AiProviders/textGenWebUI/index.js new file mode 100644 index 0000000..7d7cac2 --- /dev/null +++ b/server/utils/AiProviders/textGenWebUI/index.js @@ -0,0 +1,197 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); + +class TextGenWebUILLM { + constructor(embedder = null) { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.TEXT_GEN_WEB_UI_BASE_PATH) + throw new Error( + "TextGenWebUI must have a valid base path to use for the api." + ); + + this.className = "TextGenWebUILLM"; + this.basePath = process.env.TEXT_GEN_WEB_UI_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.TEXT_GEN_WEB_UI_API_KEY ?? null, + }); + this.model = null; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log(`Inference API: ${this.basePath} Model: ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(_modelName) { + const limit = process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Ensure the user set a value for the token limit + // and if undefined - assume 4096 window. + promptWindowLimit() { + const limit = process.env.TEXT_GEN_WEB_UI_MODEL_TOKEN_LIMIT || 4096; + if (!limit || isNaN(Number(limit))) + throw new Error("No token context limit was set."); + return Number(limit); + } + + // Short circuit since we have no idea if the model is valid or not + // in pre-flight for generic endpoints + isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: true, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + TextGenWebUILLM, +}; diff --git a/server/utils/AiProviders/togetherAi/index.js b/server/utils/AiProviders/togetherAi/index.js new file mode 100644 index 0000000..f957164 --- /dev/null +++ b/server/utils/AiProviders/togetherAi/index.js @@ -0,0 +1,263 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const fs = require("fs"); +const path = require("path"); +const { safeJsonParse } = require("../../http"); + +const cacheFolder = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "models", "togetherAi") + : path.resolve(__dirname, `../../../storage/models/togetherAi`) +); + +async function togetherAiModels(apiKey = null) { + const cacheModelPath = path.resolve(cacheFolder, "models.json"); + const cacheAtPath = path.resolve(cacheFolder, ".cached_at"); + + // If cache exists and is less than 1 week old, use it + if (fs.existsSync(cacheModelPath) && fs.existsSync(cacheAtPath)) { + const now = Number(new Date()); + const timestampMs = Number(fs.readFileSync(cacheAtPath)); + if (now - timestampMs <= 6.048e8) { + // 1 Week in MS + return safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + [] + ); + } + } + + try { + const { OpenAI: OpenAIApi } = require("openai"); + const openai = new OpenAIApi({ + baseURL: "https://api.together.xyz/v1", + apiKey: apiKey || process.env.TOGETHER_AI_API_KEY || null, + }); + + const response = await openai.models.list(); + + // Filter and transform models into the expected format + // Only include chat models + const validModels = response.body + .filter((model) => ["chat"].includes(model.type)) + .map((model) => ({ + id: model.id, + name: model.display_name || model.id, + organization: model.organization || "Unknown", + type: model.type, + maxLength: model.context_length || 4096, + })); + + // Cache the results + if (!fs.existsSync(cacheFolder)) + fs.mkdirSync(cacheFolder, { recursive: true }); + fs.writeFileSync(cacheModelPath, JSON.stringify(validModels), { + encoding: "utf-8", + }); + fs.writeFileSync(cacheAtPath, String(Number(new Date())), { + encoding: "utf-8", + }); + + return validModels; + } catch (error) { + console.error("Error fetching Together AI models:", error); + // If cache exists but is stale, still use it as fallback + if (fs.existsSync(cacheModelPath)) { + return safeJsonParse( + fs.readFileSync(cacheModelPath, { encoding: "utf-8" }), + [] + ); + } + return []; + } +} + +class TogetherAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.TOGETHER_AI_API_KEY) + throw new Error("No TogetherAI API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + this.className = "TogetherAiLLM"; + this.openai = new OpenAIApi({ + baseURL: "https://api.together.xyz/v1", + apiKey: process.env.TOGETHER_AI_API_KEY ?? null, + }); + this.model = modelPreference || process.env.TOGETHER_AI_MODEL_PREF; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = !embedder ? new NativeEmbedder() : embedder; + this.defaultTemp = 0.7; + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + async allModelInformation() { + const models = await togetherAiModels(); + return models.reduce((acc, model) => { + acc[model.id] = model; + return acc; + }, {}); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static async promptWindowLimit(modelName) { + const models = await togetherAiModels(); + const model = models.find((m) => m.id === modelName); + return model?.maxLength || 4096; + } + + async promptWindowLimit() { + const models = await togetherAiModels(); + const model = models.find((m) => m.id === this.model); + return model?.maxLength || 4096; + } + + async isValidChatCompletionModel(model = "") { + const models = await togetherAiModels(); + const foundModel = models.find((m) => m.id === model); + return foundModel && foundModel.type === "chat"; + } + + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...chatHistory, + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `TogetherAI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!(await this.isValidChatCompletionModel(this.model))) + throw new Error( + `TogetherAI chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + TogetherAiLLM, + togetherAiModels, +}; diff --git a/server/utils/AiProviders/xai/index.js b/server/utils/AiProviders/xai/index.js new file mode 100644 index 0000000..ae5eff6 --- /dev/null +++ b/server/utils/AiProviders/xai/index.js @@ -0,0 +1,200 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, + formatChatHistory, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class XAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.XAI_LLM_API_KEY) + throw new Error("No xAI API key was set."); + this.className = "XAiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + baseURL: "https://api.x.ai/v1", + apiKey: process.env.XAI_LLM_API_KEY, + }); + this.model = + modelPreference || process.env.XAI_LLM_MODEL_PREF || "grok-beta"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("xai", modelName) ?? 131_072; + } + + promptWindowLimit() { + return MODEL_MAP.get("xai", this.model) ?? 131_072; + } + + isValidChatCompletionModel(_modelName = "") { + return true; + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) { + return userPrompt; + } + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + detail: "high", + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], // This is the specific attachment for only this prompt + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...formatChatHistory(chatHistory, this.#generateContent), + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.isValidChatCompletionModel(this.model)) + throw new Error( + `xAI chat: ${this.model} is not valid for chat completion!` + ); + + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage.prompt_tokens || 0, + completion_tokens: result.output.usage.completion_tokens || 0, + total_tokens: result.output.usage.total_tokens || 0, + outputTps: result.output.usage.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + if (!this.isValidChatCompletionModel(this.model)) + throw new Error( + `xAI chat: ${this.model} is not valid for chat completion!` + ); + + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + XAiLLM, +}; diff --git a/server/utils/AiProviders/zai/index.js b/server/utils/AiProviders/zai/index.js new file mode 100644 index 0000000..4048089 --- /dev/null +++ b/server/utils/AiProviders/zai/index.js @@ -0,0 +1,184 @@ +const { NativeEmbedder } = require("../../EmbeddingEngines/native"); +const { + LLMPerformanceMonitor, +} = require("../../helpers/chat/LLMPerformanceMonitor"); +const { + handleDefaultStreamResponseV2, +} = require("../../helpers/chat/responses"); +const { MODEL_MAP } = require("../modelMap"); + +class ZAiLLM { + constructor(embedder = null, modelPreference = null) { + if (!process.env.ZAI_API_KEY) throw new Error("No Z.AI API key was set."); + this.className = "ZAiLLM"; + const { OpenAI: OpenAIApi } = require("openai"); + + this.openai = new OpenAIApi({ + baseURL: "https://api.z.ai/api/paas/v4", + apiKey: process.env.ZAI_API_KEY, + }); + this.model = modelPreference || process.env.ZAI_MODEL_PREF || "glm-4.5"; + this.limits = { + history: this.promptWindowLimit() * 0.15, + system: this.promptWindowLimit() * 0.15, + user: this.promptWindowLimit() * 0.7, + }; + + this.embedder = embedder ?? new NativeEmbedder(); + this.defaultTemp = 0.7; + this.log( + `Initialized ${this.model} with context window ${this.promptWindowLimit()}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + #appendContext(contextTexts = []) { + if (!contextTexts || !contextTexts.length) return ""; + return ( + "\nContext:\n" + + contextTexts + .map((text, i) => { + return `[CONTEXT ${i}]:\n${text}\n[END CONTEXT ${i}]\n\n`; + }) + .join("") + ); + } + + streamingEnabled() { + return "streamGetChatCompletion" in this; + } + + static promptWindowLimit(modelName) { + return MODEL_MAP.get("zai", modelName) ?? 131072; + } + + promptWindowLimit() { + return MODEL_MAP.get("zai", this.model) ?? 131072; + } + + async isValidChatCompletionModel(modelName = "") { + return !!modelName; // name just needs to exist + } + + /** + * Generates appropriate content array for a message + attachments. + * @param {{userPrompt:string, attachments: import("../../helpers").Attachment[]}} + * @returns {string|object[]} + */ + #generateContent({ userPrompt, attachments = [] }) { + if (!attachments.length) return userPrompt; + + const content = [{ type: "text", text: userPrompt }]; + for (let attachment of attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content.flat(); + } + + /** + * Construct the user prompt for this model. + * @param {{attachments: import("../../helpers").Attachment[]}} param0 + * @returns + */ + constructPrompt({ + systemPrompt = "", + contextTexts = [], + chatHistory = [], + userPrompt = "", + attachments = [], + }) { + const prompt = { + role: "system", + content: `${systemPrompt}${this.#appendContext(contextTexts)}`, + }; + return [ + prompt, + ...chatHistory, + { + role: "user", + content: this.#generateContent({ userPrompt, attachments }), + }, + ]; + } + + async getChatCompletion(messages = null, { temperature = 0.7 }) { + const result = await LLMPerformanceMonitor.measureAsyncFunction( + this.openai.chat.completions + .create({ + model: this.model, + messages, + temperature, + }) + .catch((e) => { + throw new Error(e.message); + }) + ); + + if ( + !result.output.hasOwnProperty("choices") || + result.output.choices.length === 0 + ) + return null; + + return { + textResponse: result.output.choices[0].message.content, + metrics: { + prompt_tokens: result.output.usage?.prompt_tokens || 0, + completion_tokens: result.output.usage?.completion_tokens || 0, + total_tokens: result.output.usage?.total_tokens || 0, + outputTps: result.output.usage?.completion_tokens / result.duration, + duration: result.duration, + model: this.model, + provider: this.className, + timestamp: new Date(), + }, + }; + } + + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ + func: this.openai.chat.completions.create({ + model: this.model, + stream: true, + messages, + temperature, + }), + messages, + runPromptTokenCalculation: false, + modelTag: this.model, + provider: this.className, + }); + + return measuredStreamRequest; + } + + handleStream(response, stream, responseProps) { + return handleDefaultStreamResponseV2(response, stream, responseProps); + } + + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations + async embedTextInput(textInput) { + return await this.embedder.embedTextInput(textInput); + } + async embedChunks(textChunks = []) { + return await this.embedder.embedChunks(textChunks); + } + + async compressMessages(promptArgs = {}, rawHistory = []) { + const { messageArrayCompressor } = require("../../helpers/chat"); + const messageArray = this.constructPrompt(promptArgs); + return await messageArrayCompressor(this, messageArray, rawHistory); + } +} + +module.exports = { + ZAiLLM, +}; diff --git a/server/utils/BackgroundWorkers/index.js b/server/utils/BackgroundWorkers/index.js new file mode 100644 index 0000000..9350a97 --- /dev/null +++ b/server/utils/BackgroundWorkers/index.js @@ -0,0 +1,432 @@ +const path = require("path"); +const Graceful = require("@ladjs/graceful"); +const Bree = require("@mintplex-labs/bree"); +const later = require("@breejs/later"); +const PQueue = require("p-queue").default; +const setLogger = require("../logger"); + +// Use UTC time for cron interpretation. This ensures consistent behavior +// regardless of server timezone (e.g., when running in containers). +later.date.UTC(); + +class BackgroundService { + name = "BackgroundWorkerService"; + static _instance = null; + documentSyncEnabled = false; + memoryExtractionEnabled = false; + #root = path.resolve(__dirname, "../../jobs"); + #scheduledJobTimers = new Map(); + #scheduledJobQueue = new PQueue({ + concurrency: Number(process.env.SCHEDULED_JOB_MAX_CONCURRENT) || 1, + }); + // Tracks in-flight worker processes per scheduled jobId so we can kill any + // active runs when the job is deleted. Without this, a running worker + // outlives the cascade-delete of its scheduled_job_runs row and throws when + // it tries to write the result back (prisma.update on a missing row). + #scheduledJobWorkers = new Map(); + + #alwaysRunJobs = [ + { + name: "cleanup-orphan-documents", + timeout: "1m", + interval: "12hr", + }, + { + name: "cleanup-generated-files", + timeout: "5m", + interval: "8hr", + }, + ]; + + #memoryJobs = [ + { + name: "extract-memories", + interval: process.env.MEMORY_EXTRACTION_INTERVAL || "3hr", + }, + ]; + + #documentSyncJobs = [ + // Job for auto-sync of documents + // https://github.com/breejs/bree + { + name: "sync-watched-documents", + interval: "1hr", + }, + ]; + + constructor() { + if (BackgroundService._instance) { + this.#log("SINGLETON LOCK: Using existing BackgroundService."); + return BackgroundService._instance; + } + + this.logger = setLogger(); + BackgroundService._instance = this; + } + + #log(text, ...args) { + console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args); + } + + /** + * Returns the root path where job files are located. + * Handles the difference between development and production (bundled) environments. + * @returns {string} + */ + get jobsRoot() { + return this.#root; + } + + /** + * Wraps the logger so that IPC messages carrying `silent: true` are + * suppressed. Bree unconditionally calls `logger.info(message)` for + * every IPC message from forked processes, so this is the only + * interception point. + */ + #makeBreeLogger() { + const base = this.logger; + const isSilent = (args) => args.length === 1 && args[0]?.silent === true; + + const wrapped = Object.create(base); + wrapped.info = (...args) => { + if (!isSilent(args)) base.info(...args); + }; + wrapped.log = (...args) => { + if (!isSilent(args)) base.log(...args); + }; + return wrapped; + } + + async boot() { + const { DocumentSyncQueue } = require("../../models/documentSyncQueue"); + const { SystemSettings } = require("../../models/systemSettings"); + const { ScheduledJobRun } = require("../../models/scheduledJobRun"); + + this.documentSyncEnabled = await DocumentSyncQueue.enabled(); + this.memoryExtractionEnabled = await SystemSettings.autoMemoriesEnabled(); + + // Mark any orphaned scheduled job runs as failed (server crashed mid-execution) + const orphanedCount = await ScheduledJobRun.failOrphanedRuns(); + if (orphanedCount > 0) { + this.#log( + `Marked ${orphanedCount} orphaned scheduled job run(s) as failed` + ); + } + + const jobsToRun = this.jobs(); + + this.#log("Starting..."); + this.bree = new Bree({ + logger: this.#makeBreeLogger(), + root: this.#root, + jobs: jobsToRun, + errorHandler: this.onError, + workerMessageHandler: this.onWorkerMessageHandler, + runJobsAs: "process", + }); + this.graceful = new Graceful({ brees: [this.bree], logger: this.logger }); + this.graceful.listen(); + + this.bree.start(); + this.#log( + `Service started with ${jobsToRun.length} jobs`, + jobsToRun.map((j) => j.name) + ); + + await this.#bootScheduledJobs(); + } + + /** + * Cleanup scheduled jobs (in-process cron timers + p-queue) + */ + #cleanupScheduledJobs() { + for (const [id, timer] of this.#scheduledJobTimers) { + timer.clear(); + this.#scheduledJobTimers.delete(id); + } + this.#scheduledJobQueue.clear(); + } + + async stop() { + this.#log("Stopping..."); + this.#cleanupScheduledJobs(); + if (!!this.graceful && !!this.bree) this.graceful.stopBree(this.bree, 0); + this.bree = null; + this.graceful = null; + this.#log("Service stopped"); + } + + /** @returns {import("@mintplex-labs/bree").Job[]} */ + jobs() { + const activeJobs = [...this.#alwaysRunJobs]; + if (this.memoryExtractionEnabled) activeJobs.push(...this.#memoryJobs); + if (this.documentSyncEnabled) activeJobs.push(...this.#documentSyncJobs); + return activeJobs; + } + + /** + * Sync the memory extraction job based on current settings. + * Called when memory_enabled or memory_auto_extraction changes. + * @param {boolean} enabled - The desired state (should the extraction job run?) + */ + async syncMemoryJob(enabled) { + if (!this.bree) return; + + const jobName = this.#memoryJobs[0].name; + const isCurrentlyRunning = this.bree.config.jobs.some( + (j) => j.name === jobName + ); + + if (enabled && !isCurrentlyRunning) { + this.memoryExtractionEnabled = true; + await this.bree.add(this.#memoryJobs[0]); + await this.bree.start(jobName); + this.#log(`Added and started ${jobName} job`); + } else if (!enabled && isCurrentlyRunning) { + this.memoryExtractionEnabled = false; + await this.bree.stop(jobName); + await this.bree.remove(jobName); + this.#log(`Stopped and removed ${jobName} job`); + } + } + + onError(error, _workerMetadata) { + this.logger.error(`${error.message}`, { + service: "bg-worker", + origin: error.name, + }); + } + + onWorkerMessageHandler(message, _workerMetadata) { + if (message?.silent || message?.message?.silent) return; + this.logger.info(`${message.message}`, { + service: "bg-worker", + origin: message.name, + }); + } + + /** + * Spawn a one-off Bree worker process for the given script. + * @param {string} scriptPath - Absolute path to the worker JS file + * @returns {Promise<{ worker: ChildProcess, jobId: string }>} + */ + async spawnWorker(scriptPath) { + if (!this.bree) + throw new Error("BackgroundService has not been booted yet"); + + const jobId = `${path.basename(scriptPath, ".js")}-${Date.now()}`; + + await this.bree.add({ + name: jobId, + path: scriptPath, + }); + + await this.bree.run(jobId); + const worker = this.bree.workers.get(jobId); + + if (!worker) throw new Error("Failed to get worker reference from Bree"); + + return { worker, jobId }; + } + + /** + * Remove a one-off Bree job registration so stale entries don't accumulate. + * @param {string} jobId + */ + async removeJob(jobId) { + if (!jobId) return; + try { + if (this.bree) await this.bree.remove(jobId); + } catch { + /* Job may already be removed */ + } + } + + // --------------------------------------------------------------- + // Scheduled Jobs — in-process cron timers + p-queue + // + // Bree tightly couples scheduling with worker spawning — when a + // Bree cron fires, it directly calls run() which immediately + // spawns a child process with no way to intercept it. We manage + // our own cron timers (via later.setInterval) to decouple + // scheduling from execution so we can route jobs through p-queue + // for global concurrency control before spawning workers. + // + // Per-job dedup lives in the database, not in process memory: any + // non-terminal row (`queued` or `running`) in scheduled_job_runs means + // the job has a run in flight. ScheduledJobRun.start() does the check + + // insert atomically and creates the row in `queued` status. The worker + // transitions it to `running` once it actually begins executing, so + // `startedAt` reflects execution start rather than queue-claim time. + // Cron-fired and manually-triggered enqueues use the same rule — + // at most one in-flight run per job, regardless of source. + // --------------------------------------------------------------- + + /** + * Register cron timers for all enabled scheduled jobs on startup. + */ + async #bootScheduledJobs() { + const { ScheduledJob } = require("../../models/scheduledJob"); + const enabledJobs = await ScheduledJob.allEnabled(); + + for (const job of enabledJobs) { + await ScheduledJob.recomputeNextRunAt(job.id); + this.addScheduledJob(job); + } + + if (enabledJobs.length > 0) { + this.#log( + `Registered ${enabledJobs.length} scheduled job(s) (max concurrent: ${this.#scheduledJobQueue.concurrency})`, + enabledJobs.map((j) => `${j.name} (${j.schedule})`) + ); + } + } + + /** + * Register an in-process cron timer for a scheduled job. + * When the cron fires, the jobId is enqueued for execution. + * @param {object} job - scheduled_jobs DB record + */ + addScheduledJob(job) { + this.removeScheduledJob(job.id); + const sched = later.parse.cron(job.schedule); + const timer = later.setInterval(() => { + this.enqueueScheduledJob(job.id); + }, sched); + this.#scheduledJobTimers.set(job.id, timer); + } + + /** + * Remove an in-process cron timer for a scheduled job and kill any in-flight + * worker processes for it. Killing in-flight workers prevents them from + * writing results back to a scheduled_job_runs row that the FK cascade (from + * a subsequent ScheduledJob.delete) is about to remove. + * @param {number} jobId - scheduled_jobs.id + */ + removeScheduledJob(jobId) { + const timer = this.#scheduledJobTimers.get(jobId); + if (timer) timer.clear(); + this.#scheduledJobTimers.delete(jobId); + + const workers = this.#scheduledJobWorkers.get(jobId); + if (workers) { + for (const worker of workers) { + try { + worker.kill("SIGTERM"); + } catch { + /* worker may have already exited */ + } + } + this.#scheduledJobWorkers.delete(jobId); + } + } + + /** + * Re-sync a scheduled job's cron timer after an update. + * Removes the old timer and re-adds if still enabled. + * @param {number} jobId - scheduled_jobs.id + */ + async syncScheduledJob(jobId) { + const { ScheduledJob } = require("../../models/scheduledJob"); + this.removeScheduledJob(jobId); + const job = await ScheduledJob.get({ id: Number(jobId) }); + if (job && job.enabled) { + this.addScheduledJob(job); + } + } + + /** + * Kill a specific run's worker process. This terminates the worker but does + * not update the database — the caller should use ScheduledJobRun.kill() + * before or after calling this to mark the run as failed. + * + * @param {number} jobId - scheduled_jobs.id (parent job) + * @param {number} runId - scheduled_job_runs.id (not directly used, but for + * future multi-run support; currently we kill all workers for the jobId) + * @returns {boolean} true if a worker was found and killed, false otherwise + */ + killRun(jobId, _runId) { + const workers = this.#scheduledJobWorkers.get(Number(jobId)); + if (!workers || workers.size === 0) return false; + + let killed = false; + for (const worker of workers) { + try { + worker.kill("SIGTERM"); + killed = true; + } catch { + /* worker may have already exited */ + } + } + return killed; + } + + /** + * Enqueue a scheduled job for execution. Called by both the cron timer + * (in addScheduledJob) and the manual trigger endpoint. ScheduledJobRun.start() + * atomically rejects the call if the job already has a run in flight. + * + * @param {number} jobId - scheduled_jobs.id + * @returns {Promise} the created run row, or null if skipped + * because a run is already in flight for this job. + */ + async enqueueScheduledJob(jobId) { + const { ScheduledJobRun } = require("../../models/scheduledJobRun"); + + const run = await ScheduledJobRun.start(jobId); + // if start returns null, skip enqueuing, schueduled job already has a run in flight + if (!run) return null; + + this.#scheduledJobQueue.add(() => + this.#runScheduledJobWorker(jobId, run.id).catch(async (err) => { + this.#log(`Scheduled job ${jobId} failed: ${err.message}`); + await ScheduledJobRun.failIfNotTerminal(run.id, err.message); + }) + ); + return run; + } + + /** + * Spawn the run-scheduled-job worker for a given run and resolve when it + * exits so p-queue can advance. The worker reads its payload from an IPC + * `process.on("message", ...)` handler. + * + * @param {number} jobId + * @param {number} runId + * @returns {Promise} + */ + async #runScheduledJobWorker(jobId, runId) { + const scriptPath = path.resolve(this.jobsRoot, "run-scheduled-job.js"); + const { worker, jobId: workerId } = await this.spawnWorker(scriptPath); + + if (!this.#scheduledJobWorkers.has(jobId)) { + this.#scheduledJobWorkers.set(jobId, new Set()); + } + this.#scheduledJobWorkers.get(jobId).add(worker); + + try { + worker.send({ jobId, runId }); + await new Promise((resolve, reject) => { + worker.on("exit", (code, signal) => { + // SIGTERM is sent by removeScheduledJob when the job is deleted + // mid-run; treat that as a normal exit rather than a worker failure. + if (code === 0 || code == null || signal === "SIGTERM") { + resolve(); + } else { + reject(new Error(`Worker exited with code ${code}`)); + } + }); + worker.on("error", reject); + }); + } finally { + const workers = this.#scheduledJobWorkers.get(jobId); + if (workers) { + workers.delete(worker); + if (workers.size === 0) this.#scheduledJobWorkers.delete(jobId); + } + await this.removeJob(workerId).catch(() => {}); + } + } +} + +module.exports.BackgroundService = BackgroundService; diff --git a/server/utils/DocumentManager/index.js b/server/utils/DocumentManager/index.js new file mode 100644 index 0000000..17fd986 --- /dev/null +++ b/server/utils/DocumentManager/index.js @@ -0,0 +1,72 @@ +const fs = require("fs"); +const path = require("path"); + +const documentsPath = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage/documents`) + : path.resolve(process.env.STORAGE_DIR, `documents`); + +class DocumentManager { + constructor({ workspace = null, maxTokens = null }) { + this.workspace = workspace; + this.maxTokens = maxTokens || Number.POSITIVE_INFINITY; + this.documentStoragePath = documentsPath; + } + + log(text, ...args) { + console.log(`\x1b[36m[DocumentManager]\x1b[0m ${text}`, ...args); + } + + async pinnedDocuments() { + if (!this.workspace) return []; + const { Document } = require("../../models/documents"); + return await Document.where({ + workspaceId: Number(this.workspace.id), + pinned: true, + }); + } + + async pinnedDocs() { + if (!this.workspace) return []; + const docPaths = (await this.pinnedDocuments()).map((doc) => doc.docpath); + if (docPaths.length === 0) return []; + + let tokens = 0; + const pinnedDocs = []; + for await (const docPath of docPaths) { + try { + const filePath = path.resolve(this.documentStoragePath, docPath); + const data = JSON.parse( + fs.readFileSync(filePath, { encoding: "utf-8" }) + ); + + if ( + !data.hasOwnProperty("pageContent") || + !data.hasOwnProperty("token_count_estimate") + ) { + this.log( + `Skipping document - Could not find page content or token_count_estimate in pinned source.` + ); + continue; + } + + if (tokens >= this.maxTokens) { + this.log( + `Skipping document - Token limit of ${this.maxTokens} has already been exceeded by pinned documents.` + ); + continue; + } + + pinnedDocs.push(data); + tokens += data.token_count_estimate || 0; + } catch {} + } + + this.log( + `Found ${pinnedDocs.length} pinned sources - prepending to content with ~${tokens} tokens of content.` + ); + return pinnedDocs; + } +} + +module.exports.DocumentManager = DocumentManager; diff --git a/server/utils/EmbeddingEngines/azureOpenAi/index.js b/server/utils/EmbeddingEngines/azureOpenAi/index.js new file mode 100644 index 0000000..084127c --- /dev/null +++ b/server/utils/EmbeddingEngines/azureOpenAi/index.js @@ -0,0 +1,115 @@ +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +class AzureOpenAiEmbedder { + constructor() { + const { AzureOpenAI } = require("openai"); + if (!process.env.AZURE_OPENAI_ENDPOINT) + throw new Error("No Azure API endpoint was set."); + if (!process.env.AZURE_OPENAI_KEY) + throw new Error("No Azure API key was set."); + + this.className = "AzureOpenAiEmbedder"; + this.apiVersion = "2024-12-01-preview"; + const openai = new AzureOpenAI({ + apiKey: process.env.AZURE_OPENAI_KEY, + endpoint: process.env.AZURE_OPENAI_ENDPOINT, + apiVersion: this.apiVersion, + }); + + // We cannot assume the model fallback since the model is based on the deployment name + // and not the model name - so this will throw on embedding if the model is not defined. + this.model = process.env.EMBEDDING_MODEL_PREF; + this.openai = openai; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + // https://learn.microsoft.com/en-us/azure/ai-services/openai/faq#i-am-trying-to-use-embeddings-and-received-the-error--invalidrequesterror--too-many-inputs--the-max-number-of-inputs-is-1---how-do-i-fix-this-:~:text=consisting%20of%20up%20to%2016%20inputs%20per%20API%20request + this.maxConcurrentChunks = 16; + + // https://learn.microsoft.com/en-us/answers/questions/1188074/text-embedding-ada-002-token-context-length + this.embeddingMaxChunkLength = 2048; + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + if (!this.model) throw new Error("No Embedding Model preference defined."); + + this.log(`Embedding ${textChunks.length} chunks...`); + // Because there is a limit on how many chunks can be sent at once to Azure OpenAI + // we concurrently execute each max batch of text chunks possible. + // Refer to constructor maxConcurrentChunks for more info. + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((res) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: res.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from Azure abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`Azure OpenAI Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + AzureOpenAiEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/cohere/index.js b/server/utils/EmbeddingEngines/cohere/index.js new file mode 100644 index 0000000..1a4fe3f --- /dev/null +++ b/server/utils/EmbeddingEngines/cohere/index.js @@ -0,0 +1,101 @@ +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +class CohereEmbedder { + constructor() { + if (!process.env.COHERE_API_KEY) + throw new Error("No Cohere API key was set."); + this.className = "CohereEmbedder"; + + // Cohere exposes an OpenAI-compatible API which lets us reuse the OpenAI SDK + // across the app instead of the cohere-ai package. The compatibility endpoint + // manages the embedding `input_type` internally so we do not set it ourselves. + // https://docs.cohere.com/docs/compatibility-api + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.cohere.ai/compatibility/v1", + apiKey: process.env.COHERE_API_KEY, + }); + + this.model = process.env.EMBEDDING_MODEL_PREF || "embed-english-v3.0"; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 96; // Cohere's limit per request is 96 + this.embeddingMaxChunkLength = 1945; // https://docs.cohere.com/docs/embed-2 - assume a token is roughly 4 letters with some padding + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + const embeddingRequests = []; + let chunksProcessed = 0; + + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + return { data: [], error: Array.from(uniqueErrors).join(", ") }; + } + + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`Cohere Failed to embed: ${error}`); + + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + CohereEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/gemini/index.js b/server/utils/EmbeddingEngines/gemini/index.js new file mode 100644 index 0000000..d87bd86 --- /dev/null +++ b/server/utils/EmbeddingEngines/gemini/index.js @@ -0,0 +1,136 @@ +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +const MODEL_MAP = { + "gemini-embedding-001": 2048, +}; + +class GeminiEmbedder { + constructor() { + if (!process.env.GEMINI_EMBEDDING_API_KEY) + throw new Error("No Gemini API key was set."); + + this.className = "GeminiEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.model = process.env.EMBEDDING_MODEL_PREF || "gemini-embedding-001"; + this.openai = new OpenAIApi({ + apiKey: process.env.GEMINI_EMBEDDING_API_KEY, + // Even models that are v1 in gemini API can be used with v1beta/openai/ endpoint and nobody knows why. + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", + }); + + this.maxConcurrentChunks = 4; + + // https://ai.google.dev/gemini-api/docs/models/gemini#text-embedding-and-embedding + this.embeddingMaxChunkLength = MODEL_MAP[this.model] || 2_048; + this.log( + `Initialized with ${this.model} - Max Size: ${this.embeddingMaxChunkLength}` + + (this.outputDimensions + ? ` - Output Dimensions: ${this.outputDimensions}` + : " Assuming default output dimensions") + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + get outputDimensions() { + if ( + process.env.EMBEDDING_OUTPUT_DIMENSIONS && + !isNaN(process.env.EMBEDDING_OUTPUT_DIMENSIONS) && + process.env.EMBEDDING_OUTPUT_DIMENSIONS > 0 + ) + return parseInt(process.env.EMBEDDING_OUTPUT_DIMENSIONS); + return null; + } + + /** + * Embeds a single text input + * @param {string|string[]} textInput - The text to embed + * @returns {Promise>} The embedding values + */ + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + /** + * Embeds a list of text inputs + * @param {string[]} textChunks - The list of text to embed + * @returns {Promise>>} The embedding values + */ + async embedChunks(textChunks = []) { + this.log(`Embedding ${textChunks.length} chunks...`); + + // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb) + // we concurrently execute each max batch of text chunks possible. + // Refer to constructor maxConcurrentChunks for more info. + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + dimensions: this.outputDimensions, + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from OpenAI abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`Gemini Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + GeminiEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/genericOpenAi/index.js b/server/utils/EmbeddingEngines/genericOpenAi/index.js new file mode 100644 index 0000000..f21800b --- /dev/null +++ b/server/utils/EmbeddingEngines/genericOpenAi/index.js @@ -0,0 +1,171 @@ +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); + +class GenericOpenAiEmbedder { + constructor() { + if (!process.env.EMBEDDING_BASE_PATH) + throw new Error( + "GenericOpenAI must have a valid base path to use for the api." + ); + this.className = "GenericOpenAiEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.basePath = process.env.EMBEDDING_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.GENERIC_OPEN_AI_EMBEDDING_API_KEY ?? null, + }); + this.model = process.env.EMBEDDING_MODEL_PREF ?? null; + this.embeddingMaxChunkLength = maximumChunkLength(); + + // this.maxConcurrentChunks is delegated to the getter below. + // Refer to your specific model and provider you use this class with to determine a valid maxChunkLength + this.log(`Initialized ${this.model}`, { + baseURL: this.basePath, + maxConcurrentChunks: this.maxConcurrentChunks, + embeddingMaxChunkLength: this.embeddingMaxChunkLength, + }); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * returns the `GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS` env variable as a number or null if the env variable is not set or is not a number. + * The minimum delay is 500ms. + * + * For some implementation this is necessary to avoid 429 errors due to rate limiting or + * hardware limitations where a single-threaded process is not able to handle the requests fast enough. + * @returns {number} + */ + get apiRequestDelay() { + if (!("GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS" in process.env)) return null; + if (isNaN(Number(process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS))) + return null; + const delayTimeout = Number( + process.env.GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS + ); + if (delayTimeout < 500) return 500; // minimum delay of 500ms + return delayTimeout; + } + + /** + * runs the delay if it is set and valid. + * @returns {Promise} + */ + async runDelay() { + if (!this.apiRequestDelay) return; + this.log(`Delaying new batch request for ${this.apiRequestDelay}ms`); + await new Promise((resolve) => setTimeout(resolve, this.apiRequestDelay)); + } + + /** + * returns the `GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS` env variable as a number + * or 500 if the env variable is not set or is not a number. + * @returns {number} + */ + get maxConcurrentChunks() { + if (!process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS) + return 500; + if ( + isNaN(Number(process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS)) + ) + return 500; + return Number(process.env.GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS); + } + + /** + * Optional prefix prepended to each text passage before embedding. Empty by default + * for backwards compatibility. Required by asymmetric models like + * Qwen3-Embedding, which expect passage chunks wrapped as + * `Instruct: ` + * @returns {string} + */ + get embeddingPrefix() { + if (!("GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX" in process.env)) return ""; + this.log( + `Embedding prefix: \x1b[43m\x1b[30m${process.env.GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX}\x1b[0m` + ); + return process.env.GENERIC_OPEN_AI_EMBEDDING_PASSAGE_PREFIX; + } + + /** + * Optional prefix prepended to each query before embedding. Empty by + * default. Most asymmetric models (Qwen3-Embedding included) leave queries + * unwrapped, but some BGE/E5 variants expect a `query: ` prefix. + * @returns {string} + */ + get queryPrefix() { + if (!("GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX" in process.env)) return ""; + this.log( + `Query prefix: \x1b[43m\x1b[30m${process.env.GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX}\x1b[0m` + ); + return process.env.GENERIC_OPEN_AI_EMBEDDING_QUERY_PREFIX; + } + + /** + * Apply the query prefix to the text input if it is required by the model. + * eg: nomic-embed-text-v1 requires a query prefix for embedding/searching. + * @param {string|string[]} textInput - The text to embed. + * @returns {string|string[]} The text with the prefix applied. + */ + #applyQueryPrefix(textInput) { + if (!this.queryPrefix) return textInput; + if (Array.isArray(textInput)) + return textInput.map((text) => `${this.queryPrefix}${text}`); + return `${this.queryPrefix}${textInput}`; + } + + async embedTextInput(textInput) { + textInput = this.#applyQueryPrefix(textInput); + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb) + // we sequentially execute each max batch of text chunks possible. + // Refer to constructor maxConcurrentChunks for more info. + const allResults = []; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + const { data = [], error = null } = await new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => resolve({ data: result?.data, error: null })) + .catch((e) => { + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }); + + // If any errors were returned from OpenAI abort the entire sequence because the embeddings + // will be incomplete. + if (error) + throw new Error(`GenericOpenAI Failed to embed: ${error.message}`); + allResults.push(...(data || [])); + reportEmbeddingProgress(allResults.length, textChunks.length); + if (this.apiRequestDelay) await this.runDelay(); + } + + return allResults.length > 0 && + allResults.every((embd) => embd.hasOwnProperty("embedding")) + ? allResults.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + GenericOpenAiEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/lemonade/index.js b/server/utils/EmbeddingEngines/lemonade/index.js new file mode 100644 index 0000000..1be81f9 --- /dev/null +++ b/server/utils/EmbeddingEngines/lemonade/index.js @@ -0,0 +1,99 @@ +const { parseLemonadeServerEndpoint } = require("../../AiProviders/lemonade"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +class LemonadeEmbedder { + constructor() { + if (!process.env.EMBEDDING_BASE_PATH) + throw new Error("No Lemonade API Base Path was set."); + if (!process.env.EMBEDDING_MODEL_PREF) + throw new Error("No Embedding Model Pref was set."); + this.className = "LemonadeEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.lemonade = new OpenAIApi({ + baseURL: parseLemonadeServerEndpoint( + process.env.EMBEDDING_BASE_PATH, + "openai" + ), + apiKey: process.env.LEMONADE_LLM_API_KEY || null, + }); + this.model = process.env.EMBEDDING_MODEL_PREF; + + this.maxConcurrentChunks = 50; + this.embeddingMaxChunkLength = + process.env.EMBEDDING_MODEL_MAX_CHUNK_LENGTH || 8_191; + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + this.log( + `Embedding ${textChunks.length} chunks of text with ${this.model}.` + ); + + const allResults = []; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + const { data = [], error = null } = await new Promise((resolve) => { + this.lemonade.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => { + if (result?.error) { + const errMsg = + result.error?.details?.response?.error?.message || + result.error?.message || + "Unknown error"; + const errType = + result.error?.details?.response?.error?.type || + result.error?.type || + "api_error"; + resolve({ + data: [], + error: { type: errType, message: errMsg }, + }); + return; + } + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }); + + if (error) { + const errorMsg = `Lemonade Failed to embed: [${error.type}]: ${error.message}`; + this.log(errorMsg); + throw new Error(errorMsg); + } + allResults.push(...(data || [])); + reportEmbeddingProgress( + Math.min(allResults.length, textChunks.length), + textChunks.length + ); + } + + return allResults.length > 0 && + allResults.every((embd) => embd.hasOwnProperty("embedding")) + ? allResults.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + LemonadeEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/liteLLM/index.js b/server/utils/EmbeddingEngines/liteLLM/index.js new file mode 100644 index 0000000..4c225fb --- /dev/null +++ b/server/utils/EmbeddingEngines/liteLLM/index.js @@ -0,0 +1,102 @@ +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); + +class LiteLLMEmbedder { + constructor() { + const { OpenAI: OpenAIApi } = require("openai"); + if (!process.env.LITE_LLM_BASE_PATH) + throw new Error( + "LiteLLM must have a valid base path to use for the api." + ); + this.basePath = process.env.LITE_LLM_BASE_PATH; + this.openai = new OpenAIApi({ + baseURL: this.basePath, + apiKey: process.env.LITE_LLM_API_KEY ?? null, + }); + this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-ada-002"; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 500; + this.embeddingMaxChunkLength = maximumChunkLength(); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + // Because there is a hard POST limit on how many chunks can be sent at once to LiteLLM (~8mb) + // we concurrently execute each max batch of text chunks possible. + // Refer to constructor maxConcurrentChunks for more info. + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from LiteLLM abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`LiteLLM Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + LiteLLMEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/lmstudio/index.js b/server/utils/EmbeddingEngines/lmstudio/index.js new file mode 100644 index 0000000..239cc3a --- /dev/null +++ b/server/utils/EmbeddingEngines/lmstudio/index.js @@ -0,0 +1,122 @@ +const { parseLMStudioBasePath } = require("../../AiProviders/lmStudio"); +const { + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); + +class LMStudioEmbedder { + constructor() { + if (!process.env.EMBEDDING_BASE_PATH) + throw new Error("No embedding base path was set."); + if (!process.env.EMBEDDING_MODEL_PREF) + throw new Error("No embedding model was set."); + + const apiKey = process.env.LMSTUDIO_AUTH_TOKEN ?? null; + this.className = "LMStudioEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.lmstudio = new OpenAIApi({ + baseURL: parseLMStudioBasePath(process.env.EMBEDDING_BASE_PATH), + apiKey, + }); + this.model = process.env.EMBEDDING_MODEL_PREF; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 1; + this.embeddingMaxChunkLength = maximumChunkLength(); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async #isAlive() { + return await this.lmstudio.models + .list() + .then((res) => res?.data?.length > 0) + .catch((e) => { + this.log(e.message); + return false; + }); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + if (!(await this.#isAlive())) + throw new Error( + `LMStudio service could not be reached. Is LMStudio running?` + ); + + this.log( + `Embedding ${textChunks.length} chunks of text with ${this.model}.` + ); + + // LMStudio will drop all queued requests now? So if there are many going on + // we need to do them sequentially or else only the first resolves and the others + // get dropped or go unanswered >:( + let results = []; + let hasError = false; + for (const [idx, chunk] of textChunks.entries()) { + if (hasError) break; + results.push( + await this.lmstudio.embeddings + .create({ + model: this.model, + input: chunk, + encoding_format: "base64", + }) + .then((result) => { + const embedding = result.data?.[0]?.embedding; + if (!Array.isArray(embedding) || !embedding.length) + throw { + type: "EMPTY_ARR", + message: "The embedding was empty from LMStudio", + }; + reportEmbeddingProgress(idx + 1, textChunks.length); + return { data: embedding, error: null }; + }) + .catch((e) => { + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + hasError = true; + return { data: [], error: e }; + }) + ); + } + + // Accumulate errors from embedding. + // If any are present throw an abort error. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + + if (errors.length > 0) { + let uniqueErrors = new Set(); + console.log(errors); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + if (errors.length > 0) + throw new Error( + `LMStudio Failed to embed: ${Array.from(uniqueErrors).join(", ")}` + ); + } + + const data = results.map((res) => res?.data || []); + return data.length > 0 ? data : null; + } +} + +module.exports = { + LMStudioEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/localAi/index.js b/server/utils/EmbeddingEngines/localAi/index.js new file mode 100644 index 0000000..90da439 --- /dev/null +++ b/server/utils/EmbeddingEngines/localAi/index.js @@ -0,0 +1,126 @@ +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); + +class LocalAiEmbedder { + constructor() { + if (!process.env.EMBEDDING_BASE_PATH) + throw new Error("No embedding base path was set."); + if (!process.env.EMBEDDING_MODEL_PREF) + throw new Error("No embedding model was set."); + + this.className = "LocalAiEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.model = process.env.EMBEDDING_MODEL_PREF; + this.openai = new OpenAIApi({ + baseURL: process.env.EMBEDDING_BASE_PATH, + apiKey: process.env.LOCAL_AI_API_KEY ?? null, + }); + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 50; + this.embeddingMaxChunkLength = maximumChunkLength(); + + this.log( + `Initialized with ${this.model} - Max Size: ${this.embeddingMaxChunkLength}` + + (this.outputDimensions + ? ` - Output Dimensions: ${this.outputDimensions}` + : " Assuming default output dimensions") + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + get outputDimensions() { + if ( + process.env.EMBEDDING_OUTPUT_DIMENSIONS && + !isNaN(process.env.EMBEDDING_OUTPUT_DIMENSIONS) && + process.env.EMBEDDING_OUTPUT_DIMENSIONS > 0 + ) + return parseInt(process.env.EMBEDDING_OUTPUT_DIMENSIONS); + return null; + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + // Dev: LocalAI versions < v2.29 ignored encoding_format and always returned float arrays, + // causing the OpenAI SDK's default base64 decode to silently produce dims/4 zero vectors. + // Fixed in LocalAI PR #9135 (Mar 2026). Pinning "float" here for safety with older versions. + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + dimensions: this.outputDimensions, + encoding_format: "float", + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from LocalAI abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`LocalAI Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + LocalAiEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/mistral/index.js b/server/utils/EmbeddingEngines/mistral/index.js new file mode 100644 index 0000000..620f2ec --- /dev/null +++ b/server/utils/EmbeddingEngines/mistral/index.js @@ -0,0 +1,41 @@ +class MistralEmbedder { + constructor() { + if (!process.env.MISTRAL_API_KEY) + throw new Error("No Mistral API key was set."); + + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.mistral.ai/v1", + apiKey: process.env.MISTRAL_API_KEY ?? null, + }); + this.model = process.env.EMBEDDING_MODEL_PREF || "mistral-embed"; + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + try { + const response = await this.openai.embeddings.create({ + model: this.model, + input: textChunks, + encoding_format: "float", + }); + const embeddings = response?.data?.map((emb) => emb.embedding) || []; + if (embeddings.length === 0) + throw new Error("Mistral returned empty embeddings for batch"); + return embeddings; + } catch (error) { + console.error("Failed to get embeddings from Mistral.", error.message); + throw new Error(`Mistral Failed to embed: ${error.message}`); + } + } +} + +module.exports = { + MistralEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/native/constants.js b/server/utils/EmbeddingEngines/native/constants.js new file mode 100644 index 0000000..76c4d96 --- /dev/null +++ b/server/utils/EmbeddingEngines/native/constants.js @@ -0,0 +1,63 @@ +const SUPPORTED_NATIVE_EMBEDDING_MODELS = { + "Xenova/all-MiniLM-L6-v2": { + maxConcurrentChunks: 25, + // Right now, this is NOT the token length, and is instead the number of characters + // that can be processed in a single pass. So we override to 1,000 characters. + // roughtly the max number of tokens assuming 2 characters per token. (undershooting) + // embeddingMaxChunkLength: 512, (from the model card) + embeddingMaxChunkLength: 1_000, + chunkPrefix: "", + queryPrefix: "", + apiInfo: { + id: "Xenova/all-MiniLM-L6-v2", + name: "all-MiniLM-L6-v2", + description: + "A lightweight and fast model for embedding text. The default model for AnythingLLM.", + lang: "English", + size: "23MB", + modelCard: "https://huggingface.co/Xenova/all-MiniLM-L6-v2", + }, + }, + "Xenova/nomic-embed-text-v1": { + maxConcurrentChunks: 5, + // Right now, this is NOT the token length, and is instead the number of characters + // that can be processed in a single pass. So we override to 16,000 characters. + // roughtly the max number of tokens assuming 2 characters per token. (undershooting) + // embeddingMaxChunkLength: 8192, (from the model card) + embeddingMaxChunkLength: 16_000, + chunkPrefix: "search_document: ", + queryPrefix: "search_query: ", + apiInfo: { + id: "Xenova/nomic-embed-text-v1", + name: "nomic-embed-text-v1", + description: + "A high-performing open embedding model with a large token context window. Requires more processing power and memory.", + lang: "English", + size: "139MB", + modelCard: "https://huggingface.co/Xenova/nomic-embed-text-v1", + }, + }, + "MintplexLabs/multilingual-e5-small": { + maxConcurrentChunks: 5, + // Right now, this is NOT the token length, and is instead the number of characters + // that can be processed in a single pass. So we override to 1,000 characters. + // roughtly the max number of tokens assuming 2 characters per token. (undershooting) + // embeddingMaxChunkLength: 512, (from the model card) + embeddingMaxChunkLength: 1_000, + chunkPrefix: "passage: ", + queryPrefix: "query: ", + apiInfo: { + id: "MintplexLabs/multilingual-e5-small", + name: "multilingual-e5-small", + description: + "A larger multilingual embedding model that supports 100+ languages. Requires more processing power and memory.", + lang: "100+ languages", + size: "487MB", + modelCard: "https://huggingface.co/intfloat/multilingual-e5-small", + }, + }, +}; + +module.exports = { + SUPPORTED_NATIVE_EMBEDDING_MODELS, +}; diff --git a/server/utils/EmbeddingEngines/native/index.js b/server/utils/EmbeddingEngines/native/index.js new file mode 100644 index 0000000..763b7b9 --- /dev/null +++ b/server/utils/EmbeddingEngines/native/index.js @@ -0,0 +1,310 @@ +const path = require("path"); +const fs = require("fs"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); +const { v4 } = require("uuid"); +const { SUPPORTED_NATIVE_EMBEDDING_MODELS } = require("./constants"); + +class NativeEmbedder { + static defaultModel = "Xenova/all-MiniLM-L6-v2"; + + // ONNX sessions cannot be freed on onnxruntime-node 1.14 (dispose() is a + // no-op), so we must only ever create one pipeline per model. + /** @type {Map} */ + static #pipelines = new Map(); + /** @type {Map>} */ + static #pipelinePromises = new Map(); + + /** + * Supported embedding models for native. + * @type {Record} + */ + static supportedModels = SUPPORTED_NATIVE_EMBEDDING_MODELS; + + // This is a folder that Mintplex Labs hosts for those who cannot capture the HF model download + // endpoint for various reasons. This endpoint is not guaranteed to be active or maintained + // and may go offline at any time at Mintplex Labs's discretion. + #fallbackHost = "https://cdn.anythingllm.com/support/models/"; + + constructor() { + this.className = "NativeEmbedder"; + this.model = this.getEmbeddingModel(); + this.modelInfo = this.getEmbedderInfo(); + this.cacheDir = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, `models`) + : path.resolve(__dirname, `../../../storage/models`) + ); + this.modelPath = path.resolve(this.cacheDir, ...this.model.split("/")); + this.modelDownloaded = fs.existsSync(this.modelPath); + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = this.modelInfo.maxConcurrentChunks; + this.embeddingMaxChunkLength = this.modelInfo.embeddingMaxChunkLength; + + // Make directory when it does not exist in existing installations + if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir); + this.log(`Initialized ${this.model}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * Get the selected model from the environment variable. + * @returns {string} + */ + static _getEmbeddingModel() { + const envModel = + process.env.EMBEDDING_MODEL_PREF ?? NativeEmbedder.defaultModel; + if (NativeEmbedder.supportedModels?.[envModel]) return envModel; + return NativeEmbedder.defaultModel; + } + + get embeddingPrefix() { + return NativeEmbedder.supportedModels[this.model]?.chunkPrefix || ""; + } + + get queryPrefix() { + return NativeEmbedder.supportedModels[this.model]?.queryPrefix || ""; + } + + /** + * Get the available models in an API response format + * we can use to populate the frontend dropdown. + * @returns {{id: string, name: string, description: string, lang: string, size: string, modelCard: string}[]} + */ + static availableModels() { + return Object.values(NativeEmbedder.supportedModels).map( + (model) => model.apiInfo + ); + } + + /** + * Get the embedding model to use. + * We only support a few models and will default to the default model if the environment variable is not set or not supported. + * + * Why only a few? Because we need to mirror them on the CDN so non-US users can download them. + * eg: "Xenova/all-MiniLM-L6-v2" + * eg: "Xenova/nomic-embed-text-v1" + * @returns {string} + */ + getEmbeddingModel() { + const envModel = + process.env.EMBEDDING_MODEL_PREF ?? NativeEmbedder.defaultModel; + if (NativeEmbedder.supportedModels?.[envModel]) return envModel; + return NativeEmbedder.defaultModel; + } + + /** + * Get the embedding model info. + * + * Will always fallback to the default model if the model is not supported. + * @returns {Object} + */ + getEmbedderInfo() { + const model = this.getEmbeddingModel(); + return NativeEmbedder.supportedModels[model]; + } + + #tempfilePath() { + const filename = `${v4()}.tmp`; + const tmpPath = process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "tmp") + : path.resolve(__dirname, `../../../storage/tmp`); + if (!fs.existsSync(tmpPath)) fs.mkdirSync(tmpPath, { recursive: true }); + return path.resolve(tmpPath, filename); + } + + async #writeToTempfile(filePath, data) { + try { + await fs.promises.appendFile(filePath, data, { encoding: "utf8" }); + } catch (e) { + console.error(`Error writing to tempfile: ${e}`); + } + } + + async #fetchWithHost(hostOverride = null) { + try { + // Convert ESM to CommonJS via import so we can load this library. + const pipeline = (...args) => + import("@xenova/transformers").then(({ pipeline, env }) => { + if (!this.modelDownloaded) { + // if model is not downloaded, we will log where we are fetching from. + if (hostOverride) { + env.remoteHost = hostOverride; + env.remotePathTemplate = "{model}/"; // Our S3 fallback url does not support revision File structure. + } + this.log(`Downloading ${this.model} from ${env.remoteHost}`); + } + return pipeline(...args); + }); + return { + pipeline: await pipeline("feature-extraction", this.model, { + cache_dir: this.cacheDir, + ...(!this.modelDownloaded + ? { + // Show download progress if we need to download any files + progress_callback: (data) => { + if (!data.hasOwnProperty("progress")) return; + console.log( + `\x1b[36m[NativeEmbedder - Downloading model]\x1b[0m ${ + data.file + } ${~~data?.progress}%` + ); + }, + } + : {}), + }), + retry: false, + error: null, + }; + } catch (error) { + return { + pipeline: null, + retry: hostOverride === null ? this.#fallbackHost : false, + error, + }; + } + } + + // This function will do a single fallback attempt (not recursive on purpose) to try to grab the embedder model on first embed + // since at time, some clients cannot properly download the model from HF servers due to a number of reasons (IP, VPN, etc). + // Given this model is critical and nobody reads the GitHub issues before submitting the bug, we get the same bug + // report 20 times a day: https://github.com/Mintplex-Labs/anything-llm/issues/821 + // So to attempt to monkey-patch this we have a single fallback URL to help alleviate duplicate bug reports. + async embedderClient() { + if (NativeEmbedder.#pipelines.has(this.model)) + return NativeEmbedder.#pipelines.get(this.model); + if (NativeEmbedder.#pipelinePromises.has(this.model)) + return await NativeEmbedder.#pipelinePromises.get(this.model); + + const loadPromise = (async () => { + if (!this.modelDownloaded) + this.log( + "The native embedding model has never been run and will be downloaded right now. Subsequent runs will be faster. (~23MB)" + ); + + let fetchResponse = await this.#fetchWithHost(); + if (fetchResponse.pipeline !== null) { + this.modelDownloaded = true; + NativeEmbedder.#pipelines.set(this.model, fetchResponse.pipeline); + return fetchResponse.pipeline; + } + + this.log( + `Failed to download model from primary URL. Using fallback ${fetchResponse.retry}` + ); + if (!!fetchResponse.retry) + fetchResponse = await this.#fetchWithHost(fetchResponse.retry); + if (fetchResponse.pipeline !== null) { + this.modelDownloaded = true; + NativeEmbedder.#pipelines.set(this.model, fetchResponse.pipeline); + return fetchResponse.pipeline; + } + + throw fetchResponse.error; + })(); + + NativeEmbedder.#pipelinePromises.set(this.model, loadPromise); + try { + return await loadPromise; + } finally { + NativeEmbedder.#pipelinePromises.delete(this.model); + } + } + + /** + * Apply the query prefix to the text input if it is required by the model. + * eg: nomic-embed-text-v1 requires a query prefix for embedding/searching. + * @param {string|string[]} textInput - The text to embed. + * @returns {string|string[]} The text with the prefix applied. + */ + #applyQueryPrefix(textInput) { + if (!this.queryPrefix) return textInput; + if (Array.isArray(textInput)) + return textInput.map((text) => `${this.queryPrefix}${text}`); + return `${this.queryPrefix}${textInput}`; + } + + /** + * Embed a single text input. + * @param {string|string[]} textInput - The text to embed. + * @returns {Promise>} The embedded text. + */ + async embedTextInput(textInput) { + textInput = this.#applyQueryPrefix(textInput); + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + // If you are thinking you want to edit this function - you probably don't. + // This process was benchmarked heavily on a t3.small (2GB RAM 1vCPU) + // and without careful memory management for the V8 garbage collector + // this function will likely result in an OOM on any resource-constrained deployment. + // To help manage very large documents we run a concurrent write-log each iteration + // to keep the embedding result out of memory. The `maxConcurrentChunk` is set to 25, + // as 50 seems to overflow no matter what. Given the above, memory use hovers around ~30% + // during a very large document (>100K words) but can spike up to 70% before gc. + // This seems repeatable for all document sizes. + // While this does take a while, it is zero set up and is 100% free and on-instance. + // It still may crash depending on other elements at play - so no promises it works under all conditions. + async embedChunks(textChunks = []) { + const tmpFilePath = this.#tempfilePath(); + const chunks = toChunks(textChunks, this.maxConcurrentChunks); + const chunkLen = chunks.length; + const totalChunks = textChunks.length; + + const pipeline = await this.embedderClient(); + for (let [idx, chunk] of chunks.entries()) { + if (idx === 0) await this.#writeToTempfile(tmpFilePath, "["); + let data; + let output = await pipeline(chunk, { + pooling: "mean", + normalize: true, + }); + + if (output.length === 0) { + output = null; + data = null; + continue; + } + + data = JSON.stringify(output.tolist()); + await this.#writeToTempfile(tmpFilePath, data); + this.log(`Embedded Chunk Group ${idx + 1} of ${chunkLen}`); + if (chunkLen - 1 !== idx) await this.#writeToTempfile(tmpFilePath, ","); + if (chunkLen - 1 === idx) await this.#writeToTempfile(tmpFilePath, "]"); + + reportEmbeddingProgress( + Math.min((idx + 1) * this.maxConcurrentChunks, totalChunks), + totalChunks + ); + output = null; + data = null; + } + + const embeddingResults = JSON.parse( + fs.readFileSync(tmpFilePath, { encoding: "utf-8" }) + ); + fs.rmSync(tmpFilePath, { force: true }); + return embeddingResults.length > 0 ? embeddingResults.flat() : null; + } +} + +module.exports = { + NativeEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/ollama/index.js b/server/utils/EmbeddingEngines/ollama/index.js new file mode 100644 index 0000000..308119f --- /dev/null +++ b/server/utils/EmbeddingEngines/ollama/index.js @@ -0,0 +1,140 @@ +const { + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); +const { Ollama } = require("ollama"); +const { OllamaAILLM } = require("../../AiProviders/ollama"); + +class OllamaEmbedder { + constructor() { + if (!process.env.EMBEDDING_BASE_PATH) + throw new Error("No embedding base path was set."); + if (!process.env.EMBEDDING_MODEL_PREF) + throw new Error("No embedding model was set."); + + this.className = "OllamaEmbedder"; + this.basePath = process.env.EMBEDDING_BASE_PATH; + this.model = process.env.EMBEDDING_MODEL_PREF; + this.maxConcurrentChunks = process.env.OLLAMA_EMBEDDING_BATCH_SIZE + ? Number(process.env.OLLAMA_EMBEDDING_BATCH_SIZE) + : 1; + this.embeddingMaxChunkLength = maximumChunkLength(); + this.authToken = process.env.OLLAMA_AUTH_TOKEN; + + const headers = this.authToken + ? { Authorization: `Bearer ${this.authToken}` } + : {}; + this.client = new Ollama({ + host: this.basePath, + headers, + fetch: OllamaAILLM.applyOllamaFetch(), + }); + this.log( + `initialized with model ${this.model} at ${this.basePath}. Batch size: ${this.maxConcurrentChunks}, num_ctx: ${this.embeddingMaxChunkLength}` + ); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * Checks if the Ollama service is alive by pinging the base path. + * @returns {Promise} - A promise that resolves to true if the service is alive, false otherwise. + */ + async #isAlive() { + const headers = this.authToken + ? { Authorization: `Bearer ${this.authToken}` } + : {}; + + return await fetch(this.basePath, { headers }) + .then((res) => res.ok) + .catch((e) => { + this.log(e.message); + return false; + }); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + /** + * This function takes an array of text chunks and embeds them using the Ollama API. + * Chunks are processed in batches based on the maxConcurrentChunks setting to balance + * resource usage on the Ollama endpoint. + * + * We will use the num_ctx option to set the maximum context window to the max chunk length defined by the user in the settings + * so that the maximum context window is used and content is not truncated. + * + * We also assume the default keep alive option. This could cause issues with models being unloaded and reloaded + * on low memory machines, but that is simply a user-end issue we cannot control. If the LLM and embedder are + * constantly being loaded and unloaded, the user should use another LLM or Embedder to avoid this issue. + * @param {string[]} textChunks - An array of text chunks to embed. + * @returns {Promise>} - A promise that resolves to an array of embeddings. + */ + async embedChunks(textChunks = []) { + if (!(await this.#isAlive())) + throw new Error( + `Ollama service could not be reached. Is Ollama running?` + ); + this.log( + `Embedding ${textChunks.length} chunks of text with ${this.model} in batches of ${this.maxConcurrentChunks}.` + ); + + let data = []; + let error = null; + + // Process chunks in batches based on maxConcurrentChunks + const totalBatches = Math.ceil( + textChunks.length / this.maxConcurrentChunks + ); + let currentBatch = 0; + + for (let i = 0; i < textChunks.length; i += this.maxConcurrentChunks) { + const batch = textChunks.slice(i, i + this.maxConcurrentChunks); + currentBatch++; + + try { + // Use input param instead of prompt param to support batch processing + const res = await this.client.embed({ + model: this.model, + input: batch, + options: { + // Always set the num_ctx to the max chunk length defined by the user in the settings + // so that the maximum context window is used and content is not truncated. + num_ctx: this.embeddingMaxChunkLength, + }, + }); + + const { embeddings } = res; + if (!Array.isArray(embeddings) || embeddings.length === 0) + throw new Error("Ollama returned empty embeddings for batch!"); + + // Using prompt param in embed() would return a single embedding (number[]) + // but input param returns an array of embeddings (number[][]) for batch processing. + // This is why we spread the embeddings array into the data array. + data.push(...embeddings); + reportEmbeddingProgress(data.length, textChunks.length); + this.log( + `Batch ${currentBatch}/${totalBatches}: Embedded ${embeddings.length} chunks. Total: ${data.length}/${textChunks.length}` + ); + } catch (err) { + this.log(err.message); + error = err.message; + data = []; + break; + } + } + + if (!!error) throw new Error(`Ollama Failed to embed: ${error}`); + return data.length > 0 ? data : null; + } +} + +module.exports = { + OllamaEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/openAi/index.js b/server/utils/EmbeddingEngines/openAi/index.js new file mode 100644 index 0000000..6a809d9 --- /dev/null +++ b/server/utils/EmbeddingEngines/openAi/index.js @@ -0,0 +1,102 @@ +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +class OpenAiEmbedder { + constructor() { + if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set."); + this.className = "OpenAiEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.OPEN_AI_KEY, + }); + this.model = process.env.EMBEDDING_MODEL_PREF || "text-embedding-ada-002"; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 500; + + // https://platform.openai.com/docs/guides/embeddings/embedding-models + this.embeddingMaxChunkLength = 8_191; + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + this.log(`Embedding ${textChunks.length} chunks...`); + + // Because there is a hard POST limit on how many chunks can be sent at once to OpenAI (~8mb) + // we concurrently execute each max batch of text chunks possible. + // Refer to constructor maxConcurrentChunks for more info. + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from OpenAI abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`OpenAI Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +module.exports = { + OpenAiEmbedder, +}; diff --git a/server/utils/EmbeddingEngines/openRouter/index.js b/server/utils/EmbeddingEngines/openRouter/index.js new file mode 100644 index 0000000..f82e6da --- /dev/null +++ b/server/utils/EmbeddingEngines/openRouter/index.js @@ -0,0 +1,131 @@ +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); + +class OpenRouterEmbedder { + constructor() { + if (!process.env.OPENROUTER_API_KEY) + throw new Error("No OpenRouter API key was set."); + this.className = "OpenRouterEmbedder"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, + defaultHeaders: { + "HTTP-Referer": "https://anythingllm.com", + "X-Title": "AnythingLLM", + }, + }); + this.model = process.env.EMBEDDING_MODEL_PREF || "baai/bge-m3"; + + // Limit of how many strings we can process in a single pass to stay with resource or network limits + this.maxConcurrentChunks = 500; + + // https://openrouter.ai/docs/api/reference/embeddings + this.embeddingMaxChunkLength = 8_191; + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + async embedTextInput(textInput) { + const result = await this.embedChunks( + Array.isArray(textInput) ? textInput : [textInput] + ); + return result?.[0] || []; + } + + async embedChunks(textChunks = []) { + this.log(`Embedding ${textChunks.length} document chunks...`); + const embeddingRequests = []; + let chunksProcessed = 0; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + embeddingRequests.push( + new Promise((resolve) => { + this.openai.embeddings + .create({ + model: this.model, + input: chunk, + }) + .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + resolve({ data: result?.data, error: null }); + }) + .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); + e.type = + e?.response?.data?.error?.code || + e?.response?.status || + "failed_to_embed"; + e.message = e?.response?.data?.error?.message || e.message; + resolve({ data: [], error: e }); + }); + }) + ); + } + + const { data = [], error = null } = await Promise.all( + embeddingRequests + ).then((results) => { + // If any errors were returned from OpenAI abort the entire sequence because the embeddings + // will be incomplete. + const errors = results + .filter((res) => !!res.error) + .map((res) => res.error) + .flat(); + if (errors.length > 0) { + let uniqueErrors = new Set(); + errors.map((error) => + uniqueErrors.add(`[${error.type}]: ${error.message}`) + ); + + return { + data: [], + error: Array.from(uniqueErrors).join(", "), + }; + } + return { + data: results.map((res) => res?.data || []).flat(), + error: null, + }; + }); + + if (!!error) throw new Error(`OpenRouter Failed to embed: ${error}`); + return data.length > 0 && + data.every((embd) => embd.hasOwnProperty("embedding")) + ? data.map((embd) => embd.embedding) + : null; + } +} + +async function fetchOpenRouterEmbeddingModels() { + return await fetch(`https://openrouter.ai/api/v1/embeddings/models`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }) + .then((res) => res.json()) + .then(({ data = [] }) => { + const models = {}; + data.forEach((model) => { + models[model.id] = { + id: model.id, + name: model.name || model.id, + organization: + model.id.split("/")[0].charAt(0).toUpperCase() + + model.id.split("/")[0].slice(1), + maxLength: model.context_length, + }; + }); + return models; + }) + .catch((e) => { + console.error("OpenRouter:fetchEmbeddingModels", e.message); + return {}; + }); +} + +module.exports = { + OpenRouterEmbedder, + fetchOpenRouterEmbeddingModels, +}; diff --git a/server/utils/EmbeddingEngines/voyageAi/index.js b/server/utils/EmbeddingEngines/voyageAi/index.js new file mode 100644 index 0000000..9fda4f8 --- /dev/null +++ b/server/utils/EmbeddingEngines/voyageAi/index.js @@ -0,0 +1,71 @@ +class VoyageAiEmbedder { + constructor() { + if (!process.env.VOYAGEAI_API_KEY) + throw new Error("No Voyage AI API key was set."); + + const { + VoyageEmbeddings, + } = require("@langchain/community/embeddings/voyage"); + + this.model = process.env.EMBEDDING_MODEL_PREF || "voyage-3-lite"; + this.voyage = new VoyageEmbeddings({ + apiKey: process.env.VOYAGEAI_API_KEY, + modelName: this.model, + // Voyage AI's limit per request is 128 https://docs.voyageai.com/docs/rate-limits#use-larger-batches + batchSize: 128, + }); + this.embeddingMaxChunkLength = this.#getMaxEmbeddingLength(); + } + + // https://docs.voyageai.com/docs/embeddings + #getMaxEmbeddingLength() { + switch (this.model) { + case "voyage-finance-2": + case "voyage-multilingual-2": + case "voyage-3": + case "voyage-3-lite": + case "voyage-3-large": + case "voyage-code-3": + return 32_000; + case "voyage-large-2-instruct": + case "voyage-law-2": + case "voyage-code-2": + case "voyage-large-2": + return 16_000; + case "voyage-2": + return 4_000; + default: + return 4_000; + } + } + + async embedTextInput(textInput) { + const result = await this.voyage.embedDocuments( + Array.isArray(textInput) ? textInput : [textInput] + ); + + // If given an array return the native Array[Array] format since that should be the outcome. + // But if given a single string, we need to flatten it so that we have a 1D array. + return (Array.isArray(textInput) ? result : result.flat()) || []; + } + + async embedChunks(textChunks = []) { + try { + const embeddings = await this.voyage.embedDocuments(textChunks); + return embeddings; + } catch (error) { + console.error("Voyage AI Failed to embed:", error); + if ( + error.message.includes( + "Cannot read properties of undefined (reading '0')" + ) + ) + throw new Error("Voyage AI failed to embed: Rate limit reached"); + throw error; + } + } +} + +module.exports = { + VoyageAiEmbedder, +}; diff --git a/server/utils/EmbeddingRerankers/native/index.js b/server/utils/EmbeddingRerankers/native/index.js new file mode 100644 index 0000000..5993382 --- /dev/null +++ b/server/utils/EmbeddingRerankers/native/index.js @@ -0,0 +1,257 @@ +const path = require("path"); +const fs = require("fs"); + +class NativeEmbeddingReranker { + static #model = null; + static #tokenizer = null; + static #transformers = null; + static #initializationPromise = null; + + // This is a folder that Mintplex Labs hosts for those who cannot capture the HF model download + // endpoint for various reasons. This endpoint is not guaranteed to be active or maintained + // and may go offline at any time at Mintplex Labs's discretion. + #fallbackHost = "https://cdn.anythingllm.com/support/models/"; + + constructor() { + // An alternative model to the mixedbread-ai/mxbai-rerank-xsmall-v1 model (speed on CPU is much slower for this model @ 18docs = 6s) + // Model Card: https://huggingface.co/Xenova/ms-marco-MiniLM-L-6-v2 (speed on CPU is much faster @ 18docs = 1.6s) + this.model = "Xenova/ms-marco-MiniLM-L-6-v2"; + this.cacheDir = path.resolve( + process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, `models`) + : path.resolve(__dirname, `../../../storage/models`) + ); + this.modelPath = path.resolve(this.cacheDir, ...this.model.split("/")); + // Make directory when it does not exist in existing installations + if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir); + + this.modelDownloaded = fs.existsSync( + path.resolve(this.cacheDir, this.model) + ); + this.log("Initialized"); + } + + log(text, ...args) { + console.log(`\x1b[36m[NativeEmbeddingReranker]\x1b[0m ${text}`, ...args); + } + + /** + * This function will return the host of the current reranker suite. + * If the reranker suite is not initialized, it will return the default HF host. + * @returns {string} The host of the current reranker suite. + */ + get host() { + if (!NativeEmbeddingReranker.#transformers) return "https://huggingface.co"; + try { + return new URL(NativeEmbeddingReranker.#transformers.env.remoteHost).host; + } catch { + return this.#fallbackHost; + } + } + + /** + * This function will preload the reranker suite and tokenizer. + * This is useful for reducing the latency of the first rerank call and pre-downloading the models and such + * to avoid having to wait for the models to download on the first rerank call. + */ + async preload() { + try { + this.log(`Preloading reranker suite...`); + await this.initClient(); + this.log( + `Preloaded reranker suite. Reranking is available as a service now.` + ); + return; + } catch (e) { + console.error(e); + this.log( + `Failed to preload reranker suite. Reranking will be available on the first rerank call.` + ); + return; + } + } + + async initClient() { + if ( + NativeEmbeddingReranker.#transformers && + NativeEmbeddingReranker.#model && + NativeEmbeddingReranker.#tokenizer + ) { + this.log(`Reranker suite already fully initialized - reusing.`); + return; + } + + if (NativeEmbeddingReranker.#initializationPromise) { + this.log(`Waiting for existing initialization to complete...`); + await NativeEmbeddingReranker.#initializationPromise; + return; + } + + NativeEmbeddingReranker.#initializationPromise = (async () => { + try { + const { AutoModelForSequenceClassification, AutoTokenizer, env } = + await import("@xenova/transformers"); + this.log(`Loading reranker suite...`); + NativeEmbeddingReranker.#transformers = { + AutoModelForSequenceClassification, + AutoTokenizer, + env, + }; + // Attempt to load the model and tokenizer in this order: + // 1. From local file system cache + // 2. Download and cache from remote host (hf.co) + // 3. Download and cache from fallback host (cdn.anythingllm.com) + await this.#getPreTrainedModel(); + await this.#getPreTrainedTokenizer(); + } finally { + NativeEmbeddingReranker.#initializationPromise = null; + } + })(); + + await NativeEmbeddingReranker.#initializationPromise; + } + + /** + * This function will load the model from the local file system cache, or download and cache it from the remote host. + * If the model is not found in the local file system cache, it will download and cache it from the remote host. + * If the model is not found in the remote host, it will download and cache it from the fallback host. + * @returns {Promise} The loaded model. + */ + async #getPreTrainedModel() { + if (NativeEmbeddingReranker.#model) { + this.log(`Loading model from singleton...`); + return NativeEmbeddingReranker.#model; + } + + try { + const model = + await NativeEmbeddingReranker.#transformers.AutoModelForSequenceClassification.from_pretrained( + this.model, + { + progress_callback: (p) => { + if (!this.modelDownloaded && p.status === "progress") { + this.log( + `[${this.host}] Loading model ${this.model}... ${p?.progress}%` + ); + } + }, + cache_dir: this.cacheDir, + } + ); + this.log(`Loaded model ${this.model}`); + NativeEmbeddingReranker.#model = model; + return model; + } catch (e) { + this.log( + `Failed to load model ${this.model} from ${this.host}.`, + e.message, + e.stack + ); + if ( + NativeEmbeddingReranker.#transformers.env.remoteHost === + this.#fallbackHost + ) { + this.log(`Failed to load model ${this.model} from fallback host.`); + throw e; + } + + this.log(`Falling back to fallback host. ${this.#fallbackHost}`); + NativeEmbeddingReranker.#transformers.env.remoteHost = this.#fallbackHost; + NativeEmbeddingReranker.#transformers.env.remotePathTemplate = "{model}/"; + return await this.#getPreTrainedModel(); + } + } + + /** + * This function will load the tokenizer from the local file system cache, or download and cache it from the remote host. + * If the tokenizer is not found in the local file system cache, it will download and cache it from the remote host. + * If the tokenizer is not found in the remote host, it will download and cache it from the fallback host. + * @returns {Promise} The loaded tokenizer. + */ + async #getPreTrainedTokenizer() { + if (NativeEmbeddingReranker.#tokenizer) { + this.log(`Loading tokenizer from singleton...`); + return NativeEmbeddingReranker.#tokenizer; + } + + try { + const tokenizer = + await NativeEmbeddingReranker.#transformers.AutoTokenizer.from_pretrained( + this.model, + { + progress_callback: (p) => { + if (!this.modelDownloaded && p.status === "progress") { + this.log( + `[${this.host}] Loading tokenizer ${this.model}... ${p?.progress}%` + ); + } + }, + cache_dir: this.cacheDir, + } + ); + this.log(`Loaded tokenizer ${this.model}`); + NativeEmbeddingReranker.#tokenizer = tokenizer; + return tokenizer; + } catch (e) { + this.log( + `Failed to load tokenizer ${this.model} from ${this.host}.`, + e.message, + e.stack + ); + if ( + NativeEmbeddingReranker.#transformers.env.remoteHost === + this.#fallbackHost + ) { + this.log(`Failed to load tokenizer ${this.model} from fallback host.`); + throw e; + } + + this.log(`Falling back to fallback host. ${this.#fallbackHost}`); + NativeEmbeddingReranker.#transformers.env.remoteHost = this.#fallbackHost; + NativeEmbeddingReranker.#transformers.env.remotePathTemplate = "{model}/"; + return await this.#getPreTrainedTokenizer(); + } + } + + /** + * Reranks a list of documents based on the query. + * @param {string} query - The query to rerank the documents against. + * @param {{text: string}[]} documents - The list of document text snippets to rerank. Should be output from a vector search. + * @param {Object} options - The options for the reranking. + * @param {number} options.topK - The number of top documents to return. + * @returns {Promise} - The reranked list of documents. + */ + async rerank(query, documents, options = { topK: 4 }) { + await this.initClient(); + const model = NativeEmbeddingReranker.#model; + const tokenizer = NativeEmbeddingReranker.#tokenizer; + + const start = Date.now(); + this.log(`Reranking ${documents.length} documents...`); + const inputs = tokenizer(new Array(documents.length).fill(query), { + text_pair: documents.map((doc) => doc.text), + padding: true, + truncation: true, + }); + const { logits } = await model(inputs); + const reranked = logits + .sigmoid() + .tolist() + .map(([score], i) => ({ + rerank_corpus_id: i, + rerank_score: score, + ...documents[i], + })) + .sort((a, b) => b.rerank_score - a.rerank_score) + .slice(0, options.topK); + + this.log( + `Reranking ${documents.length} documents to top ${options.topK} took ${Date.now() - start}ms` + ); + return reranked; + } +} + +module.exports = { + NativeEmbeddingReranker, +}; diff --git a/server/utils/EmbeddingWorkerManager.js b/server/utils/EmbeddingWorkerManager.js new file mode 100644 index 0000000..88eee1a --- /dev/null +++ b/server/utils/EmbeddingWorkerManager.js @@ -0,0 +1,202 @@ +const path = require("path"); +const { EventLogs } = require("../models/eventLogs"); + +/** @type {Map} */ +const runningWorkers = new Map(); + +/** @type {Map>} */ +const sseConnections = new Map(); + +/** @type {Map} Buffered events per workspace for SSE replay */ +const eventHistory = new Map(); + +/** + * Write an SSE event payload to all connected clients for a workspace. + * Also called by Document.addDocuments for the non-native embedder path. + */ +function emitProgress(slug, event) { + if (typeof event === "object" && event !== null) { + if (!eventHistory.has(slug)) eventHistory.set(slug, []); + eventHistory.get(slug).push(event); + + if (event.type === "all_complete") + setTimeout(() => eventHistory.delete(slug), 10_000); + } + + const connections = sseConnections.get(slug); + if (!connections || connections.size === 0) return; + const data = `data: ${typeof event === "string" ? event : JSON.stringify(event)}\n\n`; + for (const res of connections) { + try { + res.write(data); + } catch { + connections.delete(res); + } + } +} + +function logEmbeddingEvent(msg) { + EventLogs.logEvent( + "workspace_documents_added", + { + workspaceName: msg.workspaceSlug, + embeddedFiles: msg.embeddedFiles ?? [], + failedFiles: msg.failedFiles ?? [], + embedded: msg.embedded ?? 0, + failed: msg.failed ?? 0, + }, + msg.userId ?? null + ).catch(() => {}); +} + +function addSSEConnection(slug, res) { + if (!sseConnections.has(slug)) sseConnections.set(slug, new Set()); + sseConnections.get(slug).add(res); + + // Only replay buffered events when a worker is actively running. + // If the worker has already exited the history is stale (e.g. contains + // all_complete from a previous run) and replaying it would poison a + // new SSE connection opened for a subsequent embedding job. + if (!runningWorkers.has(slug)) return; + + const history = eventHistory.get(slug); + if (history) { + for (const event of history) { + try { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch { + break; + } + } + } +} + +function removeSSEConnection(slug, res) { + const set = sseConnections.get(slug); + if (!set) return; + set.delete(res); + if (set.size === 0) sseConnections.delete(slug); +} + +/** + * Dispatch files to an embedding worker for the native embedder. + * If a worker is already running for this workspace, appends files to it. + * Otherwise spawns a new worker via BackgroundService. + * + * @param {string} slug - Workspace slug + * @param {string[]} files - Document paths to embed + * @param {number} workspaceId - Workspace DB id + * @param {number|null} userId + */ +async function embedFiles(slug, files, workspaceId, userId) { + if (runningWorkers.has(slug)) { + const { worker } = runningWorkers.get(slug); + try { + worker.send({ type: "add_files", files }); + return; + } catch { + runningWorkers.delete(slug); + } + } + + // Clear stale event history from any previous run so new SSE + // connections don't replay old events (including all_complete). + eventHistory.delete(slug); + + const { BackgroundService } = require("./BackgroundWorkers"); + const bg = new BackgroundService(); + const scriptPath = path.resolve(bg.jobsRoot, "embedding-worker.js"); + const { worker, jobId } = await bg.spawnWorker(scriptPath); + + runningWorkers.set(slug, { worker, jobId }); + let workerCompleted = false; + worker.on("message", (msg) => { + if (!msg || !msg.type) return; + if (msg.type === "all_complete") { + workerCompleted = true; + logEmbeddingEvent(msg); + } + emitProgress(slug, msg); + }); + + worker.on("exit", (code) => { + if (runningWorkers.get(slug)?.worker === worker) { + runningWorkers.delete(slug); + } + bg.removeJob(jobId).catch(() => {}); + + if (!workerCompleted) { + emitProgress(slug, { + type: "all_complete", + workspaceSlug: slug, + error: `Worker exited unexpectedly (code ${code ?? "unknown"})`, + embedded: 0, + failed: 0, + }); + } + }); + + worker.on("error", (err) => { + console.error( + `[EmbeddingWorkerManager] Worker error for ${slug}:`, + err.message + ); + if (runningWorkers.get(slug)?.worker === worker) { + runningWorkers.delete(slug); + } + }); + + worker.send({ + type: "embed", + files, + workspaceSlug: slug, + workspaceId, + userId, + }); +} + +/** + * Remove a queued (not yet processing) file from the embedding worker. + * @param {string} slug - Workspace slug + * @param {string} filename - Document path to dequeue + * @returns {boolean} true if the message was sent to the worker + */ +function removeQueuedFile(slug, filename) { + const entry = runningWorkers.get(slug); + if (!entry) return false; + try { + entry.worker.send({ type: "remove_file", filename }); + } catch { + return false; + } + + // Scrub the file from the event history so replayed SSE state is consistent. + const history = eventHistory.get(slug); + if (history) { + const cleaned = history.filter( + (e) => !(e.filename === filename && e.type !== "file_removed") + ); + for (const e of cleaned) { + if (e.type === "batch_starting" && e.filenames) { + e.filenames = e.filenames.filter((f) => f !== filename); + e.totalDocs = e.filenames.length; + } + } + eventHistory.set(slug, cleaned); + } + return true; +} + +function isNativeEmbedder() { + const engine = process.env.EMBEDDING_ENGINE; + return !engine || engine === "native"; +} + +module.exports = { + emitProgress, + addSSEConnection, + removeSSEConnection, + embedFiles, + removeQueuedFile, + isNativeEmbedder, +}; diff --git a/server/utils/EncryptionManager/index.js b/server/utils/EncryptionManager/index.js new file mode 100644 index 0000000..6157092 --- /dev/null +++ b/server/utils/EncryptionManager/index.js @@ -0,0 +1,85 @@ +const crypto = require("crypto"); +const { dumpENV } = require("../helpers/updateENV"); + +// Class that is used to arbitrarily encrypt/decrypt string data via a persistent passphrase/salt that +// is either user defined or is created and saved to the ENV on creation. +class EncryptionManager { + #keyENV = "SIG_KEY"; + #saltENV = "SIG_SALT"; + #encryptionKey; + #encryptionSalt; + + constructor({ key = null, salt = null } = {}) { + this.#loadOrCreateKeySalt(key, salt); + this.key = crypto.scryptSync(this.#encryptionKey, this.#encryptionSalt, 32); + this.algorithm = "aes-256-cbc"; + this.separator = ":"; + + // Used to send key to collector process to be able to decrypt data since they do not share ENVs + // this value should use the CommunicationKey.encrypt process before sending anywhere outside the + // server process so it is never sent in its raw format. + this.xPayload = this.key.toString("base64"); + } + + log(text, ...args) { + console.log(`\x1b[36m[EncryptionManager]\x1b[0m ${text}`, ...args); + } + + #loadOrCreateKeySalt(_key = null, _salt = null) { + if (!!_key && !!_salt) { + this.log( + "Pre-assigned key & salt for encrypting arbitrary data was used." + ); + this.#encryptionKey = _key; + this.#encryptionSalt = _salt; + return; + } + + if (!process.env[this.#keyENV] || !process.env[this.#saltENV]) { + this.log("Self-assigning key & salt for encrypting arbitrary data."); + process.env[this.#keyENV] = crypto.randomBytes(32).toString("hex"); + process.env[this.#saltENV] = crypto.randomBytes(32).toString("hex"); + dumpENV(); + } else + this.log("Loaded existing key & salt for encrypting arbitrary data."); + + this.#encryptionKey = process.env[this.#keyENV]; + this.#encryptionSalt = process.env[this.#saltENV]; + return; + } + + encrypt(plainTextString = null) { + try { + if (!plainTextString) + throw new Error("Empty string is not valid for this method."); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + const encrypted = cipher.update(plainTextString, "utf8", "hex"); + return [ + encrypted + cipher.final("hex"), + Buffer.from(iv).toString("hex"), + ].join(this.separator); + } catch (e) { + this.log(e); + return null; + } + } + + decrypt(encryptedString) { + try { + const [encrypted, iv] = encryptedString.split(this.separator); + if (!iv) throw new Error("IV not found"); + const decipher = crypto.createDecipheriv( + this.algorithm, + this.key, + Buffer.from(iv, "hex") + ); + return decipher.update(encrypted, "hex", "utf8") + decipher.final("utf8"); + } catch (e) { + this.log(e); + return null; + } + } +} + +module.exports = { EncryptionManager }; diff --git a/server/utils/MCP/hypervisor/index.js b/server/utils/MCP/hypervisor/index.js new file mode 100644 index 0000000..000c5c5 --- /dev/null +++ b/server/utils/MCP/hypervisor/index.js @@ -0,0 +1,553 @@ +const { safeJsonParse } = require("../../http"); +const path = require("path"); +const fs = require("fs"); +const { Client } = require("@modelcontextprotocol/sdk/client/index.js"); +const { + StdioClientTransport, +} = require("@modelcontextprotocol/sdk/client/stdio.js"); +const { + SSEClientTransport, +} = require("@modelcontextprotocol/sdk/client/sse.js"); +const { + StreamableHTTPClientTransport, +} = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); +const { patchShellEnvironmentPath } = require("../../helpers/shell"); + +/** + * @typedef {'stdio' | 'http'} MCPServerTypes + */ + +/** + * @class MCPHypervisor + * @description A class that manages MCP servers found in the storage/plugins/anythingllm_mcp_servers.json file. + * This class is responsible for booting, stopping, and reloading MCP servers - it is the user responsibility for the MCP server definitions + * to me correct and also functioning tools depending on their deployment (docker vs local) as well as the security of said tools + * since MCP is basically arbitrary code execution. + * + * @notice This class is a singleton. + * @notice Each MCP tool has dependencies specific to it and this call WILL NOT check for them. + * For example, if the tools requires `npx` then the context in which AnythingLLM mains process is running will need to access npx. + * This is typically not common in our pre-built image so may not function. But this is the case anywhere MCP is used. + * + * AnythingLLM will take care of porting MCP servers to agent-callable functions via @agent directive. + * @see MCPCompatibilityLayer.convertServerToolsToPlugins + */ +class MCPHypervisor { + static _instance; + /** + * The path to the JSON file containing the MCP server definitions. + * @type {string} + */ + mcpServerJSONPath; + + /** + * The MCP servers currently running. + * @type { { [key: string]: Client & {transport: {_process: import('child_process').ChildProcess}, aibitatToolIds: string[]} } } + */ + mcps = {}; + /** + * The results of the MCP server loading process. + * @type { { [key: string]: {status: 'success' | 'failed', message: string} } } + */ + mcpLoadingResults = {}; + + constructor() { + if (MCPHypervisor._instance) return MCPHypervisor._instance; + MCPHypervisor._instance = this; + this.className = "MCPHypervisor"; + this.log("Initializing MCP Hypervisor - subsequent calls will boot faster"); + this.#setupConfigFile(); + return this; + } + + /** + * Setup the MCP server definitions file. + * Will create the file/directory if it doesn't exist already in storage/plugins with blank options + */ + #setupConfigFile() { + this.mcpServerJSONPath = + process.env.NODE_ENV === "development" + ? path.resolve( + __dirname, + `../../../storage/plugins/anythingllm_mcp_servers.json` + ) + : path.resolve( + process.env.STORAGE_DIR ?? + path.resolve(__dirname, `../../../storage`), + `plugins/anythingllm_mcp_servers.json` + ); + + if (!fs.existsSync(this.mcpServerJSONPath)) { + fs.mkdirSync(path.dirname(this.mcpServerJSONPath), { recursive: true }); + fs.writeFileSync( + this.mcpServerJSONPath, + JSON.stringify({ mcpServers: {} }, null, 2), + { encoding: "utf8" } + ); + } + + this.log(`MCP Config File: ${this.mcpServerJSONPath}`); + } + + log(text, ...args) { + console.log(`\x1b[36m[${this.className}]\x1b[0m ${text}`, ...args); + } + + /** + * Get the MCP servers from the JSON file. + * @returns { { name: string, server: { command: string, args: string[], env: { [key: string]: string } } }[] } The MCP servers. + */ + get mcpServerConfigs() { + const servers = safeJsonParse( + fs.readFileSync(this.mcpServerJSONPath, "utf8"), + { mcpServers: {} } + ); + return Object.entries(servers.mcpServers).map(([name, server]) => ({ + name, + server, + })); + } + + /** + * Remove the MCP server from the config file + * @param {string} name - The name of the MCP server to remove + * @returns {boolean} - True if the MCP server was removed, false otherwise + */ + removeMCPServerFromConfig(name) { + const servers = safeJsonParse( + fs.readFileSync(this.mcpServerJSONPath, "utf8"), + { mcpServers: {} } + ); + if (!servers.mcpServers[name]) return false; + + delete servers.mcpServers[name]; + fs.writeFileSync( + this.mcpServerJSONPath, + JSON.stringify(servers, null, 2), + "utf8" + ); + this.log(`MCP server ${name} removed from config file`); + return true; + } + + /** + * Update the suppressed tools for an MCP server + * @param {string} serverName - The name of the MCP server + * @param {string} toolName - The name of the tool to toggle + * @param {boolean} enabled - Whether the tool should be enabled (true) or suppressed (false) + * @returns {{success: boolean, error: string | null, suppressedTools: string[]}} + */ + updateSuppressedTools(serverName, toolName, enabled) { + const servers = safeJsonParse( + fs.readFileSync(this.mcpServerJSONPath, "utf8"), + { mcpServers: {} } + ); + + if (!servers.mcpServers[serverName]) { + return { + success: false, + error: `MCP server ${serverName} not found in config file.`, + suppressedTools: [], + }; + } + + const server = servers.mcpServers[serverName]; + if (!server.anythingllm) server.anythingllm = {}; + if (!Array.isArray(server.anythingllm.suppressedTools)) + server.anythingllm.suppressedTools = []; + + const suppressedTools = server.anythingllm.suppressedTools; + + if (enabled) { + const index = suppressedTools.indexOf(toolName); + if (index > -1) suppressedTools.splice(index, 1); + } else { + if (!suppressedTools.includes(toolName)) suppressedTools.push(toolName); + } + + server.anythingllm.suppressedTools = suppressedTools; + servers.mcpServers[serverName] = server; + + fs.writeFileSync( + this.mcpServerJSONPath, + JSON.stringify(servers, null, 2), + "utf8" + ); + + this.log( + `MCP server ${serverName} tool ${toolName} ${enabled ? "enabled" : "suppressed"}` + ); + return { success: true, error: null, suppressedTools }; + } + + /** + * Get the suppressed tools for an MCP server + * @param {string} serverName - The name of the MCP server + * @returns {string[]} - Array of suppressed tool names + */ + getSuppressedTools(serverName) { + const config = this.mcpServerConfigs.find((s) => s.name === serverName); + return config?.server?.anythingllm?.suppressedTools || []; + } + + /** + * Reload the MCP servers - can be used to reload the MCP servers without restarting the server or app + * and will also apply changes to the config file if any where made. + */ + async reloadMCPServers() { + this.pruneMCPServers(); + await this.bootMCPServers(); + } + + /** + * Start a single MCP server by its server name - public method + * @param {string} name - The name of the MCP server to start + * @returns {Promise<{success: boolean, error: string | null}>} + */ + async startMCPServer(name) { + if (this.mcps[name]) + return { success: false, error: `MCP server ${name} already running` }; + const config = this.mcpServerConfigs.find((s) => s.name === name); + if (!config) + return { + success: false, + error: `MCP server ${name} not found in config file`, + }; + + try { + await this.#startMCPServer(config); + this.mcpLoadingResults[name] = { + status: "success", + message: `Successfully connected to MCP server: ${name}`, + }; + + return { success: true, message: `MCP server ${name} started` }; + } catch (e) { + this.log(`Failed to start single MCP server: ${name}`, { + error: e.message, + code: e.code, + syscall: e.syscall, + path: e.path, + stack: e.stack, + }); + this.mcpLoadingResults[name] = { + status: "failed", + message: `Failed to start MCP server: ${name} [${e.code || "NO_CODE"}] ${e.message}`, + }; + + // Clean up failed connection + if (this.mcps[name]) { + this.mcps[name].close(); + delete this.mcps[name]; + } + + return { success: false, error: e.message }; + } + } + /** + * Prune a single MCP server by its server name + * @param {string} name - The name of the MCP server to prune + * @returns {boolean} - True if the MCP server was pruned, false otherwise + */ + pruneMCPServer(name) { + if (!name || !this.mcps[name]) return true; + + this.log(`Pruning MCP server: ${name}`); + const mcp = this.mcps[name]; + if (!mcp.transport) return true; + const childProcess = mcp.transport._process; + if (childProcess) childProcess.kill("SIGTERM"); + mcp.transport.close(); + + delete this.mcps[name]; + this.mcpLoadingResults[name] = { + status: "failed", + message: `Server was stopped manually by the administrator.`, + }; + return true; + } + + /** + * Prune the MCP servers - pkills and forgets all MCP servers + * @returns {void} + */ + pruneMCPServers() { + this.log(`Pruning ${Object.keys(this.mcps).length} MCP servers...`); + + for (const name of Object.keys(this.mcps)) { + if (!this.mcps[name]) continue; + const mcp = this.mcps[name]; + if (!mcp.transport) continue; + const childProcess = mcp.transport._process; + if (childProcess) + this.log(`Killing MCP ${name} (PID: ${childProcess.pid})`, { + killed: childProcess.kill("SIGTERM"), + }); + + mcp.transport.close(); + mcp.close(); + } + this.mcps = {}; + this.mcpLoadingResults = {}; + } + + /** + * Build the MCP server environment variables - ensures proper PATH and NODE_PATH + * inheritance across all platforms and deployment scenarios. + * @param {Object} server - The server definition + * @returns {Promise<{env: { [key: string]: string } | {}}}> - The environment variables + */ + async #buildMCPServerENV(server) { + const shellEnv = await patchShellEnvironmentPath(); + let baseEnv = { + PATH: + shellEnv.PATH || + process.env.PATH || + "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + NODE_PATH: + shellEnv.NODE_PATH || + process.env.NODE_PATH || + "/usr/local/lib/node_modules", + ...shellEnv, // Include all shell environment variables + }; + + // Docker-specific environment setup + if (process.env.ANYTHING_LLM_RUNTIME === "docker") { + baseEnv = { + // Fixed: NODE_PATH should point to modules directory, not node binary + NODE_PATH: "/usr/local/lib/node_modules", + PATH: "/usr/local/bin:/usr/bin:/bin", + ...baseEnv, // Allow inheritance to override docker defaults if needed + }; + } + + // No custom environment specified - return base environment + if (!server?.env || Object.keys(server.env).length === 0) { + return { env: baseEnv }; + } + + // Merge user-specified environment with base environment + // User environment takes precedence over defaults + return { + env: { + ...baseEnv, + ...server.env, + }, + }; + } + + /** + * Parse the server type from the server definition + * @param {Object} server - The server definition + * @returns {MCPServerTypes | null} - The server type + */ + #parseServerType(server) { + if ( + server.type === "sse" || + server.type === "streamable" || + server.type === "http" + ) + return "http"; + if (Object.prototype.hasOwnProperty.call(server, "command")) return "stdio"; + if (Object.prototype.hasOwnProperty.call(server, "url")) return "http"; + return null; + } + + /** + * Validate the server definition by type + * - Will throw an error if the server definition is invalid + * @param {string} name - The name of the MCP server + * @param {Object} server - The server definition + * @param {MCPServerTypes} type - The server type + * @returns {void} + */ + #validateServerDefinitionByType(name, server, type) { + if (type === "http") { + // "type" is optional for http servers - when omitted, SSE is assumed + // (see createHttpTransport). An explicit unknown value is a config error. + if ( + server.type !== undefined && + !["sse", "streamable", "http"].includes(server.type) + ) { + throw new Error("MCP server type must have sse or streamable value."); + } + + if (!server.url) { + throw new Error( + `MCP server "${name}": missing required "url" for ${server.type || "sse"} transport` + ); + } + + try { + new URL(server.url); + } catch { + throw new Error(`MCP server "${name}": invalid URL "${server.url}"`); + } + return; + } + + if (type === "stdio") { + if ( + Object.prototype.hasOwnProperty.call(server, "args") && + !Array.isArray(server.args) + ) + throw new Error("MCP server args must be an array"); + } + return; + } + + /** + * Setup the server transport by type and server definition + * @param {Object} server - The server definition + * @param {MCPServerTypes} type - The server type + * @returns {Promise} - The server transport + */ + async #setupServerTransport(server, type) { + // if not stdio then it is http or sse + if (type !== "stdio") return this.createHttpTransport(server); + + return new StdioClientTransport({ + command: server.command, + args: server?.args ?? [], + ...(await this.#buildMCPServerENV(server)), + }); + } + + /** + * Create MCP client transport for http MCP server. + * @param {Object} server - The server definition + * @returns {StreamableHTTPClientTransport | SSEClientTransport} - The server transport + */ + createHttpTransport(server) { + const url = new URL(server.url); + + // If the server block has a type property then use that to determine the transport type + switch (server.type) { + case "streamable": + case "http": + return new StreamableHTTPClientTransport(url, { + requestInit: { + headers: server.headers, + }, + }); + default: + return new SSEClientTransport(url, { + requestInit: { + headers: server.headers, + }, + }); + } + } + + /** + * @private Start a single MCP server by its server definition from the JSON file + * @param {string} name - The name of the MCP server to start + * @param {Object} server - The server definition + * @returns {Promise} + */ + async #startMCPServer({ name, server }) { + if (!name) throw new Error("MCP server name is required"); + if (!server) throw new Error("MCP server definition is required"); + const serverType = this.#parseServerType(server); + if (!serverType) throw new Error("MCP server command or url is required"); + + this.#validateServerDefinitionByType(name, server, serverType); + this.log(`Attempting to start MCP server: ${name}`); + const mcp = new Client({ name: name, version: "1.0.0" }); + const transport = await this.#setupServerTransport(server, serverType); + + // Add connection event listeners + transport.onclose = () => this.log(`${name} - Transport closed`); + transport.onerror = (error) => + this.log(`${name} - Transport error:`, error); + transport.onmessage = (message) => + this.log(`${name} - Transport message:`, message); + + // Connect and await the connection with a timeout + this.mcps[name] = mcp; + const connectionPromise = mcp.connect(transport); + + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("Connection timeout")), + 30_000 + ); // 30 second timeout + }); + + try { + await Promise.race([connectionPromise, timeoutPromise]); + if (timeoutId) clearTimeout(timeoutId); + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + throw error; + } + return true; + } + + /** + * Boot the MCP servers according to the server definitions. + * This function will skip booting MCP servers if they are already running. + * @returns { Promise<{ [key: string]: {status: string, message: string} }> } The results of the boot process. + */ + async bootMCPServers() { + if (Object.keys(this.mcps).length > 0) { + this.log("MCP Servers already running, skipping boot."); + return this.mcpLoadingResults; + } + + const serverDefinitions = this.mcpServerConfigs; + for (const { name, server } of serverDefinitions) { + if ( + server.anythingllm?.hasOwnProperty("autoStart") && + server.anythingllm.autoStart === false + ) { + this.log( + `MCP server ${name} has anythingllm.autoStart property set to false, skipping boot!` + ); + this.mcpLoadingResults[name] = { + status: "failed", + message: `MCP server ${name} has anythingllm.autoStart property set to false, boot skipped!`, + }; + continue; + } + + try { + await this.#startMCPServer({ name, server }); + // Verify the connection is alive? + // if (!(await mcp.ping())) throw new Error('Connection failed to establish'); + this.mcpLoadingResults[name] = { + status: "success", + message: `Successfully connected to MCP server: ${name}`, + }; + } catch (e) { + this.log(`Failed to start MCP server: ${name}`, { + error: e.message, + code: e.code, + syscall: e.syscall, + path: e.path, + stack: e.stack, // Adding stack trace for better debugging + }); + this.mcpLoadingResults[name] = { + status: "failed", + message: `Failed to start MCP server: ${name} [${e.code || "NO_CODE"}] ${e.message}`, + }; + + // Clean up failed connection + if (this.mcps[name]) { + this.mcps[name].close(); + delete this.mcps[name]; + } + } + } + + const runningServers = Object.keys(this.mcps); + this.log( + `Successfully started ${runningServers.length} MCP servers:`, + runningServers + ); + return this.mcpLoadingResults; + } +} + +module.exports = MCPHypervisor; diff --git a/server/utils/MCP/index.js b/server/utils/MCP/index.js new file mode 100644 index 0000000..f37189a --- /dev/null +++ b/server/utils/MCP/index.js @@ -0,0 +1,285 @@ +const MCPHypervisor = require("./hypervisor"); + +class MCPCompatibilityLayer extends MCPHypervisor { + static _instance; + + constructor() { + super(); + if (MCPCompatibilityLayer._instance) return MCPCompatibilityLayer._instance; + MCPCompatibilityLayer._instance = this; + } + + /** + * Get all of the active MCP servers as plugins we can load into agents. + * This will also boot all MCP servers if they have not been started yet. + * @returns {Promise} Array of flow names in @@mcp_{name} format + */ + async activeMCPServers() { + await this.bootMCPServers(); + return Object.keys(this.mcps).flatMap((name) => `@@mcp_${name}`); + } + + /** + * Convert an MCP server name to an AnythingLLM Agent plugin + * @param {string} name - The base name of the MCP server to convert - not the tool name. eg: `docker-mcp` not `docker-mcp:list-containers` + * @param {Object} aibitat - The aibitat object to pass to the plugin + * @returns {Promise<{name: string, description: string, plugin: Function}[]|null>} Array of plugin configurations or null if not found + */ + async convertServerToolsToPlugins(name, _aibitat = null) { + const mcp = this.mcps[name]; + if (!mcp) return null; + + let tools; + try { + const response = await mcp.listTools(); + tools = response.tools; + } catch (error) { + this.log(`Failed to list tools for MCP server ${name}:`, error); + return null; + } + if (!tools || !tools.length) return null; + + const suppressedTools = this.getSuppressedTools(name); + const totalTools = tools.length; + tools = tools.filter((tool) => !suppressedTools.includes(tool.name)); + const suppressedCount = totalTools - tools.length; + + if (suppressedCount > 0) { + this.log( + `MCP server ${name}: ${suppressedCount} tool(s) suppressed, ${tools.length} tool(s) enabled` + ); + } + + if (!tools.length) { + this.log(`MCP server ${name}: All tools are suppressed, skipping`); + return null; + } + + const plugins = []; + for (const tool of tools) { + plugins.push({ + name: `${name}-${tool.name}`, + description: tool.description, + plugin: function () { + return { + name: `${name}-${tool.name}`, + setup: (aibitat) => { + aibitat.function({ + super: aibitat, + name: `${name}-${tool.name}`, + controller: new AbortController(), + description: tool.description, + isMCPTool: true, + examples: [], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + ...tool.inputSchema, + }, + handler: async function (args = {}) { + try { + const mcpLayer = new MCPCompatibilityLayer(); + const currentMcp = mcpLayer.mcps[name]; + if (!currentMcp) + throw new Error( + `MCP server ${name} is not currently running` + ); + + aibitat.handlerProps.log( + `Executing MCP server: ${name}:${tool.name} with args:`, + args + ); + aibitat.introspect( + `Executing MCP server: ${name} with ${JSON.stringify(args, null, 2)}` + ); + const result = await currentMcp.callTool({ + name: tool.name, + arguments: args, + }); + aibitat.handlerProps.log( + `MCP server: ${name}:${tool.name} completed successfully`, + result + ); + aibitat.introspect( + `MCP server: ${name}:${tool.name} completed successfully` + ); + return MCPCompatibilityLayer.returnMCPResult(result); + } catch (error) { + aibitat.handlerProps.log( + `MCP server: ${name}:${tool.name} failed with error:`, + error + ); + aibitat.introspect( + `MCP server: ${name}:${tool.name} failed with error:`, + error + ); + return `The tool ${name}:${tool.name} failed with error: ${error?.message || "An unknown error occurred"}`; + } + }, + }); + }, + }; + }, + toolName: `${name}:${tool.name}`, + }); + } + + return plugins; + } + + /** + * Returns the MCP servers that were loaded or attempted to be loaded + * so that we can display them in the frontend for review or error logging. + * @returns {Promise<{ + * name: string, + * running: boolean, + * tools: {name: string, description: string, inputSchema: Object}[], + * process: {pid: number, cmd: string}|null, + * error: string|null + * }[]>} - The active MCP servers + */ + async servers() { + await this.bootMCPServers(); + const servers = []; + for (const [name, result] of Object.entries(this.mcpLoadingResults)) { + const config = this.mcpServerConfigs.find((s) => s.name === name); + + if (result.status === "failed") { + servers.push({ + name, + config: config?.server || null, + running: false, + tools: [], + error: result.message, + process: null, + }); + continue; + } + + const mcp = this.mcps[name]; + if (!mcp) { + delete this.mcpLoadingResults[name]; + delete this.mcps[name]; + continue; + } + + // ping() and listTools() can throw - e.g. when a tool's outputSchema + // contains a $ref the MCP SDK cannot resolve. If we let that bubble up + // it crashes the entire list, so a single bad server would hide every + // other server. Keep the server visible with its error instead. + try { + const online = !!(await mcp.ping()); + const tools = (online ? (await mcp.listTools()).tools : []).filter( + (tool) => !tool.name.startsWith("handle_mcp_connection_mcp_") + ); + servers.push({ + name, + config: config?.server || null, + running: online, + tools, + error: null, + process: { + pid: mcp.transport?.process?.pid || null, + }, + }); + } catch (error) { + this.log(`Failed to list tools for MCP server ${name}:`, error); + servers.push({ + name, + config: config?.server || null, + running: false, + tools: [], + error: error?.message || "Failed to load tools for this MCP server.", + process: null, + }); + } + } + return servers; + } + + /** + * Toggle the MCP server (start or stop) + * @param {string} name - The name of the MCP server to toggle + * @returns {Promise<{success: boolean, error: string | null}>} + */ + async toggleServerStatus(name) { + const server = this.mcpServerConfigs.find((s) => s.name === name); + if (!server) + return { + success: false, + error: `MCP server ${name} not found in config file.`, + }; + const mcp = this.mcps[name]; + const online = !!mcp ? !!(await mcp.ping()) : false; // If the server is not in the mcps object, it is not running + + if (online) { + const killed = this.pruneMCPServer(name); + return { + success: killed, + error: killed ? null : `Failed to kill MCP server: ${name}`, + }; + } else { + const startupResult = await this.startMCPServer(name); + return { success: startupResult.success, error: startupResult.error }; + } + } + + /** + * Delete the MCP server - will also remove it from the config file + * @param {string} name - The name of the MCP server to delete + * @returns {Promise<{success: boolean, error: string | null}>} + */ + async deleteServer(name) { + const server = this.mcpServerConfigs.find((s) => s.name === name); + if (!server) + return { + success: false, + error: `MCP server ${name} not found in config file.`, + }; + + const mcp = this.mcps[name]; + const online = !!mcp ? !!(await mcp.ping()) : false; // If the server is not in the mcps object, it is not running + if (online) this.pruneMCPServer(name); + this.removeMCPServerFromConfig(name); + + delete this.mcps[name]; + delete this.mcpLoadingResults[name]; + this.log(`MCP server was killed and removed from config file: ${name}`); + return { success: true, error: null }; + } + + /** + * Return the result of an MCP server call as a string + * This will handle circular references and bigints since an MCP server can return any type of data. + * @param {Object} result - The result to return + * @returns {string} The result as a string + */ + static returnMCPResult(result) { + if (typeof result !== "object" || result === null) return String(result); + + const seen = new WeakSet(); + try { + return JSON.stringify(result, (key, value) => { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }); + } catch (e) { + return `[Unserializable: ${e.message}]`; + } + } + + /** + * Toggle tool suppression for an MCP server + * @param {string} serverName - The name of the MCP server + * @param {string} toolName - The name of the tool to toggle + * @param {boolean} enabled - Whether the tool should be enabled (true) or suppressed (false) + * @returns {Promise<{success: boolean, error: string | null, suppressedTools: string[]}>} + */ + async toggleToolSuppression(serverName, toolName, enabled) { + return this.updateSuppressedTools(serverName, toolName, enabled); + } +} +module.exports = MCPCompatibilityLayer; diff --git a/server/utils/PasswordRecovery/index.js b/server/utils/PasswordRecovery/index.js new file mode 100644 index 0000000..75ec66f --- /dev/null +++ b/server/utils/PasswordRecovery/index.js @@ -0,0 +1,103 @@ +const bcrypt = require("bcryptjs"); +const { v4, validate } = require("uuid"); +const { User } = require("../../models/user"); +const { + RecoveryCode, + PasswordResetToken, +} = require("../../models/passwordRecovery"); + +async function generateRecoveryCodes(userId) { + const newRecoveryCodes = []; + const plainTextCodes = []; + for (let i = 0; i < 4; i++) { + const code = v4(); + const hashedCode = bcrypt.hashSync(code, 10); + newRecoveryCodes.push({ + user_id: userId, + code_hash: hashedCode, + }); + plainTextCodes.push(code); + } + + const { error } = await RecoveryCode.createMany(newRecoveryCodes); + if (!!error) throw new Error(error); + + const { user: success } = await User._update(userId, { + seen_recovery_codes: true, + }); + if (!success) throw new Error("Failed to generate user recovery codes!"); + + return plainTextCodes; +} + +async function recoverAccount(username = "", recoveryCodes = []) { + const user = await User.get({ username: String(username) }); + if (!user) return { success: false, error: "Invalid recovery codes." }; + + // If hashes do not exist for a user + // because this is a user who has not logged out and back in since upgrade. + const allUserHashes = await RecoveryCode.hashesForUser(user.id); + if (allUserHashes.length < 4) + return { success: false, error: "Invalid recovery codes." }; + + // If they tried to send more than two unique codes, we only take the first two + const uniqueRecoveryCodes = [...new Set(recoveryCodes)] + .map((code) => code.trim()) + .filter((code) => validate(code)) // we know that any provided code must be a uuid v4. + .slice(0, 2); + if (uniqueRecoveryCodes.length !== 2) + return { success: false, error: "Invalid recovery codes." }; + + const validCodes = uniqueRecoveryCodes.every((code) => { + let valid = false; + allUserHashes.forEach((hash) => { + if (bcrypt.compareSync(code, hash)) valid = true; + }); + return valid; + }); + if (!validCodes) return { success: false, error: "Invalid recovery codes." }; + + const { passwordResetToken, error } = await PasswordResetToken.create( + user.id + ); + if (!!error) return { success: false, error }; + return { success: true, resetToken: passwordResetToken.token }; +} + +async function resetPassword(token, _newPassword = "", confirmPassword = "") { + const newPassword = String(_newPassword).trim(); // No spaces in passwords + if (!newPassword) throw new Error("Invalid password."); + if (newPassword !== String(confirmPassword)) + throw new Error("Passwords do not match"); + + const resetToken = await PasswordResetToken.findUnique({ + token: String(token), + }); + if (!resetToken || resetToken.expiresAt < new Date()) { + return { success: false, message: "Invalid reset token" }; + } + + // JOI password rules will be enforced inside .update. + const { error } = await User.update(resetToken.user_id, { + password: newPassword, + }); + + // seen_recovery_codes is not publicly writable + // so we have to do direct update here + await User._update(resetToken.user_id, { + seen_recovery_codes: false, + }); + + if (error) return { success: false, message: error }; + await PasswordResetToken.deleteMany({ user_id: resetToken.user_id }); + await RecoveryCode.deleteMany({ user_id: resetToken.user_id }); + + // New codes are provided on first new login. + return { success: true, message: "Password reset successful" }; +} + +module.exports = { + recoverAccount, + resetPassword, + generateRecoveryCodes, +}; diff --git a/server/utils/PushNotifications/index.js b/server/utils/PushNotifications/index.js new file mode 100644 index 0000000..0bf6b12 --- /dev/null +++ b/server/utils/PushNotifications/index.js @@ -0,0 +1,228 @@ +const webpush = require("web-push"); +const fs = require("fs"); +const path = require("path"); +const { User } = require("../../models/user"); +const { SystemSettings } = require("../../models/systemSettings"); +const { safeJsonParse } = require("../http"); + +/** + * For more options, see: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options + * @typedef {Object} PushNotificationPayload + * @property {string} title - The title of the notification. + * @property {string} body - The message of the notification. + * @property {Object} data - Unstructured data for the notification. Use this for anything non-standard. + * @property {string} [data.onClickUrl] - The URL to open when the notification is clicked. Note: Can be relative or absolute. + * @property {Object[]} actions - The actions for the notification. + * @property {string} [actions[].action] - The action to perform when the notification is clicked. Handled in the service worker. + * @property {string} [actions[].title] - The title of the action to show in the Options dropdown + * @property {string} image - A string containing the URL of an image to be displayed in the notification. + */ + +class PushNotifications { + static mailTo = "anythingllm@localhost"; + /** + * @type {PushNotifications} + */ + static instance = null; + + /** + * The VAPID keys for the push notification service. + * @type {{publicKey: string | null, privateKey: string | null}} + */ + #vapidKeys = { + publicKey: null, + privateKey: null, + }; + + /** + * The subscriptions for the push notification service. + * @type {Map} + */ + #subscriptions = new Map(); + + constructor() { + if (PushNotifications.instance) return PushNotifications.instance; + PushNotifications.instance = this; + } + + #log(text, ...args) { + console.log(`\x1b[36m[PushNotifications]\x1b[0m ${text}`, ...args); + } + + get pushService() { + try { + const vapidKeys = this.existingVapidKeys; + if (!vapidKeys.publicKey || !vapidKeys.privateKey) + throw new Error( + "VAPID keys not found. Make sure they are generated in the main process first." + ); + webpush.setVapidDetails( + `mailto:${this.mailTo}`, + vapidKeys.publicKey, + vapidKeys.privateKey + ); + return webpush; + } catch (e) { + console.error("Failed to set VAPID details", e); + return null; + } + } + + get storagePath() { + return process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../storage`, "push-notifications") + : path.resolve(process.env.STORAGE_DIR, "push-notifications"); + } + + get primarySubscriptionPath() { + return path.resolve(this.storagePath, `primary-subscription.json`); + } + + get existingVapidKeys() { + // Already loaded and binded to the instance + if (this.#vapidKeys.publicKey && this.#vapidKeys.privateKey) + return this.#vapidKeys; + + const vapidKeysPath = path.resolve(this.storagePath, `vapid-keys.json`); + if (!fs.existsSync(vapidKeysPath)) + return { publicKey: null, privateKey: null }; + + const existingVapidKeys = JSON.parse( + fs.readFileSync(vapidKeysPath, "utf8") + ); + this.#log(`Loaded existing VAPID keys!`); + this.#vapidKeys.publicKey = existingVapidKeys.publicKey; + this.#vapidKeys.privateKey = existingVapidKeys.privateKey; + return this.#vapidKeys; + } + + get publicVapidKey() { + return this.existingVapidKeys.publicKey; + } + + /** + * Load the subscriptions for the push notification service. + * In single user mode, the subscription is stored in the primary-subscription.json file. + * In multi user mode, the subscriptions are stored in the database so we grab them from there + * and store them in the #subscriptions map for reference later. + * @returns {Promise} + */ + async loadSubscriptions() { + const isMultiUserMode = await SystemSettings.isMultiUserMode(); + if (isMultiUserMode) { + const users = await User._where({ + web_push_subscription_config: { not: null }, + }); + for (const user of users) { + const subscription = safeJsonParse( + user.web_push_subscription_config, + null + ); + if (subscription) this.#subscriptions.set(user.id, subscription); + } + this.#log(`Loaded ${this.#subscriptions.size} existing subscriptions.`); + return; + } + + this.#log("Loading single user mode subscriptions..."); + if (!fs.existsSync(this.primarySubscriptionPath)) return; + const subscription = JSON.parse( + fs.readFileSync(this.primarySubscriptionPath, "utf8") + ); + if (subscription) this.#subscriptions.set("primary", subscription); + this.#log(`Loaded primary user's existing subscription.`); + } + + /** + * Register a new subscription for a user. + * In single user mode, the userId is mapped to "primary" + * In multi user mode, the userId is the user's id in the database + * + * @param {Object|null} user - The user to register the subscription for. + * @param {Object} subscription - The subscription to register. + * @returns {Promise} + */ + async registerSubscription(user = null, subscription) { + let userId = user?.id || "primary"; + this.#subscriptions.set(userId, subscription); + + // If this was a real user, write the subscription to the database + if (!!user) { + await User._update(user.id, { + web_push_subscription_config: JSON.stringify(subscription), + }); + this.#log(`Registered or updated subscription for user - ${user.id}`); + } else { + if (!fs.existsSync(this.storagePath)) + fs.mkdirSync(this.storagePath, { recursive: true }); + fs.writeFileSync( + this.primarySubscriptionPath, + JSON.stringify(subscription, null, 2) + ); + this.#log(`Registered or updated primary user's subscription.`); + } + return this; + } + + /** + * Send a push notification to all subscribed clients. + * @param {Object} options - The options for the notification. + * @param {"primary"|number} [options.to] - The subscription to send the notification to. "all" sends to all subscriptions, "primary" sends to the primary user (single user mode only), a number sends subscription to specific user + * @param {PushNotificationPayload} [options.payload] - The payload to send to the clients. + * @returns {Promise} + */ + sendNotification({ to = "primary", payload = {} } = {}) { + if (this.#subscriptions.size === 0) + return this.#log(".sendNotification() - No subscriptions found"); + if (!this.#subscriptions.has(to)) + return this.#log( + `.sendNotification() - Subscription for user ${to} not found` + ); + this.#log(`.sendNotification() - Sending notification to user ${to}`); + return this.pushService + .sendNotification(this.#subscriptions.get(to), JSON.stringify(payload)) + .then((res) => { + this.#log( + `.sendNotification() - Delivered (status: ${res.statusCode})` + ); + }) + .catch((err) => { + this.#log(`.sendNotification() - Failed: ${err.message}`); + }); + } + + /** + * Setup the push notification service. + * This will generate new VAPID keys if they don't exist and save them to the storage path. + * It will also load the subscriptions from the database or the primary-subscription.json file. + * @returns {Promise} + */ + static async setupPushNotificationService() { + const instance = PushNotifications.instance; + const existingVapidKeys = instance.existingVapidKeys; + + if (!existingVapidKeys.publicKey || !existingVapidKeys.privateKey) { + instance.#log("Generating new VAPID keys..."); + const vapidKeys = webpush.generateVAPIDKeys(); + instance.#vapidKeys.publicKey = vapidKeys.publicKey; + instance.#vapidKeys.privateKey = vapidKeys.privateKey; + instance.#log(`New VAPID keys generated!`); + if (!fs.existsSync(instance.storagePath)) + fs.mkdirSync(instance.storagePath, { recursive: true }); + fs.writeFileSync( + path.resolve(instance.storagePath, `vapid-keys.json`), + JSON.stringify(vapidKeys, null, 2) + ); + } + + await instance.loadSubscriptions(); + instance.pushService; + return; + } +} + +module.exports = { + pushNotificationService: new PushNotifications(), + PushNotifications, +}; diff --git a/server/utils/SpeechToText/deepgram/index.js b/server/utils/SpeechToText/deepgram/index.js new file mode 100644 index 0000000..ac9df6b --- /dev/null +++ b/server/utils/SpeechToText/deepgram/index.js @@ -0,0 +1,79 @@ +class DeepgramSTT { + constructor() { + if (!process.env.STT_DEEPGRAM_API_KEY) + throw new Error("No Deepgram API key was set."); + + this.apiKey = process.env.STT_DEEPGRAM_API_KEY; + this.model = process.env.STT_DEEPGRAM_MODEL ?? "nova-3"; + this.endpoint = "https://api.deepgram.com/v1/listen"; + this.#log(`Service ready with model: ${this.model}`); + } + + #log(text, ...args) { + console.log(`\x1b[32m[DeepgramSTT]\x1b[0m ${text}`, ...args); + } + + // Map common audio file extensions to a Content-Type that Deepgram accepts. + // Deepgram auto-detects most containers from the bytes but a hint is best. + #contentTypeFromFilename(filename) { + const ext = filename.split(".").pop()?.toLowerCase(); + switch (ext) { + case "wav": + return "audio/wav"; + case "mp3": + return "audio/mpeg"; + case "ogg": + case "oga": + return "audio/ogg"; + case "m4a": + case "mp4": + return "audio/mp4"; + case "flac": + return "audio/flac"; + case "webm": + default: + return "audio/webm"; + } + } + + /** + * Transcribes an audio buffer to text using the Deepgram pre-recorded REST API. + * @param {Buffer} audioBuffer - The audio buffer to be transcribed. + * @param {string} filename - Original filename, used to hint the audio container/codec to Deepgram. + * @returns {Promise} The transcribed text. + */ + async transcribe(audioBuffer, filename = "audio.webm") { + const url = new URL(this.endpoint); + url.searchParams.set("model", this.model); + url.searchParams.set("smart_format", "true"); + + return await fetch(url.toString(), { + method: "POST", + headers: { + Authorization: `Token ${this.apiKey}`, + "Content-Type": this.#contentTypeFromFilename(filename), + }, + body: audioBuffer, + }) + .then(async (response) => { + if (!response.ok) { + const errBody = await response.text().catch(() => ""); + throw new Error( + `Deepgram transcription failed (${response.status}) - ${errBody}` + ); + } + return response.json(); + }) + .then((result) => { + return ( + result?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? "" + ); + }) + .catch((error) => { + this.#log(`Deepgram transcription failed - ${error.message}`); + throw new Error(`Deepgram transcription failed - ${error.message}`); + }); + } +} + +module.exports = { DeepgramSTT }; diff --git a/server/utils/SpeechToText/groq/index.js b/server/utils/SpeechToText/groq/index.js new file mode 100644 index 0000000..adafa11 --- /dev/null +++ b/server/utils/SpeechToText/groq/index.js @@ -0,0 +1,35 @@ +class GroqSTT { + constructor() { + if (!process.env.STT_GROQ_API_KEY) + throw new Error("No Groq API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: "https://api.groq.com/openai/v1", + apiKey: process.env.STT_GROQ_API_KEY, + }); + this.model = process.env.STT_GROQ_MODEL ?? "whisper-large-v3-turbo"; + } + + #log(text, ...args) { + console.log(`\x1b[32m[GroqSTT]\x1b[0m ${text}`, ...args); + } + + /** + * Transcribes an audio buffer to text using the Groq STT service. + * @param {Buffer} audioBuffer - The audio buffer to be transcribed. + * @param {string} filename - Original filename, used to hint the audio container/codec to Groq. + * @returns {Promise} The transcribed text. + */ + async transcribe(audioBuffer, filename = "audio.webm") { + const { toFile } = require("openai"); + this.#log(`Transcribing audio buffer to text using model: ${this.model}`); + const file = await toFile(audioBuffer, filename); + const result = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + }); + return result?.text ?? ""; + } +} + +module.exports = { GroqSTT }; diff --git a/server/utils/SpeechToText/helpers.js b/server/utils/SpeechToText/helpers.js new file mode 100644 index 0000000..e8baa44 --- /dev/null +++ b/server/utils/SpeechToText/helpers.js @@ -0,0 +1,36 @@ +const fs = require("fs/promises"); +const path = require("path"); +const { v4 } = require("uuid"); +const { CollectorApi } = require("../collectorApi"); +const { hotdirPath, isWithin } = require("../files"); + +/** + * Convert an audio buffer to a 16kHz mono WAV buffer via the collector's + * FFMPEG wrapper. Use this when the downstream STT provider (e.g. Lemonade) + * runs a whisper.cpp backend that rejects webm/opus input. + * @param {Buffer} audioBuffer - Source audio buffer. + * @param {string} extension - Source file extension including the leading dot (e.g. ".webm"). + * @returns {Promise} The converted WAV buffer. + */ +async function convertAudioBufferToWav(audioBuffer, extension) { + let wavPath = null; + const sourceFilename = `stt-${v4()}${extension}`; + const sourcePath = path.resolve(hotdirPath, sourceFilename); + if (!isWithin(hotdirPath, sourcePath)) + throw new Error("Source path is outside the hotdir."); + + try { + await fs.writeFile(sourcePath, audioBuffer); + const result = await new CollectorApi().convertAudioToWav(sourceFilename); + if (!result?.success || !result?.wavFilename) + throw new Error(result?.reason || "Audio conversion failed."); + + wavPath = path.resolve(hotdirPath, result.wavFilename); + return await fs.readFile(wavPath); + } finally { + await fs.rm(sourcePath, { force: true }).catch(() => {}); + if (wavPath) await fs.rm(wavPath, { force: true }).catch(() => {}); + } +} + +module.exports = { convertAudioBufferToWav }; diff --git a/server/utils/SpeechToText/index.js b/server/utils/SpeechToText/index.js new file mode 100644 index 0000000..11dadcc --- /dev/null +++ b/server/utils/SpeechToText/index.js @@ -0,0 +1,26 @@ +function getSTTProvider() { + const provider = process.env.STT_PROVIDER || "native"; + switch (provider) { + case "openai": + const { OpenAiSTT } = require("./openAi"); + return new OpenAiSTT(); + case "lemonade": + const { LemonadeSTT } = require("./lemonade"); + return new LemonadeSTT(); + case "deepgram": + const { DeepgramSTT } = require("./deepgram"); + return new DeepgramSTT(); + case "generic-openai": + const { GenericOpenAiSTT } = require("./openAiGeneric"); + return new GenericOpenAiSTT(); + case "groq": + const { GroqSTT } = require("./groq"); + return new GroqSTT(); + default: + throw new Error( + `STT_PROVIDER "${provider}" is not a server-side provider.` + ); + } +} + +module.exports = { getSTTProvider }; diff --git a/server/utils/SpeechToText/lemonade/index.js b/server/utils/SpeechToText/lemonade/index.js new file mode 100644 index 0000000..8b5d38e --- /dev/null +++ b/server/utils/SpeechToText/lemonade/index.js @@ -0,0 +1,54 @@ +const path = require("path"); +const { parseLemonadeServerEndpoint } = require("../../AiProviders/lemonade"); +const { convertAudioBufferToWav } = require("../helpers"); + +class LemonadeSTT { + constructor() { + if (!process.env.STT_LEMONADE_BASE_PATH) + throw new Error("No Lemonade base path was set."); + + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + baseURL: parseLemonadeServerEndpoint( + process.env.STT_LEMONADE_BASE_PATH, + "openai" + ), + apiKey: process.env.LEMONADE_LLM_API_KEY || null, + }); + this.model = process.env.STT_LEMONADE_MODEL_PREF ?? "whisper-1"; + this.#log( + `Service (${process.env.STT_LEMONADE_BASE_PATH}) with model: ${this.model}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[LemonadeSTT]\x1b[0m ${text}`, ...args); + } + + /** + * Transcribes an audio buffer to text using the Lemonade STT service. + * Whisper backends behind Lemonade reject webm/opus, so any non-wav input + * is converted to 16kHz mono WAV via the collector before being forwarded. + * @param {Buffer} audioBuffer - The audio buffer to be transcribed. + * @param {string} filename - Original filename, used to hint the audio container/codec to the service. + * @returns {Promise} The transcribed text. + */ + async transcribe(audioBuffer, filename = "audio.webm") { + const { toFile } = require("openai"); + const extension = path.extname(filename).toLowerCase() || ".webm"; + let payloadBuffer = audioBuffer; + let payloadFilename = filename; + if (extension !== ".wav") { + payloadBuffer = await convertAudioBufferToWav(audioBuffer, extension); + payloadFilename = "audio.wav"; + } + const file = await toFile(payloadBuffer, payloadFilename); + const result = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + }); + return result?.text ?? ""; + } +} + +module.exports = { LemonadeSTT }; diff --git a/server/utils/SpeechToText/openAi/index.js b/server/utils/SpeechToText/openAi/index.js new file mode 100644 index 0000000..a657c96 --- /dev/null +++ b/server/utils/SpeechToText/openAi/index.js @@ -0,0 +1,28 @@ +class OpenAiSTT { + constructor() { + if (!process.env.OPEN_AI_KEY) throw new Error("No OpenAI API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.OPEN_AI_KEY, + }); + this.model = process.env.STT_OPEN_AI_MODEL ?? "whisper-1"; + } + + /** + * Transcribes an audio buffer to text using the OpenAI STT service. + * @param {Buffer} audioBuffer - The audio buffer to be transcribed. + * @param {string} filename - Original filename, used to hint the audio container/codec to OpenAI. + * @returns {Promise} The transcribed text. + */ + async transcribe(audioBuffer, filename = "audio.webm") { + const { toFile } = require("openai"); + const file = await toFile(audioBuffer, filename); + const result = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + }); + return result?.text ?? ""; + } +} + +module.exports = { OpenAiSTT }; diff --git a/server/utils/SpeechToText/openAiGeneric/index.js b/server/utils/SpeechToText/openAiGeneric/index.js new file mode 100644 index 0000000..e60fc22 --- /dev/null +++ b/server/utils/SpeechToText/openAiGeneric/index.js @@ -0,0 +1,58 @@ +const { convertAudioBufferToWav } = require("../helpers"); +const path = require("path"); + +class GenericOpenAiSTT { + constructor() { + if (!process.env.STT_OPEN_AI_COMPATIBLE_ENDPOINT) + throw new Error( + "No OpenAI compatible endpoint was set. Please set this to use your OpenAI compatible STT service." + ); + if (!process.env.STT_OPEN_AI_COMPATIBLE_KEY) + this.#log( + "No OpenAI compatible API key was set. You might need to set this to use your OpenAI compatible STT service." + ); + if (!process.env.STT_OPEN_AI_COMPATIBLE_MODEL) + this.#log( + "No OpenAI compatible STT model was set. We will use the default model 'whisper-1'. This may not exist or be valid for your selected endpoint." + ); + + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.STT_OPEN_AI_COMPATIBLE_KEY || null, + baseURL: process.env.STT_OPEN_AI_COMPATIBLE_ENDPOINT, + }); + this.model = process.env.STT_OPEN_AI_COMPATIBLE_MODEL ?? "whisper-1"; + this.#log( + `Service (${process.env.STT_OPEN_AI_COMPATIBLE_ENDPOINT}) with model: ${this.model}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[OpenAiGenericSTT]\x1b[0m ${text}`, ...args); + } + + /** + * Transcribes an audio buffer to text using an OpenAI-compatible STT service. + * @param {Buffer} audioBuffer - The audio buffer to be transcribed. + * @param {string} filename - Original filename, used to hint the audio container/codec to the service. + * @returns {Promise} The transcribed text. + */ + async transcribe(audioBuffer, filename = "audio.webm") { + const { toFile } = require("openai"); + const extension = path.extname(filename).toLowerCase() || ".webm"; + let payloadBuffer = audioBuffer; + let payloadFilename = filename; + if (extension !== ".wav") { + payloadBuffer = await convertAudioBufferToWav(audioBuffer, extension); + payloadFilename = "audio.wav"; + } + const file = await toFile(payloadBuffer, payloadFilename); + const result = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + }); + return result?.text ?? ""; + } +} + +module.exports = { GenericOpenAiSTT }; diff --git a/server/utils/TextSplitter/index.js b/server/utils/TextSplitter/index.js new file mode 100644 index 0000000..c3f03bf --- /dev/null +++ b/server/utils/TextSplitter/index.js @@ -0,0 +1,206 @@ +/** + * @typedef {object} DocumentMetadata + * @property {string} id - eg; "123e4567-e89b-12d3-a456-426614174000" + * @property {string} url - eg; "file://example.com/index.html" + * @property {string} title - eg; "example.com/index.html" + * @property {string} docAuthor - eg; "no author found" + * @property {string} description - eg; "No description found." + * @property {string} docSource - eg; "URL link uploaded by the user." + * @property {string} chunkSource - eg; link://https://example.com + * @property {string} published - ISO 8601 date string + * @property {number} wordCount - Number of words in the document + * @property {string} pageContent - The raw text content of the document + * @property {number} token_count_estimate - Number of tokens in the document + */ + +function isNullOrNaN(value) { + if (value === null) return true; + return isNaN(value); +} + +class TextSplitter { + #splitter; + + /** + * Creates a new TextSplitter instance. + * @param {Object} config + * @param {string} [config.chunkPrefix = ""] - Prefix to be added to the start of each chunk. + * @param {number} [config.chunkSize = 1000] - The size of each chunk. + * @param {number} [config.chunkOverlap = 20] - The overlap between chunks. + * @param {Object} [config.chunkHeaderMeta = null] - Metadata to be added to the start of each chunk - will come after the prefix. + */ + constructor(config = {}) { + this.config = config; + this.#splitter = this.#setSplitter(config); + } + + log(text, ...args) { + console.log(`\x1b[35m[TextSplitter]\x1b[0m ${text}`, ...args); + } + + /** + * Does a quick check to determine the text chunk length limit. + * Embedder models have hard-set limits that cannot be exceeded, just like an LLM context + * so here we want to allow override of the default 1000, but up to the models maximum, which is + * sometimes user defined. + */ + static determineMaxChunkSize(preferred = null, embedderLimit = 1000) { + const prefValue = isNullOrNaN(preferred) + ? Number(embedderLimit) + : Number(preferred); + const limit = Number(embedderLimit); + if (prefValue > limit) + console.log( + `\x1b[43m[WARN]\x1b[0m Text splitter chunk length of ${prefValue} exceeds embedder model max of ${embedderLimit}. Will use ${embedderLimit}.` + ); + return prefValue > limit ? limit : prefValue; + } + + /** + * Creates a string of metadata to be prepended to each chunk. + * @param {DocumentMetadata} metadata - Metadata to be prepended to each chunk. + * @returns {{[key: ('title' | 'published' | 'source')]: string}} Object of metadata that will be prepended to each chunk. + */ + static buildHeaderMeta(metadata = {}) { + if (!metadata || Object.keys(metadata).length === 0) return null; + const PLUCK_MAP = { + title: { + as: "sourceDocument", + pluck: (metadata) => { + return metadata?.title || null; + }, + }, + published: { + as: "published", + pluck: (metadata) => { + return metadata?.published || null; + }, + }, + chunkSource: { + as: "source", + pluck: (metadata) => { + const validPrefixes = ["link://", "youtube://"]; + // If the chunkSource is a link or youtube link, we can add the URL + // as its source in the metadata so the LLM can use it for context. + // eg prompt: Where did you get this information? -> answer: "from https://example.com" + if ( + !metadata?.chunkSource || // Exists + !metadata?.chunkSource.length || // Is not empty + typeof metadata.chunkSource !== "string" || // Is a string + !validPrefixes.some( + (prefix) => metadata.chunkSource.startsWith(prefix) // Has a valid prefix we respect + ) + ) + return null; + + // We know a prefix is present, so we can split on it and return the rest. + // If nothing is found, return null and it will not be added to the metadata. + let source = null; + for (const prefix of validPrefixes) { + source = metadata.chunkSource.split(prefix)?.[1] || null; + if (source) break; + } + + return source; + }, + }, + }; + + const pluckedData = {}; + Object.entries(PLUCK_MAP).forEach(([key, value]) => { + if (!(key in metadata)) return; // Skip if the metadata key is not present. + const pluckedValue = value.pluck(metadata); + if (!pluckedValue) return; // Skip if the plucked value is null/empty. + pluckedData[value.as] = pluckedValue; + }); + + return pluckedData; + } + + /** + * Apply the chunk prefix to the text if it is present. + * @param {string} text - The text to apply the prefix to. + * @returns {string} The text with the embedder model prefix applied. + */ + #applyPrefix(text = "") { + if (!this.config.chunkPrefix) return text; + return `${this.config.chunkPrefix}${text}`; + } + + /** + * Creates a string of metadata to be prepended to each chunk. + * Will additionally prepend a prefix to the text if it was provided (requirement for some embedders). + * @returns {string} The text with the embedder model prefix applied. + */ + stringifyHeader() { + let content = ""; + if (!this.config.chunkHeaderMeta) return this.#applyPrefix(content); + Object.entries(this.config.chunkHeaderMeta).map(([key, value]) => { + if (!key || !value) return; + content += `${key}: ${value}\n`; + }); + + if (!content) return this.#applyPrefix(content); + return this.#applyPrefix( + `\n${content}\n\n` + ); + } + + /** + * Sets the splitter to use a defined config passes to other subclasses. + * @param {Object} config + * @param {string} [config.chunkPrefix = ""] - Prefix to be added to the start of each chunk. + * @param {number} [config.chunkSize = 1000] - The size of each chunk. + * @param {number} [config.chunkOverlap = 20] - The overlap between chunks. + */ + #setSplitter(config = {}) { + // if (!config?.splitByFilename) {// TODO do something when specific extension is present? } + return new RecursiveSplitter({ + chunkSize: isNaN(config?.chunkSize) ? 1_000 : Number(config?.chunkSize), + chunkOverlap: isNaN(config?.chunkOverlap) + ? 20 + : Number(config?.chunkOverlap), + chunkHeader: this.stringifyHeader(), + }); + } + + async splitText(documentText) { + return this.#splitter._splitText(documentText); + } +} + +// Wrapper for Langchain default RecursiveCharacterTextSplitter class. +class RecursiveSplitter { + constructor({ chunkSize, chunkOverlap, chunkHeader = null }) { + const { + RecursiveCharacterTextSplitter, + } = require("@langchain/textsplitters"); + this.log(`Will split with`, { + chunkSize, + chunkOverlap, + chunkHeader: chunkHeader ? `${chunkHeader?.slice(0, 50)}...` : null, + }); + this.chunkHeader = chunkHeader; + this.engine = new RecursiveCharacterTextSplitter({ + chunkSize, + chunkOverlap, + }); + } + + log(text, ...args) { + console.log(`\x1b[35m[RecursiveSplitter]\x1b[0m ${text}`, ...args); + } + + async _splitText(documentText) { + if (!this.chunkHeader) return this.engine.splitText(documentText); + const strings = await this.engine.splitText(documentText); + const documents = await this.engine.createDocuments(strings, [], { + chunkHeader: this.chunkHeader, + }); + return documents + .filter((doc) => !!doc.pageContent) + .map((doc) => doc.pageContent); + } +} + +module.exports.TextSplitter = TextSplitter; diff --git a/server/utils/TextToSpeech/elevenLabs/index.js b/server/utils/TextToSpeech/elevenLabs/index.js new file mode 100644 index 0000000..1fbbe07 --- /dev/null +++ b/server/utils/TextToSpeech/elevenLabs/index.js @@ -0,0 +1,54 @@ +const { ElevenLabsClient } = require("elevenlabs"); + +class ElevenLabsTTS { + constructor() { + if (!process.env.TTS_ELEVEN_LABS_KEY) + throw new Error("No ElevenLabs API key was set."); + this.elevenLabs = new ElevenLabsClient({ + apiKey: process.env.TTS_ELEVEN_LABS_KEY, + }); + + // Rachel as default voice + // https://api.elevenlabs.io/v1/voices + this.voiceId = + process.env.TTS_ELEVEN_LABS_VOICE_MODEL ?? "21m00Tcm4TlvDq8ikWAM"; + this.modelId = "eleven_multilingual_v2"; + } + + static async voices(apiKey = null) { + try { + const client = new ElevenLabsClient({ + apiKey: apiKey ?? process.env.TTS_ELEVEN_LABS_KEY ?? null, + }); + return (await client.voices.getAll())?.voices ?? []; + } catch {} + return []; + } + + #stream2buffer(stream) { + return new Promise((resolve, reject) => { + const _buf = []; + stream.on("data", (chunk) => _buf.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(_buf))); + stream.on("error", (err) => reject(err)); + }); + } + + async ttsBuffer(textInput) { + try { + const audio = await this.elevenLabs.generate({ + voice: this.voiceId, + text: textInput, + model_id: "eleven_multilingual_v2", + }); + return Buffer.from(await this.#stream2buffer(audio)); + } catch (e) { + console.error(e); + } + return null; + } +} + +module.exports = { + ElevenLabsTTS, +}; diff --git a/server/utils/TextToSpeech/index.js b/server/utils/TextToSpeech/index.js new file mode 100644 index 0000000..f1d735f --- /dev/null +++ b/server/utils/TextToSpeech/index.js @@ -0,0 +1,21 @@ +function getTTSProvider() { + const provider = process.env.TTS_PROVIDER || "openai"; + switch (provider) { + case "openai": + const { OpenAiTTS } = require("./openAi"); + return new OpenAiTTS(); + case "elevenlabs": + const { ElevenLabsTTS } = require("./elevenLabs"); + return new ElevenLabsTTS(); + case "generic-openai": + const { GenericOpenAiTTS } = require("./openAiGeneric"); + return new GenericOpenAiTTS(); + case "kokoro": + const { KokoroTTS } = require("./kokoro"); + return new KokoroTTS(); + default: + throw new Error("ENV: No TTS_PROVIDER value found in environment!"); + } +} + +module.exports = { getTTSProvider }; diff --git a/server/utils/TextToSpeech/kokoro/index.js b/server/utils/TextToSpeech/kokoro/index.js new file mode 100644 index 0000000..9be7541 --- /dev/null +++ b/server/utils/TextToSpeech/kokoro/index.js @@ -0,0 +1,50 @@ +class KokoroTTS { + constructor() { + if (!process.env.TTS_KOKORO_ENDPOINT) + throw new Error( + "No Kokoro endpoint was set. Please set TTS_KOKORO_ENDPOINT to the base URL of your kokoro-fastapi server (e.g. http://localhost:8880/v1)." + ); + + const endpoint = new URL(process.env.TTS_KOKORO_ENDPOINT); + if (!endpoint.pathname.endsWith("/v1")) endpoint.pathname = "/v1"; + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.TTS_KOKORO_KEY || null, + baseURL: endpoint.toString(), + }); + + this.model = "kokoro"; + this.voice = process.env.TTS_KOKORO_VOICE_MODEL ?? "af_bella"; + this.#log( + `Service (${process.env.TTS_KOKORO_ENDPOINT}) with voice: ${this.voice}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[KokoroTTS]\x1b[0m ${text}`, ...args); + } + + /** + * Generates a buffer from the given text input using a kokoro-fastapi server + * via its OpenAI-compatible /audio/speech endpoint. + * @param {string} textInput - The text to be converted to audio. + * @returns {Promise} A buffer containing the audio data. + */ + async ttsBuffer(textInput) { + try { + const result = await this.openai.audio.speech.create({ + model: this.model, + voice: this.voice, + input: textInput, + }); + return Buffer.from(await result.arrayBuffer()); + } catch (e) { + console.error(e); + } + return null; + } +} + +module.exports = { + KokoroTTS, +}; diff --git a/server/utils/TextToSpeech/openAi/index.js b/server/utils/TextToSpeech/openAi/index.js new file mode 100644 index 0000000..3c5b484 --- /dev/null +++ b/server/utils/TextToSpeech/openAi/index.js @@ -0,0 +1,29 @@ +class OpenAiTTS { + constructor() { + if (!process.env.TTS_OPEN_AI_KEY) + throw new Error("No OpenAI API key was set."); + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.TTS_OPEN_AI_KEY, + }); + this.voice = process.env.TTS_OPEN_AI_VOICE_MODEL ?? "alloy"; + } + + async ttsBuffer(textInput) { + try { + const result = await this.openai.audio.speech.create({ + model: "tts-1", + voice: this.voice, + input: textInput, + }); + return Buffer.from(await result.arrayBuffer()); + } catch (e) { + console.error(e); + } + return null; + } +} + +module.exports = { + OpenAiTTS, +}; diff --git a/server/utils/TextToSpeech/openAiGeneric/index.js b/server/utils/TextToSpeech/openAiGeneric/index.js new file mode 100644 index 0000000..5694ed3 --- /dev/null +++ b/server/utils/TextToSpeech/openAiGeneric/index.js @@ -0,0 +1,58 @@ +class GenericOpenAiTTS { + constructor() { + if (!process.env.TTS_OPEN_AI_COMPATIBLE_KEY) + this.#log( + "No OpenAI compatible API key was set. You might need to set this to use your OpenAI compatible TTS service." + ); + if (!process.env.TTS_OPEN_AI_COMPATIBLE_MODEL) + this.#log( + "No OpenAI compatible TTS model was set. We will use the default voice model 'tts-1'. This may not exist or be valid your selected endpoint." + ); + if (!process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL) + this.#log( + "No OpenAI compatible voice model was set. We will use the default voice model 'alloy'. This may not exist for your selected endpoint." + ); + if (!process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT) + throw new Error( + "No OpenAI compatible endpoint was set. Please set this to use your OpenAI compatible TTS service." + ); + + const { OpenAI: OpenAIApi } = require("openai"); + this.openai = new OpenAIApi({ + apiKey: process.env.TTS_OPEN_AI_COMPATIBLE_KEY || null, + baseURL: process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT, + }); + this.model = process.env.TTS_OPEN_AI_COMPATIBLE_MODEL ?? "tts-1"; + this.voice = process.env.TTS_OPEN_AI_COMPATIBLE_VOICE_MODEL ?? "alloy"; + this.#log( + `Service (${process.env.TTS_OPEN_AI_COMPATIBLE_ENDPOINT}) with model: ${this.model} and voice: ${this.voice}` + ); + } + + #log(text, ...args) { + console.log(`\x1b[32m[OpenAiGenericTTS]\x1b[0m ${text}`, ...args); + } + + /** + * Generates a buffer from the given text input using the OpenAI compatible TTS service. + * @param {string} textInput - The text to be converted to audio. + * @returns {Promise} A buffer containing the audio data. + */ + async ttsBuffer(textInput) { + try { + const result = await this.openai.audio.speech.create({ + model: this.model, + voice: this.voice, + input: textInput, + }); + return Buffer.from(await result.arrayBuffer()); + } catch (e) { + console.error(e); + } + return null; + } +} + +module.exports = { + GenericOpenAiTTS, +}; diff --git a/server/utils/agentFlows/executor.js b/server/utils/agentFlows/executor.js new file mode 100644 index 0000000..c41ca9c --- /dev/null +++ b/server/utils/agentFlows/executor.js @@ -0,0 +1,235 @@ +const { FLOW_TYPES } = require("./flowTypes"); +const executeApiCall = require("./executors/api-call"); +const executeLLMInstruction = require("./executors/llm-instruction"); +const executeWebScraping = require("./executors/web-scraping"); +const { Telemetry } = require("../../models/telemetry"); +const { safeJsonParse } = require("../http"); + +class FlowExecutor { + constructor() { + this.variables = {}; + this.introspect = (...args) => console.log("[introspect] ", ...args); + this.logger = console.info; + this.aibitat = null; + } + + attachLogging(introspectFn = null, loggerFn = null) { + this.introspect = + introspectFn || ((...args) => console.log("[introspect] ", ...args)); + this.logger = loggerFn || console.info; + } + + /** + * Resolves nested values from objects using dot notation and array indices + * Supports paths like "data.items[0].name" or "response.users[2].address.city" + * Returns undefined for invalid paths or errors + * @param {Object|string} obj - The object to resolve the value from + * @param {string} path - The path to the value + * @returns {string} The resolved value + */ + getValueFromPath(obj = {}, path = "") { + if (typeof obj === "string") obj = safeJsonParse(obj, {}); + + if ( + !obj || + !path || + typeof obj !== "object" || + Object.keys(obj).length === 0 || + typeof path !== "string" + ) + return ""; + + // First split by dots that are not inside brackets + const parts = []; + let currentPart = ""; + let inBrackets = false; + + for (let i = 0; i < path.length; i++) { + const char = path[i]; + if (char === "[") { + inBrackets = true; + if (currentPart) { + parts.push(currentPart); + currentPart = ""; + } + currentPart += char; + } else if (char === "]") { + inBrackets = false; + currentPart += char; + parts.push(currentPart); + currentPart = ""; + } else if (char === "." && !inBrackets) { + if (currentPart) { + parts.push(currentPart); + currentPart = ""; + } + } else { + currentPart += char; + } + } + + if (currentPart) parts.push(currentPart); + let current = obj; + + for (const part of parts) { + if (current === null || typeof current !== "object") return undefined; + + // Handle bracket notation + if (part.startsWith("[") && part.endsWith("]")) { + const key = part.slice(1, -1); + const cleanKey = key.replace(/^['"]|['"]$/g, ""); + + if (!isNaN(cleanKey)) { + if (!Array.isArray(current)) return undefined; + current = current[parseInt(cleanKey)]; + } else { + if (!(cleanKey in current)) return undefined; + current = current[cleanKey]; + } + } else { + // Handle dot notation + if (!(part in current)) return undefined; + current = current[part]; + } + + if (current === undefined || current === null) return undefined; + } + + return typeof current === "object" ? JSON.stringify(current) : current; + } + + /** + * Replaces variables in the config with their values + * @param {Object} config - The config to replace variables in + * @returns {Object} The config with variables replaced + */ + replaceVariables(config) { + const deepReplace = (obj) => { + if (typeof obj === "string") { + return obj.replace(/\${([^}]+)}/g, (match, varName) => { + const value = this.getValueFromPath(this.variables, varName); + return value !== undefined ? value : match; + }); + } + + if (Array.isArray(obj)) return obj.map((item) => deepReplace(item)); + + if (obj && typeof obj === "object") { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = deepReplace(value); + } + return result; + } + return obj; + }; + + return deepReplace(config); + } + + /** + * Executes a single step of the flow + * @param {Object} step - The step to execute + * @returns {Promise} The result of the step + */ + async executeStep(step) { + const config = this.replaceVariables(step.config); + let result; + // Create execution context with introspect + const context = { + introspect: this.introspect, + variables: this.variables, + logger: this.logger, + aibitat: this.aibitat, + }; + + switch (step.type) { + case FLOW_TYPES.START.type: + // For start blocks, we just initialize variables if they're not already set + if (config.variables) { + config.variables.forEach((v) => { + if (v.name && !this.variables[v.name]) { + this.variables[v.name] = v.value || ""; + } + }); + } + result = this.variables; + break; + case FLOW_TYPES.API_CALL.type: + result = await executeApiCall(config, context); + break; + case FLOW_TYPES.LLM_INSTRUCTION.type: + result = await executeLLMInstruction(config, context); + break; + case FLOW_TYPES.WEB_SCRAPING.type: + result = await executeWebScraping(config, context); + break; + default: + throw new Error(`Unknown flow type: ${step.type}`); + } + + // Store result in variable if specified + if (config.resultVariable || config.responseVariable) { + const varName = config.resultVariable || config.responseVariable; + this.variables[varName] = result; + } + + // If directOutput is true, mark this result for direct output + if (config.directOutput) result = { directOutput: true, result }; + return result; + } + + /** + * Execute entire flow + * @param {Object} flow - The flow to execute + * @param {Object} initialVariables - Initial variables for the flow + * @param {Object} aibitat - The aibitat instance from the agent handler + */ + async executeFlow(flow, initialVariables = {}, aibitat) { + await Telemetry.sendTelemetry("agent_flow_execution_started"); + + // Initialize variables with both initial values and any passed-in values + this.variables = { + ...( + flow.config.steps.find((s) => s.type === "start")?.config?.variables || + [] + ).reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {}), + ...initialVariables, // This will override any default values with passed-in values + }; + + this.aibitat = aibitat; + this.attachLogging(aibitat?.introspect, aibitat?.handlerProps?.log); + const results = []; + let directOutputResult = null; + + for (const step of flow.config.steps) { + try { + const result = await this.executeStep(step); + + // If the step has directOutput, stop processing and return the result + // so that no other steps are executed or processed + if (result?.directOutput) { + directOutputResult = result.result; + break; + } + + results.push({ success: true, result }); + } catch (error) { + results.push({ success: false, error: error.message }); + break; + } + } + + return { + success: results.every((r) => r.success), + results, + variables: this.variables, + directOutput: directOutputResult, + }; + } +} + +module.exports = { + FlowExecutor, + FLOW_TYPES, +}; diff --git a/server/utils/agentFlows/executors/api-call.js b/server/utils/agentFlows/executors/api-call.js new file mode 100644 index 0000000..6d06024 --- /dev/null +++ b/server/utils/agentFlows/executors/api-call.js @@ -0,0 +1,60 @@ +const { safeJsonParse } = require("../../http"); + +/** + * Execute an API call flow step + * @param {Object} config Flow step configuration + * @param {Object} context Execution context with introspect function + * @returns {Promise} Response data + */ +async function executeApiCall(config, context) { + const { url, method, headers = [], body, bodyType, formData } = config; + const { introspect, logger } = context; + logger(`\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing API Call block`); + introspect(`Making ${method} request to external API...`); + + const requestConfig = { + method, + headers: headers.reduce((acc, h) => ({ ...acc, [h.key]: h.value }), {}), + }; + + if (["POST", "PUT", "PATCH"].includes(method)) { + if (bodyType === "form") { + const formDataObj = new URLSearchParams(); + formData.forEach(({ key, value }) => formDataObj.append(key, value)); + requestConfig.body = formDataObj.toString(); + requestConfig.headers["Content-Type"] = + "application/x-www-form-urlencoded"; + } else if (bodyType === "json") { + const parsedBody = safeJsonParse(body, null); + if (parsedBody !== null) { + requestConfig.body = JSON.stringify(parsedBody); + } + requestConfig.headers["Content-Type"] = "application/json"; + } else if (bodyType === "text") { + requestConfig.body = String(body); + } else { + requestConfig.body = body; + } + } + + try { + introspect(`Sending body to ${url}: ${requestConfig?.body || "No body"}`); + const response = await fetch(url, requestConfig); + if (!response.ok) { + introspect(`Request failed with status ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}`); + } + + introspect(`API call completed`); + return await response + .text() + .then((text) => + safeJsonParse(text, "Failed to parse output from API call block") + ); + } catch (error) { + console.error(error); + throw new Error(`API Call failed: ${error.message}`); + } +} + +module.exports = executeApiCall; diff --git a/server/utils/agentFlows/executors/llm-instruction.js b/server/utils/agentFlows/executors/llm-instruction.js new file mode 100644 index 0000000..d594c54 --- /dev/null +++ b/server/utils/agentFlows/executors/llm-instruction.js @@ -0,0 +1,47 @@ +/** + * Execute an LLM instruction flow step + * @param {Object} config Flow step configuration + * @param {{introspect: Function, logger: Function}} context Execution context with introspect function + * @returns {Promise} Processed result + */ +async function executeLLMInstruction(config, context) { + const { instruction, resultVariable } = config; + const { introspect, logger, aibitat } = context; + logger( + `\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing LLM Instruction block` + ); + introspect(`Processing data with LLM instruction...`); + + try { + logger( + `Sending request to LLM (${aibitat.defaultProvider.provider}::${aibitat.defaultProvider.model})` + ); + introspect(`Sending request to LLM...`); + + // Ensure the input is a string since we are sending it to the LLM direct as a message + let input = instruction; + if (typeof input === "object") input = JSON.stringify(input); + if (typeof input !== "string") input = String(input); + + let completion; + const provider = aibitat.getProviderForConfig(aibitat.defaultProvider); + if (provider.supportsAgentStreaming) { + completion = await provider.stream( + [{ role: "user", content: input }], + [], + null + ); + } else { + completion = await provider.complete([{ role: "user", content: input }]); + } + + introspect(`Successfully received LLM response`); + if (resultVariable) config.resultVariable = resultVariable; + return completion.textResponse; + } catch (error) { + logger(`LLM processing failed: ${error.message}`, error); + throw new Error(`LLM processing failed: ${error.message}`); + } +} + +module.exports = executeLLMInstruction; diff --git a/server/utils/agentFlows/executors/web-scraping.js b/server/utils/agentFlows/executors/web-scraping.js new file mode 100644 index 0000000..c1fea75 --- /dev/null +++ b/server/utils/agentFlows/executors/web-scraping.js @@ -0,0 +1,111 @@ +/** + * Execute a web scraping flow step + * @param {Object} config Flow step configuration + * @param {Object} context Execution context with introspect function + * @returns {Promise} Scraped content + */ +async function executeWebScraping(config, context) { + const { CollectorApi } = require("../../collectorApi"); + const { TokenManager } = require("../../helpers/tiktoken"); + const Provider = require("../../agents/aibitat/providers/ai-provider"); + const { summarizeContent } = require("../../agents/aibitat/utils/summarize"); + + const { url, captureAs = "text", enableSummarization = true } = config; + const { introspect, logger, aibitat } = context; + logger( + `\x1b[43m[AgentFlowToolExecutor]\x1b[0m - executing Web Scraping block` + ); + + if (!url) { + throw new Error("URL is required for web scraping"); + } + + const captureMode = captureAs === "querySelector" ? "html" : captureAs; + introspect(`Scraping the content of ${url} as ${captureAs}`); + const { success, content } = await new CollectorApi() + .getLinkContent(url, captureMode) + .then((res) => { + if (captureAs !== "querySelector") return res; + return parseHTMLwithSelector(res.content, config.querySelector, context); + }); + + if (!success) { + introspect(`Could not scrape ${url}. Cannot use this page's content.`); + throw new Error("URL could not be scraped and no content was found."); + } + + introspect(`Successfully scraped content from ${url}`); + if (!content || content?.length === 0) { + introspect("There was no content to be collected or read."); + throw new Error("There was no content to be collected or read."); + } + + if (!enableSummarization) { + logger(`Returning raw content as summarization is disabled`); + return content; + } + + const tokenCount = new TokenManager( + aibitat.defaultProvider.model + ).countFromString(content); + const contextLimit = Provider.contextLimit( + aibitat.defaultProvider.provider, + aibitat.defaultProvider.model + ); + + if (tokenCount < contextLimit) { + logger( + `Content within token limit (${tokenCount}/${contextLimit}). Returning raw content.` + ); + return content; + } + + introspect( + `This page's content is way too long (${tokenCount} tokens). I will summarize it right now.` + ); + const summary = await summarizeContent({ + provider: aibitat.defaultProvider.provider, + model: aibitat.defaultProvider.model, + content, + aibitat, + }); + + introspect(`Successfully summarized content`); + return summary; +} + +/** + * Parse HTML with a CSS selector + * @param {string} html - The HTML to parse + * @param {string|null} selector - The CSS selector to use (as text string) + * @param {{introspect: Function}} context - The context object + * @returns {Object} The parsed content + */ +function parseHTMLwithSelector(html, selector = null, context) { + if (!selector || selector.length === 0) { + context.introspect("No selector provided. Returning the entire HTML."); + return { success: true, content: html }; + } + + const Cheerio = require("cheerio"); + const $ = Cheerio.load(html); + const selectedElements = $(selector); + + let content; + if (selectedElements.length === 0) { + return { success: false, content: null }; + } else if (selectedElements.length === 1) { + content = selectedElements.html(); + } else { + context.introspect( + `Found ${selectedElements.length} elements matching selector: ${selector}` + ); + content = selectedElements + .map((_, element) => $(element).html()) + .get() + .join("\n"); + } + return { success: true, content }; +} + +module.exports = executeWebScraping; diff --git a/server/utils/agentFlows/flowTypes.js b/server/utils/agentFlows/flowTypes.js new file mode 100644 index 0000000..0572bd1 --- /dev/null +++ b/server/utils/agentFlows/flowTypes.js @@ -0,0 +1,85 @@ +const FLOW_TYPES = { + START: { + type: "start", + description: "Initialize flow variables", + parameters: { + variables: { + type: "array", + description: "List of variables to initialize", + }, + }, + }, + API_CALL: { + type: "apiCall", + description: "Make an HTTP request to an API endpoint", + parameters: { + url: { type: "string", description: "The URL to make the request to" }, + method: { type: "string", description: "HTTP method (GET, POST, etc.)" }, + headers: { + type: "array", + description: "Request headers as key-value pairs", + }, + bodyType: { + type: "string", + description: "Type of request body (json, form)", + }, + body: { + type: "string", + description: + "Request body content. If body type is json, always return a valid json object. If body type is form, always return a valid form data object.", + }, + formData: { type: "array", description: "Form data as key-value pairs" }, + responseVariable: { + type: "string", + description: "Variable to store the response", + }, + directOutput: { + type: "boolean", + description: + "Whether to return the response directly to the user without LLM processing", + }, + }, + examples: [ + { + url: "https://api.example.com/data", + method: "GET", + headers: [{ key: "Authorization", value: "Bearer 1234567890" }], + }, + ], + }, + LLM_INSTRUCTION: { + type: "llmInstruction", + description: "Process data using LLM instructions", + parameters: { + instruction: { + type: "string", + description: "The instruction for the LLM to follow", + }, + resultVariable: { + type: "string", + description: "Variable to store the processed result", + }, + }, + }, + WEB_SCRAPING: { + type: "webScraping", + description: "Scrape content from a webpage", + parameters: { + url: { + type: "string", + description: "The URL of the webpage to scrape", + }, + resultVariable: { + type: "string", + description: "Variable to store the scraped content", + }, + directOutput: { + type: "boolean", + description: + "Whether to return the scraped content directly to the user without LLM processing", + }, + }, + }, +}; + +module.exports.FLOW_TYPES = FLOW_TYPES; diff --git a/server/utils/agentFlows/index.js b/server/utils/agentFlows/index.js new file mode 100644 index 0000000..2bd5ec7 --- /dev/null +++ b/server/utils/agentFlows/index.js @@ -0,0 +1,288 @@ +const fs = require("fs"); +const path = require("path"); +const { v4: uuidv4 } = require("uuid"); +const { FlowExecutor, FLOW_TYPES } = require("./executor"); +const { normalizePath, isWithin } = require("../files"); +const { safeJsonParse } = require("../http"); + +/** + * @typedef {Object} LoadedFlow + * @property {string} name - The name of the flow + * @property {string} uuid - The UUID of the flow + * @property {Object} config - The flow configuration details + * @property {string} config.description - The description of the flow + * @property {Array<{type: string, config: Object, [key: string]: any}>} config.steps - The steps of the flow. Each step has at least a type and config + */ + +class AgentFlows { + static flowsDir = process.env.STORAGE_DIR + ? path.join(process.env.STORAGE_DIR, "plugins", "agent-flows") + : path.join(process.cwd(), "storage", "plugins", "agent-flows"); + + constructor() {} + + /** + * Ensure flows directory exists + * @returns {Boolean} True if directory exists, false otherwise + */ + static createOrCheckFlowsDir() { + try { + if (fs.existsSync(AgentFlows.flowsDir)) return true; + fs.mkdirSync(AgentFlows.flowsDir, { recursive: true }); + return true; + } catch (error) { + console.error("Failed to create flows directory:", error); + return false; + } + } + + /** + * Helper to get all flow files with their contents + * @returns {Object} Map of flow UUID to flow config + */ + static getAllFlows() { + AgentFlows.createOrCheckFlowsDir(); + const files = fs.readdirSync(AgentFlows.flowsDir); + const flows = {}; + + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const filePath = path.join(AgentFlows.flowsDir, file); + const content = fs.readFileSync(normalizePath(filePath), "utf8"); + const config = JSON.parse(content); + const id = file.replace(".json", ""); + flows[id] = config; + } catch (error) { + console.error(`Error reading flow file ${file}:`, error); + } + } + + return flows; + } + + /** + * Load a flow configuration by UUID + * @param {string} uuid - The UUID of the flow to load + * @returns {LoadedFlow|null} Flow configuration or null if not found + */ + static loadFlow(uuid) { + try { + const flowJsonPath = normalizePath( + path.join(AgentFlows.flowsDir, `${uuid}.json`) + ); + if ( + !uuid || + !fs.existsSync(flowJsonPath) || + !isWithin(AgentFlows.flowsDir, flowJsonPath) + ) + return null; + const flow = safeJsonParse(fs.readFileSync(flowJsonPath, "utf8"), null); + if (!flow) return null; + + return { + name: flow.name, + uuid, + config: flow, + }; + } catch (error) { + console.error("Failed to load flow:", error); + return null; + } + } + + /** + * Save a flow configuration + * @param {string} name - The name of the flow + * @param {Object} config - The flow configuration + * @param {string|null} uuid - Optional UUID for the flow + * @returns {Object} Result of the save operation + */ + static saveFlow(name, config, uuid = null) { + try { + AgentFlows.createOrCheckFlowsDir(); + + if (!uuid) uuid = uuidv4(); + const normalizedUuid = normalizePath(`${uuid}.json`); + const filePath = path.join(AgentFlows.flowsDir, normalizedUuid); + if (!isWithin(AgentFlows.flowsDir, filePath)) return null; + + // Prevent saving flows with unsupported blocks or importing + // flows with unsupported blocks (eg: file writing or code execution on Desktop importing to Docker) + const supportedFlowTypes = Object.values(FLOW_TYPES).map( + (definition) => definition.type + ); + const supportsAllBlocks = config.steps.every((step) => + supportedFlowTypes.includes(step.type) + ); + if (!supportsAllBlocks) + throw new Error( + "This flow includes unsupported blocks. They may not be supported by your version of AnythingLLM or are not available on this platform." + ); + + fs.writeFileSync(filePath, JSON.stringify({ ...config, name }, null, 2)); + return { success: true, uuid }; + } catch (error) { + console.error("Failed to save flow:", error); + return { success: false, error: error.message }; + } + } + + /** + * List all available flows + * @returns {Array} Array of flow summaries + */ + static listFlows() { + try { + const flows = AgentFlows.getAllFlows(); + return Object.entries(flows).map(([uuid, flow]) => ({ + name: flow.name, + uuid, + description: flow.description, + active: flow.active !== false, + })); + } catch (error) { + console.error("Failed to list flows:", error); + return []; + } + } + + /** + * Delete a flow by UUID + * @param {string} uuid - The UUID of the flow to delete + * @returns {Object} Result of the delete operation + */ + static deleteFlow(uuid) { + try { + const filePath = normalizePath( + path.join(AgentFlows.flowsDir, `${uuid}.json`) + ); + if (!fs.existsSync(filePath) || !isWithin(AgentFlows.flowsDir, filePath)) + throw new Error(`Flow ${uuid} not found`); + fs.rmSync(filePath); + return { success: true }; + } catch (error) { + console.error("Failed to delete flow:", error); + return { success: false, error: error.message }; + } + } + + /** + * Execute a flow by UUID + * @param {string} uuid - The UUID of the flow to execute + * @param {Object} variables - Initial variables for the flow + * @param {Object} aibitat - The aibitat instance from the agent handler + * @returns {Promise} Result of flow execution + */ + static async executeFlow(uuid, variables = {}, aibitat = null) { + const flow = AgentFlows.loadFlow(uuid); + if (!flow) throw new Error(`Flow ${uuid} not found`); + const flowExecutor = new FlowExecutor(); + return await flowExecutor.executeFlow(flow, variables, aibitat); + } + + /** + * Get all active flows as plugins that can be loaded into the agent + * @returns {string[]} Array of flow names in @@flow_{uuid} format + */ + static activeFlowPlugins() { + const flows = AgentFlows.getAllFlows(); + return Object.entries(flows) + .filter(([_, flow]) => flow.active !== false) + .map(([uuid]) => `@@flow_${uuid}`); + } + + /** + * Sanitize a flow name into a valid OpenAI-compatible tool name. + * Must match ^[a-zA-Z0-9_-]{1,64}$ + * @param {string} flowName - The human-readable flow name + * @returns {string|null} Sanitized tool name, or null if empty after sanitization + */ + static sanitizeToolName(flowName) { + const sanitized = flowName + .toLowerCase() + .trim() + .replace(/\s+/g, "_") + .replace(/[^a-z0-9_-]/g, "") + .replace(/_+/g, "_") + .replace(/^[-_]+|[-_]+$/g, ""); + if (!sanitized) return null; + return sanitized.slice(0, 64); + } + + /** + * Load a flow plugin by its UUID + * @param {string} uuid - The UUID of the flow to load + * @returns {Object|null} Plugin configuration or null if not found + */ + static loadFlowPlugin(uuid) { + const flow = AgentFlows.loadFlow(uuid); + if (!flow) return null; + + const startBlock = flow.config.steps?.find((s) => s.type === "start"); + const variables = startBlock?.config?.variables || []; + const toolName = AgentFlows.sanitizeToolName(flow.name) || `flow_${uuid}`; + + return { + name: toolName, + description: `Execute agent flow: ${flow.name}`, + plugin: (_runtimeArgs = {}) => ({ + name: toolName, + description: + flow.config.description || `Execute agent flow: ${flow.name}`, + setup: (aibitat) => { + aibitat.function({ + name: toolName, + description: + flow.config.description || `Execute agent flow: ${flow.name}`, + parameters: { + type: "object", + properties: variables.reduce((acc, v) => { + if (v.name) { + acc[v.name] = { + type: "string", + description: + v.description || `Value for variable ${v.name}`, + }; + } + return acc; + }, {}), + }, + handler: async (args) => { + aibitat.introspect(`Executing flow: ${flow.name}`); + const result = await AgentFlows.executeFlow(uuid, args, aibitat); + if (!result.success) { + aibitat.introspect( + `Flow failed: ${result.results[0]?.error || "Unknown error"}` + ); + return `Flow execution failed: ${result.results[0]?.error || "Unknown error"}`; + } + aibitat.introspect(`${flow.name} completed successfully`); + + // If the flow result has directOutput, return it + // as the aibitat result so that no other processing is done + if (!!result.directOutput) { + aibitat.skipHandleExecution = true; + return AgentFlows.stringifyResult(result.directOutput); + } + + return AgentFlows.stringifyResult(result); + }, + }); + }, + }), + flowName: flow.name, + }; + } + + /** + * Stringify the result of a flow execution or return the input as is + * @param {Object|string} input - The result to stringify + * @returns {string} The stringified result + */ + static stringifyResult(input) { + return typeof input === "object" ? JSON.stringify(input) : String(input); + } +} + +module.exports.AgentFlows = AgentFlows; diff --git a/server/utils/agents/aibitat/error.js b/server/utils/agents/aibitat/error.js new file mode 100644 index 0000000..223f335 --- /dev/null +++ b/server/utils/agents/aibitat/error.js @@ -0,0 +1,18 @@ +class AIbitatError extends Error {} + +class APIError extends AIbitatError { + constructor(message) { + super(message); + } +} + +/** + * The error when the AI provider returns an error that should be treated as something + * that should be retried. + */ +class RetryError extends APIError {} + +module.exports = { + APIError, + RetryError, +}; diff --git a/server/utils/agents/aibitat/example/.gitignore b/server/utils/agents/aibitat/example/.gitignore new file mode 100644 index 0000000..4b0412c --- /dev/null +++ b/server/utils/agents/aibitat/example/.gitignore @@ -0,0 +1 @@ +history/ \ No newline at end of file diff --git a/server/utils/agents/aibitat/example/beginner-chat.js b/server/utils/agents/aibitat/example/beginner-chat.js new file mode 100644 index 0000000..d81c2b7 --- /dev/null +++ b/server/utils/agents/aibitat/example/beginner-chat.js @@ -0,0 +1,56 @@ +// You must execute this example from within the example folder. +const AIbitat = require("../index.js"); +const { cli } = require("../plugins/cli.js"); +const { NodeHtmlMarkdown } = require("node-html-markdown"); +require("dotenv").config({ path: `../../../../.env.development` }); + +const Agent = { + HUMAN: "🧑", + AI: "🤖", +}; + +const aibitat = new AIbitat({ + provider: "openai", + model: "gpt-4o", +}) + .use(cli.plugin()) + .function({ + name: "aibitat-documentations", + description: "The documentation about aibitat AI project.", + parameters: { + type: "object", + properties: {}, + }, + handler: async () => { + return await fetch( + "https://raw.githubusercontent.com/wladiston/aibitat/main/README.md" + ) + .then((res) => res.text()) + .then((html) => NodeHtmlMarkdown.translate(html)) + .catch((e) => { + console.error(e.message); + return "FAILED TO FETCH"; + }); + }, + }) + .agent(Agent.HUMAN, { + interrupt: "ALWAYS", + role: "You are a human assistant.", + }) + .agent(Agent.AI, { + functions: ["aibitat-documentations"], + }); + +async function main() { + if (!process.env.OPEN_AI_KEY) + throw new Error( + "This example requires a valid OPEN_AI_KEY in the env.development file" + ); + await aibitat.start({ + from: Agent.HUMAN, + to: Agent.AI, + content: `Please, talk about the documentation of AIbitat.`, + }); +} + +main(); diff --git a/server/utils/agents/aibitat/example/blog-post-coding.js b/server/utils/agents/aibitat/example/blog-post-coding.js new file mode 100644 index 0000000..1764d49 --- /dev/null +++ b/server/utils/agents/aibitat/example/blog-post-coding.js @@ -0,0 +1,55 @@ +const AIbitat = require("../index.js"); +const { + cli, + webBrowsing, + fileHistory, + webScraping, +} = require("../plugins/index.js"); +require("dotenv").config({ path: `../../../../.env.development` }); + +const aibitat = new AIbitat({ + model: "gpt-4o", +}) + .use(cli.plugin()) + .use(fileHistory.plugin()) + .use(webBrowsing.plugin()) // Does not have introspect so will fail. + .use(webScraping.plugin()) + .agent("researcher", { + role: `You are a Researcher. Conduct thorough research to gather all necessary information about the topic + you are writing about. Collect data, facts, and statistics. Analyze competitor blogs for insights. + Provide accurate and up-to-date information that supports the blog post's content to @copywriter.`, + functions: ["web-browsing"], + }) + .agent("copywriter", { + role: `You are a Copywriter. Interpret the draft as general idea and write the full blog post using markdown, + ensuring it is tailored to the target audience's preferences, interests, and demographics. Apply genre-specific + writing techniques relevant to the author's genre. Add code examples when needed. Code must be written in + Typescript. Always mention references. Revisit and edit the post for clarity, coherence, and + correctness based on the feedback provided. Ask for feedbacks to the channel when you are done`, + }) + .agent("pm", { + role: `You are a Project Manager. Coordinate the project, ensure tasks are completed on time and within budget. + Communicate with team members and stakeholders.`, + interrupt: "ALWAYS", + }) + .channel("content-team", ["researcher", "copywriter", "pm"]); + +async function main() { + if (!process.env.OPEN_AI_KEY) + throw new Error( + "This example requires a valid OPEN_AI_KEY in the env.development file" + ); + await aibitat.start({ + from: "pm", + to: "content-team", + content: `We have got this draft of the new blog post, let us start working on it. + --- BEGIN DRAFT OF POST --- + + Maui is a beautiful island in the state of Hawaii and is world-renowned for its whale watching season. Here are 2 additional things to do in Maui, HI: + + --- END DRAFT OF POST --- + `, + }); +} + +main(); diff --git a/server/utils/agents/aibitat/example/websocket/index.html b/server/utils/agents/aibitat/example/websocket/index.html new file mode 100644 index 0000000..2fbb56c --- /dev/null +++ b/server/utils/agents/aibitat/example/websocket/index.html @@ -0,0 +1,67 @@ + + + + + + + + +
+ + diff --git a/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js b/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js new file mode 100644 index 0000000..fcda2b9 --- /dev/null +++ b/server/utils/agents/aibitat/example/websocket/websock-branding-collab.js @@ -0,0 +1,100 @@ +// You can only run this example from within the websocket/ directory. +// NODE_ENV=development node websock-branding-collab.js +// Scraping is enabled, but search requires AGENT_GSE_* keys. + +const express = require("express"); +const chalk = require("chalk"); +const AIbitat = require("../../index.js"); +const { + websocket, + webBrowsing, + webScraping, +} = require("../../plugins/index.js"); +const path = require("path"); +const port = 3000; +const app = express(); +require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode. +require("dotenv").config({ path: `../../../../../.env.development` }); + +// Debugging echo function if this is working for you. +// app.ws('/echo', function (ws, req) { +// ws.on('message', function (msg) { +// ws.send(msg); +// }); +// }); + +// Set up WSS sockets for listening. +app.ws("/ws", function (ws, _response) { + try { + ws.on("message", function (msg) { + if (ws?.handleFeedback) ws.handleFeedback(msg); + }); + + ws.on("close", function () { + console.log("Socket killed"); + return; + }); + + console.log("Socket online and waiting..."); + runAIbitat(ws).catch((error) => { + ws.send( + JSON.stringify({ + from: "AI", + to: "HUMAN", + content: error.message, + }) + ); + }); + } catch {} +}); + +app.all("*", function (_, response) { + response.sendFile(path.join(__dirname, "index.html")); +}); + +app.listen(port, () => { + console.log(`Testing HTTP/WSS server listening at http://localhost:${port}`); +}); + +async function runAIbitat(socket) { + console.log(chalk.blue("Booting AIbitat class & starting agent(s)")); + + const aibitat = new AIbitat({ + provider: "openai", + model: "gpt-4", + }) + .use(websocket.plugin({ socket })) + .use(webBrowsing.plugin()) + .use(webScraping.plugin()) + .agent("creativeDirector", { + role: `You are a Creative Director. Your role is overseeing the entire branding project, ensuring + the client's brief is met, and maintaining consistency across all brand elements, developing the + brand strategy, guiding the visual and conceptual direction, and providing overall creative leadership.`, + }) + .agent("marketResearcher", { + role: `You do competitive market analysis via searching on the internet and learning about + comparative products and services. You can search by using keywords and phrases that you think will lead + to competitor research that can help find the unique angle and market of the idea.`, + functions: ["web-browsing"], + }) + .agent("PM", { + role: `You are the Project Coordinator. Your role is overseeing the project's progress, timeline, + and budget. Ensure effective communication and coordination among team members, client, and stakeholders. + Your tasks include planning and scheduling project milestones, tracking tasks, and managing any + risks or issues that arise.`, + interrupt: "ALWAYS", + }) + .channel("#branding", [ + "creativeDirector", + "marketResearcher", + "PM", + ]); + + await aibitat.start({ + from: "PM", + to: "#branding", + content: `I have an idea for a muslim focused meetup called Chai & Vibes. + I want to focus on professionals that are muslim and are in their 18-30 year old range who live in big cities. + Does anything like this exist? How can we differentiate?`, + }); +} diff --git a/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js b/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js new file mode 100644 index 0000000..92279c6 --- /dev/null +++ b/server/utils/agents/aibitat/example/websocket/websock-multi-turn-chat.js @@ -0,0 +1,91 @@ +// You can only run this example from within the websocket/ directory. +// NODE_ENV=development node websock-multi-turn-chat.js +// Scraping is enabled, but search requires AGENT_GSE_* keys. + +const express = require("express"); +const chalk = require("chalk"); +const AIbitat = require("../../index.js"); +const { + websocket, + webBrowsing, + webScraping, +} = require("../../plugins/index.js"); +const path = require("path"); +const port = 3000; +const app = express(); +require("@mintplex-labs/express-ws").default(app); // load WebSockets in non-SSL mode. +require("dotenv").config({ path: `../../../../../.env.development` }); + +// Debugging echo function if this is working for you. +// app.ws('/echo', function (ws, req) { +// ws.on('message', function (msg) { +// ws.send(msg); +// }); +// }); + +// Set up WSS sockets for listening. +app.ws("/ws", function (ws, _response) { + try { + ws.on("message", function (msg) { + if (ws?.handleFeedback) ws.handleFeedback(msg); + }); + + ws.on("close", function () { + console.log("Socket killed"); + return; + }); + + console.log("Socket online and waiting..."); + runAIbitat(ws).catch((error) => { + ws.send( + JSON.stringify({ + from: Agent.AI, + to: Agent.HUMAN, + content: error.message, + }) + ); + }); + } catch {} +}); + +app.all("*", function (_, response) { + response.sendFile(path.join(__dirname, "index.html")); +}); + +app.listen(port, () => { + console.log(`Testing HTTP/WSS server listening at http://localhost:${port}`); +}); + +const Agent = { + HUMAN: "🧑", + AI: "🤖", +}; + +async function runAIbitat(socket) { + if (!process.env.OPEN_AI_KEY) + throw new Error( + "This example requires a valid OPEN_AI_KEY in the env.development file" + ); + console.log(chalk.blue("Booting AIbitat class & starting agent(s)")); + const aibitat = new AIbitat({ + provider: "openai", + model: "gpt-4o", + }) + .use(websocket.plugin({ socket })) + .use(webBrowsing.plugin()) + .use(webScraping.plugin()) + .agent(Agent.HUMAN, { + interrupt: "ALWAYS", + role: "You are a human assistant.", + }) + .agent(Agent.AI, { + role: "You are a helpful ai assistant who likes to chat with the user who an also browse the web for questions it does not know or have real-time access to.", + functions: ["web-browsing"], + }); + + await aibitat.start({ + from: Agent.HUMAN, + to: Agent.AI, + content: `How are you doing today?`, + }); +} diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js new file mode 100644 index 0000000..dc5755b --- /dev/null +++ b/server/utils/agents/aibitat/index.js @@ -0,0 +1,1459 @@ +/* eslint-disable unused-imports/no-unused-vars */ +const { EventEmitter } = require("events"); +const { APIError } = require("./error.js"); +const Providers = require("./providers/index.js"); +const { Telemetry } = require("../../../models/telemetry.js"); +const { v4 } = require("uuid"); +const { ToolReranker } = require("./utils/toolReranker.js"); + +/** + * AIbitat is a class that manages the conversation between agents. + * It is designed to solve a task with LLM. + * + * Guiding the chat through a graph of agents. + */ +class AIbitat { + emitter = new EventEmitter(); + + /** + * Temporary flag to skip the handleExecution function + * This is used to return the result of a flow execution directly to the chat + * without going through the handleExecution function (resulting in more LLM processing) + * + * Setting Skip execution to true will prevent any further tool calls from being executed. + * This is useful for flow executions that need to return a result directly to the chat but + * can also prevent tool-call chaining. + * + * @type {boolean} + */ + skipHandleExecution = false; + + _provider = null; + + /** @type {import("./providers/ai-provider").AgentProviderInstance|null} */ + _providerInstance = null; + + defaultProvider = null; + defaultInterrupt; + maxRounds; + _chats; + _trackedChatId = null; + agents = new Map(); + channels = new Map(); + functions = new Map(); + + /** + * Buffer for citations collected during tool execution. + * Citations are flushed to the frontend when the response is finalized. + * @type {Array<{id: string, title: string, text: string, chunkSource?: string, score?: number}>} + */ + _pendingCitations = []; + + /** + * Buffer for attachments (images) collected during tool execution. + * Tools can call addToolAttachment() to queue images for injection into the conversation. + * These are injected as a user message so all providers' existing attachment handling works. + * @type {Array<{name: string, mime: string, contentString: string}>} + */ + _toolAttachments = []; + + /** + * Buffer for clarifying-question surveys completed during tool execution. + * Each entry is one ask-user invocation (questions + the user's result), + * drained by the chat-history plugin into workspace_chats.response so the + * filled-in survey persists alongside citations/outputs. + * @type {Array<{questions: Array, result: Object}>} + */ + _pendingClarifyingQuestionSurveys = []; + + /** + * Get the default maximum number of tools an agent can chain for a single response. + * @returns {number} + */ + static defaultMaxToolCalls() { + const envMaxToolCalls = parseInt(process.env.AGENT_MAX_TOOL_CALLS, 10); + return !isNaN(envMaxToolCalls) && envMaxToolCalls > 0 + ? envMaxToolCalls + : 10; + } + + /** + * Create a new AIbitat instance. + * @param {Object} props - The properties for the AIbitat instance. + * @param {Array} props.chats - [default: []] The chat history between agents and channels. + * @param {string} props.interrupt - [default: "NEVER"] The interrupt mode for the AIbitat instance. + * @param {number} props.maxRounds - [default: 100] The maximum number of rounds for the AIbitat instance. + * @param {number} props.maxToolCalls - [default: AIbitat.defaultMaxToolCalls()] The maximum number of tools an agent can chain for a single response. + * @param {string} props.provider - [default: "openai"] The provider for the AIbitat instance. + * @param {Object} props.handlerProps - The handler properties for the AIbitat instance. + * @param {Object} rest - The rest of the properties for the AIbitat instance. + */ + constructor(props = {}) { + const { + chats = [], + interrupt = "NEVER", + maxRounds = 100, + maxToolCalls = AIbitat.defaultMaxToolCalls(), + provider = "openai", + handlerProps = {}, // Inherited props we can spread so aibitat can access. + ...rest + } = props; + this._chats = chats; + this.defaultInterrupt = interrupt; + this.maxRounds = maxRounds; + this.maxToolCalls = maxToolCalls; + this.handlerProps = handlerProps; + + this.defaultProvider = { + provider, + ...rest, + }; + this.provider = this.defaultProvider.provider; + this.model = this.defaultProvider.model; + } + + /** + * Get the chat history between agents and channels. + */ + get chats() { + return this._chats; + } + + get provider() { + return this._provider; + } + + set provider(value) { + if (value !== null && typeof value !== "string") { + console.trace(); // print this for user report debugging so call stack is visible + throw new TypeError( + `aibitat.provider must be a string tag (e.g. "openai"), got ${typeof value}. ` + + `Use aibitat.providerInstance to to get/store the provider instance.` + ); + } + this._provider = value; + } + + /** @returns {import("./providers/ai-provider").AgentProviderInstance} */ + get providerInstance() { + return this._providerInstance; + } + + /** @param {import("./providers/ai-provider").AgentProviderInstance|null} value */ + set providerInstance(value) { + this._providerInstance = value; + } + + /** + * Install a plugin. + */ + use(plugin) { + plugin.setup(this); + return this; + } + + /** + * Register a new chat ID for tracking for a given conversation exchange + * @param {number} chatId - The ID of the chat to register. + */ + registerChatId(chatId = null) { + if (!chatId) return; + this._trackedChatId = Number(chatId); + } + + /** + * Get the tracked chat ID for a given conversation exchange + * @returns {number|null} The ID of the chat to register. + */ + get trackedChatId() { + return this._trackedChatId ?? null; + } + + /** + * Clear the tracked chat ID for a given conversation exchange + */ + clearTrackedChatId() { + this._trackedChatId = null; + } + + /** + * Emit the tracked chat ID to the frontend via the websocket + * plugin (assumed to be attached). + * @param {string} [uuid] - The message UUID to associate with this chatId + */ + emitChatId(uuid = null) { + if (!this.trackedChatId || !uuid) return null; + this.socket?.send?.("reportStreamEvent", { + type: "chatId", + uuid, + chatId: this.trackedChatId, + }); + } + + /** + * Add citation(s) to be reported when the response is finalized. + * Citations are buffered and flushed with the correct message UUID. + * @param {{id: string, title: string, text: string, chunkSource?: string, score?: number}|Array<{id: string, title: string, text: string, chunkSource?: string, score?: number}>} citations - Citation object or array of citation objects + */ + addCitation(citations) { + if (!citations) return; + if (Array.isArray(citations)) + this._pendingCitations.push(...citations.filter(Boolean)); + else if (typeof citations === "object") + this._pendingCitations.push(citations); + } + + /** + * Register attached documents (parsed/pinned files) as citations so they surface as + * sources, mirroring normal chat. Dedupes by id since this runs on every reply turn. + * @param {Array<{name: string, content: string, metadata?: object}>} documents + */ + addDocumentCitations(documents = []) { + const existingIds = new Set(this._pendingCitations.map((c) => c.id)); + for (const { name, content, metadata = {} } of documents) { + const id = metadata.id || metadata.location || name; + if (existingIds.has(id)) continue; + existingIds.add(id); + this.addCitation({ + id, + title: name, + text: content.slice(0, 1_000) + "...continued on in source document...", + chunkSource: metadata.chunkSource || null, + score: null, + }); + } + } + + /** + * Flush all pending citations to the frontend with the given message UUID. + * Called automatically when the agent response is finalized. + * Note: Does not clear citations - they are cleared by chat-history plugin after persisting. + * @param {string} messageUuid - The UUID of the message to attach citations to + */ + flushCitations(messageUuid) { + if (!messageUuid || this._pendingCitations.length === 0) return; + this.socket?.send?.("reportStreamEvent", { + type: "citations", + uuid: messageUuid, + citations: this._pendingCitations, + }); + } + + /** + * Clear all pending citations. Called after citations have been persisted. + */ + clearCitations() { + this._pendingCitations = []; + } + + /** + * Send routing metadata to the frontend for the given message UUID. + * Only emits if routing metadata exists in handlerProps. + * @param {string} messageUuid - The UUID of the message to attach routing info to + */ + flushRoutingMetadata(messageUuid) { + const routingMetadata = this.handlerProps?.routingMetadata; + if ( + !messageUuid || + !routingMetadata?.routedTo || + !routingMetadata.routedTo.shouldNotify + ) + return; + this.socket?.send?.("reportStreamEvent", { + type: "modelRouteNotification", + uuid: `${messageUuid}:route`, + routedTo: routingMetadata.routedTo, + }); + } + + /** + * Add an attachment (image) from a tool to be injected into the conversation. + * The attachment will be added as a user message so the model can "see" it. + * This leverages existing provider attachment handling for user messages. + * @param {{name: string, mime: string, contentString: string}} attachment - The attachment object with name, mime type, and base64 data URL + */ + addToolAttachment(attachment) { + if (!attachment || !attachment.contentString) return; + this._toolAttachments.push(attachment); + } + + /** + * Add a completed clarifying-question survey to the pending buffer. + * The chat-history plugin drains this buffer when persisting the agent reply. + * @param {{questions: Array, result: Object}} survey - The survey to add + */ + addClarifyingQuestionSurvey(survey) { + if (!survey || typeof survey !== "object") return; + this._pendingClarifyingQuestionSurveys.push(survey); + } + + /** + * Clear all pending clarifying-question surveys. Called after surveys + * have been persisted to the workspace_chats record. + */ + clearClarifyingQuestionSurveys() { + this._pendingClarifyingQuestionSurveys = []; + } + + /** + * Collect and clear any pending tool attachments. + * @returns {Array<{name: string, mime: string, contentString: string}>} The collected attachments + */ + collectToolAttachments() { + if (this._toolAttachments.length === 0) return []; + const attachments = [...this._toolAttachments]; + this._toolAttachments = []; + return attachments; + } + + /** + * Add a new agent to the AIbitat. + * + * @param name + * @param config + * @returns + */ + agent(name = "", config = {}) { + this.agents.set(name, config); + return this; + } + + /** + * Add a new channel to the AIbitat. + * + * @param name + * @param members + * @param config + * @returns + */ + channel(name = "", members = [""], config = {}) { + this.channels.set(name, { + members, + ...config, + }); + return this; + } + + /** + * Get the specific agent configuration. + * + * @param agent The name of the agent. + * @throws When the agent configuration is not found. + * @returns The agent configuration. + */ + getAgentConfig(agent = "") { + const config = this.agents.get(agent); + if (!config) { + throw new Error(`Agent configuration "${agent}" not found`); + } + return { + role: "You are a helpful AI assistant.", + // role: `You are a helpful AI assistant. + // Solve tasks using your coding and language skills. + // In the following cases, suggest typescript code (in a typescript coding block) or shell script (in a sh coding block) for the user to execute. + // 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. + // 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. + // Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. + // When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. + // If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. + // If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. + // When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. + // Reply "TERMINATE" when everything is done.`, + ...config, + }; + } + + /** + * Get the specific channel configuration. + * + * @param channel The name of the channel. + * @throws When the channel configuration is not found. + * @returns The channel configuration. + */ + getChannelConfig(channel = "") { + const config = this.channels.get(channel); + if (!config) { + throw new Error(`Channel configuration "${channel}" not found`); + } + return { + maxRounds: 10, + role: "", + ...config, + }; + } + + /** + * Get the members of a group. + * @throws When the group is not defined as an array in the connections. + * @param node The name of the group. + * @returns The members of the group. + */ + getGroupMembers(node = "") { + const group = this.getChannelConfig(node); + return group.members; + } + + /** + * Triggered when a plugin, socket, or command is aborted. + * + * @param listener + * @returns + */ + onAbort(listener = () => null) { + this.emitter.on("abort", listener); + return this; + } + + /** + * Abort the running of any plugins that may still be pending (Langchain summarize) + */ + abort() { + this.emitter.emit("abort", null, this); + } + + /** + * Triggered when a chat is terminated. After this, the chat can't be continued. + * + * @param listener + * @returns + */ + onTerminate(listener = () => null) { + this.emitter.on("terminate", listener); + return this; + } + + /** + * Terminate the chat. After this, the chat can't be continued. + * + * @param node Last node to chat with + */ + terminate(node = "") { + this.emitter.emit("terminate", node, this); + } + + /** + * Triggered when a chat is interrupted by a node. + * + * @param listener + * @returns + */ + onInterrupt(listener = () => null) { + this.emitter.on("interrupt", listener); + return this; + } + + /** + * Interruption the chat. + * + * @param route The nodes that participated in the interruption. + * @returns + */ + interrupt(route) { + this._chats.push({ + ...route, + state: "interrupt", + }); + this.emitter.emit("interrupt", route, this); + } + + /** + * Triggered when a message is added to the chat history. + * This can either be the first message or a reply to a message. + * + * @param listener + * @returns + */ + onMessage(listener = (chat) => null) { + this.emitter.on("message", listener); + return this; + } + + /** + * Register a new successful message in the chat history. + * This will trigger the `onMessage` event. + * + * @param message + */ + newMessage(message) { + const chat = { + ...message, + state: "success", + }; + + this._chats.push(chat); + this.emitter.emit("message", chat, this); + } + + /** + * Triggered when an error occurs during the chat. + * + * @param listener + * @returns + */ + onError( + listener = ( + /** + * The error that occurred. + * + * Native errors are: + * - `APIError` + * - `AuthorizationError` + * - `UnknownError` + * - `RateLimitError` + * - `ServerError` + */ + error = null, + /** + * The message when the error occurred. + */ + // eslint-disable-next-line + {} + ) => null + ) { + this.emitter.on("replyError", listener); + return this; + } + + /** + * Triggered when a tool call completes and returns a result. + * Used by scheduled jobs to capture tool results for the execution trace. + * + * @param listener + * @returns + */ + onToolCallResult(listener = () => null) { + this.emitter.on("toolCallResult", listener); + return this; + } + + /** + * Register an error in the chat history. + * This will trigger the `onError` event. + * + * @param route + * @param error + */ + newError(route, error) { + const chat = { + ...route, + content: error instanceof Error ? error.message : String(error), + state: "error", + }; + this._chats.push(chat); + this.emitter.emit("replyError", error, chat); + } + + /** + * Triggered when a chat is interrupted by a node. + * + * @param listener + * @returns + */ + onStart(listener = (chat, aibitat) => null) { + this.emitter.on("start", listener); + return this; + } + + /** + * Start a new chat. + * + * @param message The message to start the chat. + */ + async start(message) { + // register the message in the chat history + this.newMessage(message); + this.emitter.emit("start", message, this); + + // ask the node to reply + await this.chat({ + to: message.from, + from: message.to, + }); + + return this; + } + + /** + * Recursively chat between two nodes. + * + * @param route + * @param keepAlive Whether to keep the chat alive. + */ + async chat(route, keepAlive = true) { + // check if the message is for a group + // if it is, select the next node to chat with from the group + // and then ask them to reply. + if (this.channels.get(route.from)) { + // select a node from the group + let nextNode; + try { + nextNode = await this.selectNext(route.from); + } catch (error) { + if (error instanceof APIError) { + return this.newError({ from: route.from, to: route.to }, error); + } + throw error; + } + + if (!nextNode) { + // TODO: should it throw an error or keep the chat alive when there is no node to chat with in the group? + // maybe it should wrap up the chat and reply to the original node + // For now, it will terminate the chat + this.terminate(route.from); + return; + } + + const nextChat = { + from: nextNode, + to: route.from, + }; + + if (this.shouldAgentInterrupt(nextNode)) { + this.interrupt(nextChat); + return; + } + + // get chats only from the group's nodes + const history = this.getHistory({ to: route.from }); + const group = this.getGroupMembers(route.from); + const rounds = history.filter((chat) => group.includes(chat.from)).length; + + const { maxRounds } = this.getChannelConfig(route.from); + if (rounds >= maxRounds) { + this.terminate(route.to); + return; + } + + await this.chat(nextChat); + return; + } + + // If it's a direct message, reply to the message + let reply = ""; + try { + reply = await this.reply(route); + } catch (error) { + if (error instanceof APIError) { + return this.newError({ from: route.from, to: route.to }, error); + } + throw error; + } + + if ( + reply === "TERMINATE" || + this.hasReachedMaximumRounds(route.from, route.to) + ) { + this.terminate(route.to); + return; + } + + const newChat = { to: route.from, from: route.to }; + + if ( + reply === "INTERRUPT" || + (this.agents.get(route.to) && this.shouldAgentInterrupt(route.to)) + ) { + this.interrupt(newChat); + return; + } + + if (keepAlive) { + // keep the chat alive by replying to the other node + await this.chat(newChat, true); + } + } + + /** + * Check if the agent should interrupt the chat based on its configuration. + * + * @param agent + * @returns {boolean} Whether the agent should interrupt the chat. + */ + shouldAgentInterrupt(agent = "") { + const config = this.getAgentConfig(agent); + return this.defaultInterrupt === "ALWAYS" || config.interrupt === "ALWAYS"; + } + + /** + * Select the next node to chat with from a group. The node will be selected based on the history of chats. + * It will select the node that has not reached the maximum number of rounds yet and has not chatted with the channel in the last round. + * If it could not determine the next node, it will return a random node. + * + * @param channel The name of the group. + * @returns The name of the node to chat with. + */ + async selectNext(channel = "") { + // get all members of the group + const nodes = this.getGroupMembers(channel); + const channelConfig = this.getChannelConfig(channel); + + // TODO: move this to when the group is created + // warn if the group is underpopulated + if (nodes.length < 3) { + console.warn( + `- Group (${channel}) is underpopulated with ${nodes.length} agents. Direct communication would be more efficient.` + ); + } + + // get the nodes that have not reached the maximum number of rounds + const availableNodes = nodes.filter( + (node) => !this.hasReachedMaximumRounds(channel, node) + ); + + // remove the last node that chatted with the channel, so it doesn't chat again + const lastChat = this._chats.filter((c) => c.to === channel).at(-1); + if (lastChat) { + const index = availableNodes.indexOf(lastChat.from); + if (index > -1) { + availableNodes.splice(index, 1); + } + } + + // TODO: what should it do when there is no node to chat with? + if (!availableNodes.length) return; + + // get the provider that will be used for the channel + // if the channel has a provider, use that otherwise + // use the GPT-4 because it has a better reasoning + const provider = this.getProviderForConfig({ + // @ts-expect-error + model: "gpt-4", + ...this.defaultProvider, + ...channelConfig, + }); + provider.attachHandlerProps(this.handlerProps); + + const history = this.getHistory({ to: channel }); + + // build the messages to send to the provider + const messages = [ + { + role: "system", + content: channelConfig.role, + }, + { + role: "user", + content: `You are in a role play game. The following roles are available: +${availableNodes.map((node) => `@${node}: ${this.getAgentConfig(node).role}`).join("\n")}. + +Read the following conversation. + +CHAT HISTORY +${history.map((c) => `@${c.from}: ${c.content}`).join("\n")} + +Then select the next role from that is going to speak next. +Only return the role. +`, + }, + ]; + + // ask the provider to select the next node to chat with + // and remove the @ from the response + const { result } = await provider.complete(messages); + const name = result?.replace(/^@/g, ""); + if (this.agents.get(name)) return name; + + // if the name is not in the nodes, return a random node + return availableNodes[Math.floor(Math.random() * availableNodes.length)]; + } + + /** + * + * @param {string} pluginName this name of the plugin being called + * @returns string of the plugin to be called compensating for children denoted by # in the string. + * eg: sql-agent:list-database-connections + * or is a custom plugin + * eg: @@custom-plugin-name + */ + #parseFunctionName(pluginName = "") { + if (!pluginName.includes("#") && !pluginName.startsWith("@@")) + return pluginName; + if (pluginName.startsWith("@@")) return pluginName.replace("@@", ""); + return pluginName.split("#")[1]; + } + + /** + * Extract the user's prompt from the messages array for tool reranking. + * Gets the content of the last user message. + * @param {Array} messages - Array of chat messages + * @returns {string|null} The user's prompt or null if not found + */ + #extractUserPrompt(messages) { + if (!messages || !Array.isArray(messages)) return null; + + // Find the last user message + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "user" && msg.content) { + return typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content); + } + } + return null; + } + + /** + * Check if the chat has reached the maximum number of rounds. + */ + hasReachedMaximumRounds(from = "", to = "") { + return this.getHistory({ from, to }).length >= this.maxRounds; + } + + /** + * Get the chat history between two nodes or all chats to/from a node. + * + * @param route + * @returns + */ + getOrFormatNodeChatHistory(route) { + if (this.channels.get(route.to)) { + return [ + { + role: "user", + content: `You are in a whatsapp group. Read the following conversation and then reply. +Do not add introduction or conclusion to your reply because this will be a continuous conversation. Don't introduce yourself. + +CHAT HISTORY +${this.getHistory({ to: route.to }) + .map((c) => `@${c.from}: ${c.content}`) + .join("\n")} + +@${route.from}:`, + }, + ]; + } + + // This is normal chat between user<->agent + // Include attachments if present (for vision/multimodal support) + return this.getHistory(route).map((c) => { + const message = { + content: c.content, + role: c.from === route.to ? "user" : "assistant", + }; + // Pass attachments through for user messages that have them + if ( + c.attachments && + c.attachments.length > 0 && + message.role === "user" + ) { + message.attachments = c.attachments; + } + return message; + }); + } + + /** + * Ask the for the AI provider to generate a reply to the chat. + * This will load the functions that the node can call and the chat history. + * Then before calling the provider, it will check if the provider supports agent streaming. + * If it does, it will call the provider asynchronously (streaming). + * Otherwise, it will call the provider synchronously (non-streaming). + * `.supportsAgentStreaming` is used to determine if the provider supports agent streaming on the respective provider. + * + * @param route.to The node that sent the chat. + * @param route.from The node that will reply to the chat. + */ + async reply(route) { + const fromConfig = this.getAgentConfig(route.from); + const chatHistory = this.getOrFormatNodeChatHistory(route); + + // Fetch fresh parsed file context and inject into the last user message + if (this.fetchParsedFileContext) { + const parsedContext = await this.fetchParsedFileContext(); + if (parsedContext) { + // Find the last user message and append context to it + for (let i = chatHistory.length - 1; i >= 0; i--) { + if (chatHistory[i].role === "user") { + chatHistory[i] = { + ...chatHistory[i], + content: chatHistory[i].content + parsedContext, + }; + break; + } + } + } + } + + const messages = [ + { + content: fromConfig.role, + role: "system", + }, + ...chatHistory, + ]; + + // get the functions that the node can call + let functions = fromConfig.functions + ?.map((name) => this.functions.get(this.#parseFunctionName(name))) + .filter((a) => !!a); + + // Rerank tools based on user prompt if enabled + if (ToolReranker.isEnabled() && functions?.length) { + const toolReranker = new ToolReranker(); + const userPrompt = this.#extractUserPrompt(messages); + if (userPrompt) + functions = await toolReranker.rerank(userPrompt, functions); + } else { + if (functions?.length > ToolReranker.defaultTopN) { + this.handlerProps.log?.( + ` + +\x1b[44m[HINT]\x1b[0m: You are injecting \x1b[0;93m${functions.length} tools\x1b[0m into every request. +Consider enabling \x1b[0;93mIntelligent Skill Selection\x1b[0m to reduce token usage from tool call bloat by up to \x1b[0;93m80% per request\x1b[0m. +https://docs.anythingllm.com/agent/intelligent-tool-selection + +` + ); + } + } + + // Re-evaluate model router before each turn if a resolver is attached. + // This ensures routing rules are applied per-message, not just at initialization. + if (this.resolveRoute) { + const userPrompt = + this.#extractUserPrompt(messages) || route.content || ""; + const resolved = await this.resolveRoute(userPrompt); + if (resolved) { + this.defaultProvider = { + ...this.defaultProvider, + provider: resolved.provider, + model: resolved.model, + }; + } + } + + this.providerInstance = this.getProviderForConfig({ + ...this.defaultProvider, + ...fromConfig, + }); + this.providerInstance.attachHandlerProps(this.handlerProps); + + let content; + if (this.providerInstance.supportsAgentStreaming) { + this.handlerProps.log?.( + "[DEBUG] Provider supports agent streaming - will use async execution!" + ); + content = await this.handleAsyncExecution( + messages, + functions, + route.from + ); + } else { + this.handlerProps.log?.( + "[DEBUG] Provider does not support agent streaming - will use synchronous execution!" + ); + content = await this.handleExecution(messages, functions, route.from); + } + + this.newMessage({ ...route, content }); + return content; + } + + /** + * Wrapper for provider calls that catches errors and converts them to APIError. + * This ensures provider errors are properly surfaced to the user instead of crashing. + * + * @param {Function} providerCall - Async function that calls the provider + * @returns {Promise} - The result of the provider call + * @throws {APIError} - If the provider call fails + */ + async #safeProviderCall(providerCall) { + try { + return await providerCall(); + } catch (error) { + console.error(`[AIbitat] Provider error: ${error.message}`, { + hide_meta: true, + }); + throw new APIError(`The agent model failed to respond: ${error.message}`); + } + } + + /** + * Handle the async (streaming) execution of the provider + * with tool calls. Reads the provider from this.providerInstance. + * + * @param messages + * @param functions + * @param byAgent + * + * @returns {Promise} + */ + async handleAsyncExecution( + messages = [], + functions = [], + byAgent = null, + depth = 0 + ) { + const eventHandler = (type, data) => { + this?.socket?.send(type, data); + }; + + // Emit routing notification before the first completion so it appears above the response + if (depth === 0) this?.flushRoutingMetadata?.(v4()); + + /** @type {{ functionCall: { name: string, arguments: string }, textResponse: string }} */ + const completionStream = await this.#safeProviderCall(() => + this.providerInstance.stream(messages, functions, eventHandler) + ); + + if (completionStream.functionCall) { + const { name, arguments: args } = completionStream.functionCall; + const fn = this.functions.get(name); + const reachedToolLimit = depth >= this.maxToolCalls; + + if (reachedToolLimit) { + this.handlerProps?.log?.( + `[warning]: Maximum tool call limit (${this.maxToolCalls}) reached. Executing final tool call then generating response.` + ); + this?.introspect?.( + `Maximum tool call limit (${this.maxToolCalls}) reached. After this tool I will generate a final response.` + ); + } + + if (!fn) { + return await this.handleAsyncExecution( + [ + ...messages, + { + name, + role: "function", + content: `Function "${name}" not found. Try again.`, + originalFunctionCall: completionStream.functionCall, + }, + ], + reachedToolLimit ? [] : functions, + byAgent, + depth + 1 + ); + } + + fn.caller = byAgent || "agent"; + + if (this.providerInstance?.verbose) { + this?.introspect?.( + `${fn.caller} is executing \`${name}\` tool ${JSON.stringify(args, null, 2)}` + ); + } + + this.handlerProps?.log?.( + `[debug]: ${fn.caller} is attempting to call \`${name}\` tool ${JSON.stringify(args, null, 2)}` + ); + + const result = await fn.handler(args); + Telemetry.sendTelemetry("agent_tool_call", { tool: name }, null, true); + this.emitter.emit("toolCallResult", { + toolName: name, + arguments: args, + result, + }); + + /** + * If the tool call has direct output enabled, return the result directly to the chat + * without any further processing and no further tool calls will be run. + * For streaming, we need to return the result directly to the chat via the event handler + * or else no response will be sent to the chat. + */ + if (this.skipHandleExecution) { + this.skipHandleExecution = false; + this?.introspect?.( + `The tool call has direct output enabled! The result will be returned directly to the chat without any further processing and no further tool calls will be run.` + ); + this?.introspect?.(`Tool use completed.`); + this.handlerProps?.log?.( + `${fn.caller} tool call resulted in direct output! Returning raw result as string. NO MORE TOOL CALLS WILL BE EXECUTED.` + ); + const directOutputUUID = completionStream?.uuid || v4(); + eventHandler?.("reportStreamEvent", { + type: "fullTextResponse", + uuid: directOutputUUID, + content: result, + }); + eventHandler?.("reportStreamEvent", { + type: "usageMetrics", + uuid: directOutputUUID, + metrics: this.providerInstance.getUsage(), + }); + this?.flushCitations?.(directOutputUUID); + this?.emitChatId?.(directOutputUUID); + return result; + } + + const toolAttachments = this.collectToolAttachments(); + const newMessages = [ + ...messages, + { + name, + role: "function", + content: result, + originalFunctionCall: completionStream.functionCall, + }, + ]; + + if (toolAttachments.length > 0) { + this.handlerProps?.log?.( + `[debug]: Injecting ${toolAttachments.length} image attachment(s) from tool result` + ); + newMessages.push({ + role: "user", + content: "[Attached image(s) from tool result]", + attachments: toolAttachments, + }); + } + + return await this.handleAsyncExecution( + newMessages, + reachedToolLimit ? [] : functions, + byAgent, + depth + 1 + ); + } + + const responseUuid = completionStream?.uuid || v4(); + eventHandler?.("reportStreamEvent", { + type: "usageMetrics", + uuid: responseUuid, + metrics: this.providerInstance.getUsage(), + }); + this?.flushCitations?.(responseUuid); + this?.emitChatId?.(responseUuid); + return completionStream?.textResponse; + } + + /** + * Handle the synchronous (non-streaming) execution of the provider + * with tool calls. Reads the provider from this.providerInstance. + * + * @param messages + * @param functions + * @param byAgent + * @param depth + * @param msgUUID - The message UUID to use for event correlation (created at depth=0) + * + * @returns {Promise} + */ + async handleExecution( + messages = [], + functions = [], + byAgent = null, + depth = 0, + msgUUID = null + ) { + // Create a stable UUID at the start of execution for event correlation + if (!msgUUID) msgUUID = v4(); + const eventHandler = (type, data) => { + this?.socket?.send(type, data); + }; + + // Emit routing notification before the first completion so it appears above the response + if (depth === 0) this?.flushRoutingMetadata?.(msgUUID); + + // get the chat completion + const completion = await this.#safeProviderCall(() => + this.providerInstance.complete(messages, functions) + ); + + if (completion.functionCall) { + const { name, arguments: args } = completion.functionCall; + const fn = this.functions.get(name); + const reachedToolLimit = depth >= this.maxToolCalls; + + if (reachedToolLimit) { + this.handlerProps?.log?.( + `[warning]: Maximum tool call limit (${this.maxToolCalls}) reached. Executing final tool call then generating response.` + ); + this?.introspect?.( + `Maximum tool call limit (${this.maxToolCalls}) reached. After this tool I will generate a final response.` + ); + } + + if (!fn) { + return await this.handleExecution( + [ + ...messages, + { + name, + role: "function", + content: `Function "${name}" not found. Try again.`, + originalFunctionCall: completion.functionCall, + }, + ], + reachedToolLimit ? [] : functions, + byAgent, + depth + 1, + msgUUID + ); + } + + fn.caller = byAgent || "agent"; + + if (this.providerInstance?.verbose) { + this?.introspect?.( + `[debug]: ${fn.caller} is attempting to call \`${name}\` tool` + ); + } + + this.handlerProps?.log?.( + `[debug]: ${fn.caller} is attempting to call \`${name}\` tool` + ); + + const result = await fn.handler(args); + Telemetry.sendTelemetry("agent_tool_call", { tool: name }, null, true); + this.emitter.emit("toolCallResult", { + toolName: name, + arguments: args, + result, + }); + + if (this.skipHandleExecution) { + this.skipHandleExecution = false; + this?.introspect?.( + `The tool call has direct output enabled! The result will be returned directly to the chat without any further processing and no further tool calls will be run.` + ); + this?.introspect?.(`Tool use completed.`); + this.handlerProps?.log?.( + `${fn.caller} tool call resulted in direct output! Returning raw result as string. NO MORE TOOL CALLS WILL BE EXECUTED.` + ); + eventHandler?.("reportStreamEvent", { + type: "usageMetrics", + uuid: msgUUID, + metrics: this.providerInstance.getUsage(), + }); + this?.flushCitations?.(msgUUID); + return result; + } + + const toolAttachments = this.collectToolAttachments(); + const newMessages = [ + ...messages, + { + name, + role: "function", + content: result, + originalFunctionCall: completion.functionCall, + }, + ]; + + if (toolAttachments.length > 0) { + this.handlerProps?.log?.( + `[debug]: Injecting ${toolAttachments.length} image attachment(s) from tool result` + ); + newMessages.push({ + role: "user", + content: "[Attached image(s) from tool result]", + attachments: toolAttachments, + }); + } + + return await this.handleExecution( + newMessages, + reachedToolLimit ? [] : functions, + byAgent, + depth + 1, + msgUUID + ); + } + + eventHandler?.("reportStreamEvent", { + type: "usageMetrics", + uuid: msgUUID, + metrics: this.providerInstance.getUsage(), + }); + this?.flushCitations?.(msgUUID); + this?.emitChatId?.(msgUUID); + return completion?.textResponse; + } + + /** + * Continue the chat from the last interruption. + * If the last chat was not an interruption, it will throw an error. + * Provide a feedback where it was interrupted if you want to. + * + * @param feedback The feedback to the interruption if any. + * @param attachments Optional attachments (images) to include with the feedback. + * @returns + */ + async continue(feedback, attachments = []) { + const lastChat = this._chats.at(-1); + if (!lastChat || lastChat.state !== "interrupt") { + throw new Error("No chat to continue"); + } + + // remove the last chat's that was interrupted + this._chats.pop(); + + const { from, to } = lastChat; + + if (this.hasReachedMaximumRounds(from, to)) { + throw new Error("Maximum rounds reached"); + } + + if (feedback) { + const message = { + from, + to, + content: feedback, + ...(attachments?.length > 0 ? { attachments } : {}), + }; + + // register the message in the chat history + this.newMessage(message); + + // ask the node to reply + await this.chat({ + to: message.from, + from: message.to, + }); + } else { + await this.chat({ from, to }); + } + + return this; + } + + /** + * Retry the last chat that threw an error. + * If the last chat was not an error, it will throw an error. + */ + async retry() { + const lastChat = this._chats.at(-1); + if (!lastChat || lastChat.state !== "error") { + throw new Error("No chat to retry"); + } + + // remove the last chat's that threw an error + // eslint-disable-next-line + const { from, to } = this?._chats?.pop(); + + await this.chat({ from, to }); + return this; + } + + /** + * Get the chat history between two nodes or all chats to/from a node. + */ + getHistory({ from, to }) { + return this._chats.filter((chat) => { + const isSuccess = chat.state === "success"; + + // return all chats to the node + if (!from) { + return isSuccess && chat.to === to; + } + + // get all chats from the node + if (!to) { + return isSuccess && chat.from === from; + } + + // check if the chat is between the two nodes + const hasSent = chat.from === from && chat.to === to; + const hasReceived = chat.from === to && chat.to === from; + const mutual = hasSent || hasReceived; + + return isSuccess && mutual; + }); + } + + /** + * Get provider based on configurations. + * If the provider is a string, it will return the default provider for that string. + * + * @param config The provider configuration. + * @returns {Providers.OpenAIProvider} The provider instance. + */ + getProviderForConfig(config) { + if (typeof config.provider === "object") return config.provider; + + switch (config.provider) { + case "openai": + return new Providers.OpenAIProvider({ model: config.model }); + case "anthropic": + return new Providers.AnthropicProvider({ model: config.model }); + case "lmstudio": + return new Providers.LMStudioProvider({ model: config.model }); + case "ollama": + return new Providers.OllamaProvider({ model: config.model }); + case "groq": + return new Providers.GroqProvider({ model: config.model }); + case "togetherai": + return new Providers.TogetherAIProvider({ model: config.model }); + case "azure": + return new Providers.AzureOpenAiProvider({ model: config.model }); + case "koboldcpp": + return new Providers.KoboldCPPProvider({}); + case "localai": + return new Providers.LocalAIProvider({ model: config.model }); + case "openrouter": + return new Providers.OpenRouterProvider({ model: config.model }); + case "mistral": + return new Providers.MistralProvider({ model: config.model }); + case "generic-openai": + return new Providers.GenericOpenAiProvider({ model: config.model }); + case "perplexity": + return new Providers.PerplexityProvider({ model: config.model }); + case "textgenwebui": + return new Providers.TextWebGenUiProvider({}); + case "bedrock": + return new Providers.AWSBedrockProvider({}); + case "fireworksai": + return new Providers.FireworksAIProvider({ model: config.model }); + case "nvidia-nim": + return new Providers.NvidiaNimProvider({ model: config.model }); + case "moonshotai": + return new Providers.MoonshotAiProvider({ model: config.model }); + case "deepseek": + return new Providers.DeepSeekProvider({ model: config.model }); + case "litellm": + return new Providers.LiteLLMProvider({ model: config.model }); + case "apipie": + return new Providers.ApiPieProvider({ model: config.model }); + case "xai": + return new Providers.XAIProvider({ model: config.model }); + case "zai": + return new Providers.ZAIProvider({ model: config.model }); + case "novita": + return new Providers.NovitaProvider({ model: config.model }); + case "ppio": + return new Providers.PPIOProvider({ model: config.model }); + case "gemini": + return new Providers.GeminiProvider({ model: config.model }); + case "cometapi": + return new Providers.CometApiProvider({ model: config.model }); + case "foundry": + return new Providers.FoundryProvider({ model: config.model }); + case "giteeai": + return new Providers.GiteeAIProvider({ model: config.model }); + case "cohere": + return new Providers.CohereProvider({ model: config.model }); + case "docker-model-runner": + return new Providers.DockerModelRunnerProvider({ model: config.model }); + case "privatemode": + return new Providers.PrivatemodeProvider({ model: config.model }); + case "sambanova": + return new Providers.SambaNovaProvider({ model: config.model }); + case "lemonade": + return new Providers.LemonadeProvider({ model: config.model }); + case "minimax": + return new Providers.MinimaxProvider({ model: config.model }); + case "cerebras": + return new Providers.CerebrasProvider({ model: config.model }); + default: + throw new Error( + `Unknown provider: ${config.provider}. Please use a valid provider.` + ); + } + } + + /** + * Register a new function to be called by the AIbitat agents. + * You are also required to specify the which node can call the function. + * @param functionConfig The function configuration. + */ + function(functionConfig) { + this.functions.set(functionConfig.name, functionConfig); + return this; + } +} + +module.exports = AIbitat; diff --git a/server/utils/agents/aibitat/plugins/chat-history.js b/server/utils/agents/aibitat/plugins/chat-history.js new file mode 100644 index 0000000..257a3bb --- /dev/null +++ b/server/utils/agents/aibitat/plugins/chat-history.js @@ -0,0 +1,210 @@ +const { WorkspaceChats } = require("../../../../models/workspaceChats"); +const { WorkspaceThread } = require("../../../../models/workspaceThread"); + +/** + * Plugin to save chat history to AnythingLLM DB. + */ +const chatHistory = { + name: "chat-history", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup: function (aibitat) { + // If the agent is aborted (e.g. user sent /reset mid-response), skip + // the pending save so a completing in-flight response doesn't reappear. + aibitat.onAbort(() => { + aibitat._aborted = true; + }); + + // pre-register a workspace chat ID to secure it in the DB + aibitat.onMessage(async (message) => { + if (message.from !== "USER") return; + + /** + * If we don't have a tracked chat ID, we need to create a new one so we can upsert the response later. + * Normally, if this was a totally fresh chat from the user, we can assume that the message from the socket is + * the message we want to store for the prompt. However, if this is a regeneration of a previous message and that message + * called tools the history could include intermediate messages so need to search backwards to find the most recent user message + * as that is actually the prompt. + */ + if (!aibitat.trackedChatId) { + let userMessage = message.content; + if (userMessage.startsWith("@agent:")) { + const lastUserMsgIndex = aibitat._chats.findLastIndex( + (c) => c.from === "USER" && !c.content.startsWith("@agent:") + ); + + // When regenerating a message, we need to use the last user message as the prompt. + // Also prune the chats array to only include the messages before target prompt to re-run + // or else tool call results from the previous run will be included in the history and the model will not re-call tools + // that previously worked for the to-be-regenerated prompt. + if (lastUserMsgIndex !== -1) { + userMessage = aibitat._chats[lastUserMsgIndex].content; + aibitat._chats = aibitat._chats.slice(0, lastUserMsgIndex + 1); + } + } + + const { chat } = await WorkspaceChats.new({ + workspaceId: Number(aibitat.handlerProps.invocation.workspace_id), + user: { id: aibitat.handlerProps.invocation.user_id || null }, + threadId: aibitat.handlerProps.invocation.thread_id || null, + include: false, + prompt: userMessage, + response: {}, + }); + if (chat) aibitat.registerChatId(chat.id); + } + }); + + aibitat.onMessage(async () => { + try { + if (aibitat._aborted) return; + const lastResponses = aibitat.chats.slice(-2); + if (lastResponses.length !== 2) return; + const [prev, last] = lastResponses; + + // We need a full conversation reply with prev being from + // the USER and the last being from anyone other than the user. + if (prev.from !== "USER" || last.from === "USER") return; + + // Extract attachments from user message if present + const attachments = prev.attachments || []; + + // If we have a post-reply flow we should save the chat using this special flow + // so that post save cleanup and other unique properties can be run as opposed to regular chat. + if (aibitat.hasOwnProperty("_replySpecialAttributes")) { + await this._storeSpecial(aibitat, { + prompt: prev.content, + response: last.content, + attachments, + options: aibitat._replySpecialAttributes, + }); + delete aibitat._replySpecialAttributes; + return; + } + + await this._store(aibitat, { + prompt: prev.content, + response: last.content, + attachments, + }); + } catch {} + }); + }, + _store: async function ( + aibitat, + { prompt, response, attachments = [] } = {} + ) { + const invocation = aibitat.handlerProps.invocation; + const metrics = aibitat.providerInstance?.getUsage?.() ?? {}; + const citations = aibitat._pendingCitations ?? []; + const outputs = aibitat._pendingOutputs ?? []; + const clarifyingQuestions = + aibitat._pendingClarifyingQuestionSurveys ?? []; + await WorkspaceChats.upsert(aibitat.trackedChatId, { + workspaceId: Number(invocation.workspace_id), + prompt, + response: { + text: response, + sources: citations, + type: "chat", + attachments, + metrics, + ...(outputs.length > 0 ? { outputs } : {}), + ...(clarifyingQuestions.length > 0 ? { clarifyingQuestions } : {}), + }, + user: { id: invocation?.user_id || null }, + threadId: invocation?.thread_id || null, + include: true, + }); + + if (!aibitat._threadRenamed) { + aibitat._threadRenamed = await this._autoRenameThread( + aibitat, + prompt + ); + } + this._cleanup(aibitat); + }, + _storeSpecial: async function ( + aibitat, + { prompt, response, attachments = [], options = {} } = {} + ) { + const invocation = aibitat.handlerProps.invocation; + const metrics = aibitat.providerInstance?.getUsage?.() ?? {}; + const citations = aibitat._pendingCitations ?? []; + const outputs = aibitat._pendingOutputs ?? []; + const clarifyingQuestions = + aibitat._pendingClarifyingQuestionSurveys ?? []; + const existingSources = options?.sources ?? []; + await WorkspaceChats.upsert(aibitat.trackedChatId, { + workspaceId: Number(invocation.workspace_id), + prompt, + response: { + sources: [...existingSources, ...citations], + // when we have a _storeSpecial called the options param can include a storedResponse() function + // that will override the text property to store extra information in, depending on the special type of chat. + text: options.hasOwnProperty("storedResponse") + ? options.storedResponse(response) + : response, + type: options?.saveAsType ?? "chat", + attachments, + metrics, + ...(outputs.length > 0 ? { outputs } : {}), + ...(clarifyingQuestions.length > 0 ? { clarifyingQuestions } : {}), + }, + user: { id: invocation?.user_id || null }, + threadId: invocation?.thread_id || null, + include: true, + }); + + if (!aibitat._threadRenamed) { + aibitat._threadRenamed = await this._autoRenameThread( + aibitat, + prompt + ); + } + options?.postSave(); + this._cleanup(aibitat); + }, + + _autoRenameThread: async function (aibitat, prompt) { + const invocation = aibitat.handlerProps.invocation; + if (!invocation?.thread_id) return true; + + const thread = await WorkspaceThread.get({ id: invocation.thread_id }); + if (!thread) return true; + + const { Workspace } = require("../../../../models/workspace"); + const workspace = await Workspace.get({ id: invocation.workspace_id }); + if (!workspace) return true; + + await WorkspaceThread.autoRenameThread({ + thread, + workspace, + user: invocation.user_id ? { id: invocation.user_id } : null, + prompt, + onRename: (updatedThread) => { + aibitat.socket?.send("rename_thread", { + slug: updatedThread.slug, + name: updatedThread.name, + }); + }, + }); + return true; + }, + + _cleanup: function (aibitat) { + aibitat.clearCitations?.(); + aibitat._pendingOutputs = []; + aibitat.clearClarifyingQuestionSurveys?.(); + aibitat.clearTrackedChatId(); + }, + }; + }, +}; + +module.exports = { chatHistory }; diff --git a/server/utils/agents/aibitat/plugins/cli.js b/server/utils/agents/aibitat/plugins/cli.js new file mode 100644 index 0000000..f2aea0e --- /dev/null +++ b/server/utils/agents/aibitat/plugins/cli.js @@ -0,0 +1,134 @@ +// Plugin CAN ONLY BE USE IN DEVELOPMENT. +const { input } = require("@inquirer/prompts"); +const chalk = require("chalk"); + +/** + * Command-line Interface plugin. It prints the messages on the console and asks for feedback + * while the conversation is running in the background. + */ +const cli = { + name: "cli", + startupConfig: { + params: {}, + }, + plugin: function ({ simulateStream = true } = {}) { + return { + name: this.name, + setup(aibitat) { + let printing = []; + + aibitat.onError(async (error) => { + let errorMessage = + error?.message || "An error occurred while running the agent."; + console.error(chalk.red(` error: ${errorMessage}`), error); + }); + + aibitat.onStart(() => { + console.log(); + console.log("🚀 starting chat ...\n"); + printing = [Promise.resolve()]; + }); + + aibitat.onMessage(async (message) => { + const next = new Promise(async (resolve) => { + await Promise.all(printing); + await this.print(message, simulateStream); + resolve(); + }); + printing.push(next); + }); + + aibitat.onTerminate(async () => { + await Promise.all(printing); + console.log("🚀 chat finished"); + }); + + aibitat.onInterrupt(async (node) => { + await Promise.all(printing); + const feedback = await this.askForFeedback(node); + // Add an extra line after the message + console.log(); + + if (feedback === "exit") { + console.log("🚀 chat finished"); + return process.exit(0); + } + + await aibitat.continue(feedback); + }); + }, + + /** + * Print a message on the terminal + * + * @param message + * // message Type { from: string; to: string; content?: string } & { + state: 'loading' | 'error' | 'success' | 'interrupt' + } + * @param simulateStream + */ + print: async function (message = {}, simulateStream = true) { + const replying = chalk.dim(`(to ${message.to})`); + const reference = `${chalk.magenta("✎")} ${chalk.bold( + message.from + )} ${replying}:`; + + if (!simulateStream) { + console.log(reference); + console.log(message.content); + // Add an extra line after the message + console.log(); + return; + } + + process.stdout.write(`${reference}\n`); + + // Emulate streaming by breaking the cached response into chunks + const chunks = message.content?.split(" ") || []; + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + const bytes = new TextEncoder().encode(chunk + " "); + controller.enqueue(bytes); + await new Promise((r) => + setTimeout( + r, + // get a random number between 10ms and 50ms to simulate a random delay + Math.floor(Math.random() * 40) + 10 + ) + ); + } + controller.close(); + }, + }); + + // Stream the response to the chat + for await (const chunk of stream) { + process.stdout.write(new TextDecoder().decode(chunk)); + } + + // Add an extra line after the message + console.log(); + console.log(); + }, + + /** + * Ask for feedback to the user using the terminal + * + * @param node //{ from: string; to: string } + * @returns + */ + askForFeedback: function (node = {}) { + return input({ + message: `Provide feedback to ${chalk.yellow( + node.to + )} as ${chalk.yellow( + node.from + )}. Press enter to skip and use auto-reply, or type 'exit' to end the conversation: `, + }); + }, + }; + }, +}; + +module.exports = { cli }; diff --git a/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm-invert.png b/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm-invert.png new file mode 100644 index 0000000..ec14d17 Binary files /dev/null and b/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm-invert.png differ diff --git a/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm.png b/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm.png new file mode 100644 index 0000000..96a079c Binary files /dev/null and b/server/utils/agents/aibitat/plugins/create-files/assets/anything-llm.png differ diff --git a/server/utils/agents/aibitat/plugins/create-files/docx/create-docx-file.js b/server/utils/agents/aibitat/plugins/create-files/docx/create-docx-file.js new file mode 100644 index 0000000..97fcde8 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/docx/create-docx-file.js @@ -0,0 +1,308 @@ +const { + getDeploymentVersion, +} = require("../../../../../../endpoints/utils.js"); +const createFilesLib = require("../lib.js"); +const { + getTheme, + getMargins, + loadLibraries, + htmlToDocxElements, + createCoverPageSection, + createRunningHeader, + createRunningFooter, + DEFAULT_NUMBERING_CONFIG, +} = require("./utils.js"); + +module.exports.CreateDocxFile = { + name: "create-docx-file", + plugin: function () { + return { + name: "create-docx-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a Microsoft Word document (.docx) from markdown or plain text content. Supports professional styling with color themes, title pages, and running headers/footers.", + examples: [ + { + prompt: "Create a Word document with meeting notes", + call: JSON.stringify({ + filename: "meeting-notes.docx", + content: + "# Meeting Notes - Q1 Planning\n\n## Attendees\n- John Smith\n- Sarah Johnson\n- Mike Chen\n\n## Agenda\n1. Review Q4 results\n2. Set Q1 goals\n3. Assign tasks\n\n## Action Items\n| Person | Task | Due Date |\n|--------|------|----------|\n| John | Prepare budget report | Jan 15 |\n| Sarah | Draft marketing plan | Jan 20 |\n| Mike | Schedule follow-up | Jan 10 |", + }), + }, + { + prompt: + "Create a professional project proposal with a title page", + call: JSON.stringify({ + filename: "project-proposal.docx", + title: "Project Alpha Proposal", + subtitle: "Strategic Initiative for Q2 2024", + author: "Product Team", + theme: "blue", + includeTitlePage: true, + content: + "## Executive Summary\nThis proposal outlines the development of **Project Alpha**, a next-generation platform.\n\n## Objectives\n- Increase efficiency by 40%\n- Reduce costs by $50,000 annually\n- Improve user satisfaction\n\n## Timeline\n| Phase | Duration | Deliverables |\n|-------|----------|-------------|\n| Phase 1 | 4 weeks | Requirements |\n| Phase 2 | 8 weeks | Development |\n| Phase 3 | 2 weeks | Testing |\n\n## Budget\nTotal estimated budget: **$150,000**", + }), + }, + { + prompt: "Create technical documentation with warm theme", + call: JSON.stringify({ + filename: "api-documentation.docx", + title: "API Documentation", + theme: "warm", + margins: "narrow", + content: + "# API Documentation\n\n## Authentication\nAll API requests require a Bearer token in the Authorization header.\n\n```javascript\nconst headers = {\n 'Authorization': 'Bearer YOUR_TOKEN',\n 'Content-Type': 'application/json'\n};\n```\n\n## Endpoints\n\n### GET /users\nReturns a list of all users.\n\n### POST /users\nCreates a new user.\n\n> **Note:** Rate limiting applies to all endpoints.", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + filename: { + type: "string", + description: + "The filename for the Word document. Will automatically add .docx extension if not present.", + }, + title: { + type: "string", + description: + "Document title for metadata and title page. If not provided, will be extracted from content or use filename.", + }, + subtitle: { + type: "string", + description: + "Optional subtitle displayed on the title page below the main title.", + }, + author: { + type: "string", + description: + "Optional author name displayed on the title page.", + }, + content: { + type: "string", + description: + "The content to convert to a Word document. Fully supports markdown formatting.", + }, + theme: { + type: "string", + enum: ["neutral", "blue", "warm"], + description: + "Color theme for the document. 'neutral' (slate/grey), 'blue' (corporate blue), or 'warm' (earthy tones). Defaults to neutral.", + }, + margins: { + type: "string", + enum: ["normal", "narrow", "wide"], + description: + "Page margin preset. 'normal' (standard), 'narrow' (data-heavy docs), or 'wide' (letters/memos). Defaults to normal.", + }, + includeTitlePage: { + type: "boolean", + description: + "Include a professional title page with centered title, subtitle, author, and date. Content starts on page 2 with running headers/footers.", + }, + }, + required: ["filename", "content"], + additionalProperties: false, + }, + handler: async function ({ + filename = "document.docx", + title = null, + subtitle = null, + author = null, + content = "", + theme = "neutral", + margins = "normal", + includeTitlePage = false, + }) { + try { + this.super.handlerProps.log(`Using the create-docx-file tool.`); + + // Strip XML 1.0 illegal control characters (e.g. the form feed a + // LaTeX `\frac` decodes to) so Word can open the generated file. + content = createFilesLib.stripInvalidXmlChars(content); + title = createFilesLib.stripInvalidXmlChars(title); + subtitle = createFilesLib.stripInvalidXmlChars(subtitle); + author = createFilesLib.stripInvalidXmlChars(author); + + const hasExtension = /\.docx$/i.test(filename); + if (!hasExtension) filename = `${filename}.docx`; + const displayFilename = filename.split("/").pop(); + + const documentTitle = + title || + content.match(/^#\s+(.+)$/m)?.[1] || + displayFilename.replace(/\.docx$/i, ""); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { filename: displayFilename, title: documentTitle }, + description: `Create Word document "${displayFilename}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating Word document "${displayFilename}"${includeTitlePage ? " with title page" : ""}` + ); + + const libs = await loadLibraries(); + const { marked, docx } = libs; + const { Document, Packer, Paragraph, TextRun } = docx; + marked.setOptions({ + gfm: true, + breaks: true, + }); + + const themeColors = getTheme(theme); + const marginConfig = getMargins(margins); + + const html = marked.parse(content); + this.super.handlerProps.log( + `create-docx-file: Parsed markdown to HTML (${html.length} chars), theme: ${theme}, margins: ${margins}` + ); + + const logoBuffer = createFilesLib.getLogo({ + forDarkBackground: false, + format: "buffer", + }); + + const docElements = await htmlToDocxElements( + html, + libs, + this.super.handlerProps.log, + themeColors + ); + + if (docElements.length === 0) { + docElements.push( + new Paragraph({ + children: [new TextRun({ text: content })], + }) + ); + } + + const sections = []; + + if (includeTitlePage) { + const currentDate = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + sections.push( + createCoverPageSection(docx, { + title: documentTitle, + subtitle, + author, + date: currentDate, + theme: themeColors, + margins: marginConfig, + logoBuffer, + }) + ); + + sections.push({ + properties: { + page: { + margin: marginConfig, + }, + }, + children: docElements, + headers: { + default: createRunningHeader( + docx, + documentTitle, + themeColors + ), + }, + footers: { + default: createRunningFooter(docx, logoBuffer, themeColors), + }, + }); + } else { + sections.push({ + properties: { + page: { + margin: marginConfig, + }, + }, + children: docElements, + footers: { + default: createRunningFooter(docx, logoBuffer, themeColors), + }, + }); + } + + const doc = new Document({ + title: documentTitle, + creator: `AnythingLLM ${getDeploymentVersion()}`, + description: `Word Document generated by AnythingLLM ${getDeploymentVersion()}`, + numbering: DEFAULT_NUMBERING_CONFIG, + sections, + }); + + const buffer = await Packer.toBuffer(doc); + const bufferSizeKB = (buffer.length / 1024).toFixed(2); + + this.super.handlerProps.log( + `create-docx-file: Generated buffer - size: ${bufferSizeKB}KB, title: "${documentTitle}", theme: ${theme}` + ); + + const savedFile = await createFilesLib.saveGeneratedFile({ + fileType: "docx", + extension: "docx", + buffer, + displayFilename, + }); + + this.super.socket.send("fileDownloadCard", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + createFilesLib.registerOutput(this.super, "DocxFileDownload", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + this.super.introspect( + `${this.caller}: Successfully created Word document "${displayFilename}"` + ); + + const styleInfo = [ + theme !== "neutral" ? `${theme} theme` : null, + margins !== "normal" ? `${margins} margins` : null, + includeTitlePage ? "title page" : null, + ].filter(Boolean); + + const styleDesc = + styleInfo.length > 0 ? ` with ${styleInfo.join(", ")}` : ""; + + return `Successfully created Word document "${displayFilename}" (${bufferSizeKB}KB)${styleDesc}. The document includes formatted content with tables, images, Page X of Y footer, and professional styling.`; + } catch (e) { + this.super.handlerProps.log( + `create-docx-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Word document: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/docx/test-themes.js b/server/utils/agents/aibitat/plugins/create-files/docx/test-themes.js new file mode 100755 index 0000000..ec06ec5 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/docx/test-themes.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +/** + * Test utility to generate sample Word documents for all themes and configurations. + * Run from the server directory: node utils/agents/aibitat/plugins/create-files/docx/test-themes.js + * + * Output goes to: storage/generated-files/docx-theme-previews/ + */ + +const path = require("path"); +const fs = require("fs/promises"); +const { + DOCUMENT_STYLES, + getTheme, + getMargins, + loadLibraries, + htmlToDocxElements, + createCoverPageSection, + createRunningHeader, + createRunningFooter, + DEFAULT_NUMBERING_CONFIG, +} = require("./utils.js"); + +const OUTPUT_DIR = path.resolve( + __dirname, + "../../../../../../storage/generated-files/docx-theme-previews" +); + +const SAMPLE_CONTENT = `# Sample Document + +## Executive Summary + +This document demonstrates the **styling capabilities** of the Word document generator. It includes various content types to showcase how themes affect the visual appearance. + +## Key Features + +- Professional title pages with centered content +- Running headers with document title +- Page X of Y footer numbering +- Color-coordinated themes throughout + +## Data Overview + +| Metric | Q1 | Q2 | Q3 | Q4 | +|--------|-----|-----|-----|-----| +| Revenue | $1.2M | $1.5M | $1.8M | $2.1M | +| Growth | +15% | +25% | +20% | +17% | +| Users | 10K | 15K | 22K | 30K | + +## Technical Details + +Here is an example code block: + +\`\`\`javascript +const config = { + theme: "blue", + margins: "normal", + includeTitlePage: true +}; +\`\`\` + +> **Note:** This blockquote demonstrates how accent colors are applied to the left border. Blockquotes are useful for callouts and important notes. + +## Conclusion + +The themed document system provides a consistent, professional look across all generated documents. Each theme cascades colors through: + +1. Heading text colors +2. Table header backgrounds +3. Blockquote borders +4. Footer text styling + +--- + +Thank you for reviewing this sample document. +`; + +const MINIMAL_CONTENT = `# Quick Report + +## Summary + +A brief document to test minimal content rendering. + +- Point one +- Point two +- Point three + +| Item | Value | +|------|-------| +| A | 100 | +| B | 200 | +`; + +async function generateThemePreview(themeName, themeConfig, options = {}) { + const libs = await loadLibraries(); + const { marked, docx } = libs; + const { Document, Packer, Paragraph, TextRun } = docx; + + marked.setOptions({ gfm: true, breaks: true }); + + const { + margins = "normal", + includeTitlePage = false, + content = SAMPLE_CONTENT, + subtitle = null, + author = null, + } = options; + + const marginConfig = getMargins(margins); + const title = `${themeConfig.name || themeName} Theme Preview`; + + const html = marked.parse(content); + const docElements = await htmlToDocxElements( + html, + libs, + console.log, + themeConfig + ); + + if (docElements.length === 0) { + docElements.push( + new Paragraph({ + children: [new TextRun({ text: content })], + }) + ); + } + + const sections = []; + + if (includeTitlePage) { + const currentDate = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + sections.push( + createCoverPageSection(docx, { + title, + subtitle: subtitle || `Demonstrating the ${themeName} color scheme`, + author: author || "AnythingLLM Theme Tester", + date: currentDate, + theme: themeConfig, + margins: marginConfig, + logoBuffer: null, + }) + ); + + sections.push({ + properties: { + page: { margin: marginConfig }, + titlePage: true, + }, + children: docElements, + headers: { + default: createRunningHeader(docx, title, themeConfig), + }, + footers: { + default: createRunningFooter(docx, null, themeConfig), + }, + }); + } else { + sections.push({ + properties: { + page: { margin: marginConfig }, + }, + children: docElements, + footers: { + default: createRunningFooter(docx, null, themeConfig), + }, + }); + } + + const doc = new Document({ + title, + creator: "AnythingLLM Theme Tester", + description: `Theme preview for ${themeName}`, + numbering: DEFAULT_NUMBERING_CONFIG, + sections, + }); + + return Packer.toBuffer(doc); +} + +async function main() { + console.log("DOCX Theme Preview Generator"); + console.log("============================\n"); + + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + + const themes = Object.keys(DOCUMENT_STYLES.themes); + const marginPresets = Object.keys(DOCUMENT_STYLES.margins); + + console.log(`Themes: ${themes.join(", ")}`); + console.log(`Margins: ${marginPresets.join(", ")}\n`); + + const configs = []; + + for (const themeName of themes) { + configs.push({ + name: `theme-${themeName}-simple`, + theme: themeName, + margins: "normal", + includeTitlePage: false, + content: SAMPLE_CONTENT, + }); + + configs.push({ + name: `theme-${themeName}-with-title-page`, + theme: themeName, + margins: "normal", + includeTitlePage: true, + content: SAMPLE_CONTENT, + }); + } + + for (const marginName of marginPresets) { + configs.push({ + name: `margins-${marginName}`, + theme: "neutral", + margins: marginName, + includeTitlePage: true, + content: SAMPLE_CONTENT, + }); + } + + configs.push({ + name: `full-featured-blue`, + theme: "blue", + margins: "normal", + includeTitlePage: true, + content: SAMPLE_CONTENT, + subtitle: "A Complete Feature Demonstration", + author: "Documentation Team", + }); + + configs.push({ + name: `minimal-warm`, + theme: "warm", + margins: "narrow", + includeTitlePage: false, + content: MINIMAL_CONTENT, + }); + + console.log(`Generating ${configs.length} preview documents...\n`); + + for (const config of configs) { + const themeConfig = getTheme(config.theme); + try { + const buffer = await generateThemePreview(config.theme, themeConfig, { + margins: config.margins, + includeTitlePage: config.includeTitlePage, + content: config.content, + subtitle: config.subtitle, + author: config.author, + }); + + const filename = `${config.name}.docx`; + const filepath = path.join(OUTPUT_DIR, filename); + await fs.writeFile(filepath, buffer); + + const sizeKB = (buffer.length / 1024).toFixed(1); + const titlePage = config.includeTitlePage ? "✓ title" : " - "; + console.log( + `✓ ${config.name.padEnd(30)} [${config.theme.padEnd(7)}] [${config.margins.padEnd(6)}] ${titlePage} (${sizeKB}KB)` + ); + } catch (error) { + console.error(`✗ ${config.name.padEnd(30)} → Error: ${error.message}`); + console.error(error.stack); + } + } + + console.log(`\n✅ Done! Files saved to: ${OUTPUT_DIR}`); + console.log( + "\nOpen the .docx files in Microsoft Word or LibreOffice to preview each configuration." + ); + + console.log("\n--- Theme Color Reference ---"); + for (const [name, colors] of Object.entries(DOCUMENT_STYLES.themes)) { + console.log(`\n${name.toUpperCase()}:`); + console.log(` Heading: #${colors.heading}`); + console.log(` Accent: #${colors.accent}`); + console.log(` Table Header: #${colors.tableHeader}`); + console.log(` Border: #${colors.border}`); + console.log(` Cover BG: #${colors.coverBg}`); + console.log(` Footer Text: #${colors.footerText}`); + } + + console.log("\n--- Margin Presets (twips) ---"); + for (const [name, margins] of Object.entries(DOCUMENT_STYLES.margins)) { + const inchTop = (margins.top / 1440).toFixed(2); + const inchLeft = (margins.left / 1440).toFixed(2); + console.log( + `${name.padEnd(8)}: top/bottom=${inchTop}" left/right=${inchLeft}"` + ); + } +} + +main().catch(console.error); diff --git a/server/utils/agents/aibitat/plugins/create-files/docx/utils.js b/server/utils/agents/aibitat/plugins/create-files/docx/utils.js new file mode 100644 index 0000000..daebecf --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/docx/utils.js @@ -0,0 +1,1095 @@ +/** + * Utilities for converting markdown to DOCX format. + * Uses marked for parsing, jsdom for HTML traversal, and docx for document generation. + */ + +/** + * Document style presets for professional-looking documents. + */ +const DOCUMENT_STYLES = { + margins: { + normal: { top: 1440, bottom: 1440, left: 1800, right: 1800 }, + narrow: { top: 720, bottom: 720, left: 720, right: 720 }, + wide: { top: 1440, bottom: 1440, left: 2880, right: 2880 }, + }, + themes: { + neutral: { + heading: "2E4057", + accent: "048A81", + tableHeader: "E7E6E6", + border: "CCCCCC", + coverBg: "2E4057", + coverText: "FFFFFF", + footerText: "666666", + }, + blue: { + heading: "1B3A6B", + accent: "2E86AB", + tableHeader: "D6E8F5", + border: "A8C8E8", + coverBg: "1B3A6B", + coverText: "FFFFFF", + footerText: "2E86AB", + }, + warm: { + heading: "5C3317", + accent: "C1440E", + tableHeader: "F5ECD7", + border: "D4B896", + coverBg: "5C3317", + coverText: "FFFFFF", + footerText: "8B6914", + }, + }, + fonts: { + body: "Calibri", + heading: "Calibri", + mono: "Consolas", + }, + borders: { + none: { + top: { style: "none" }, + bottom: { style: "none" }, + left: { style: "none" }, + right: { style: "none" }, + }, + }, +}; + +/** + * Gets the theme colors, falling back to neutral if not found. + * @param {string} themeName - The theme name + * @returns {Object} Theme color configuration + */ +function getTheme(themeName) { + return DOCUMENT_STYLES.themes[themeName] || DOCUMENT_STYLES.themes.neutral; +} + +/** + * Gets the margin configuration, falling back to normal if not found. + * @param {string} marginName - The margin preset name + * @returns {Object} Margin configuration + */ +function getMargins(marginName) { + return DOCUMENT_STYLES.margins[marginName] || DOCUMENT_STYLES.margins.normal; +} + +/** + * Lazy-loads the required libraries for docx generation. + * @returns {Promise} The loaded libraries + */ +async function loadLibraries() { + const { marked } = await import("marked"); + const { JSDOM } = await import("jsdom"); + const docx = await import("docx"); + + return { + marked, + JSDOM, + docx, + }; +} + +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB +const IMAGE_FETCH_TIMEOUT_MS = 2000; + +/** + * Image magic bytes and their corresponding types. + * This is hardcoded and there might be a better way to do this but this is for simple image validation. + * @type {Object} + */ +const IMAGE_MAGIC_BYTES = { + png: { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], type: "png" }, + jpg: { bytes: [0xff, 0xd8, 0xff], type: "jpg" }, + gif87a: { bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], type: "gif" }, + gif89a: { bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], type: "gif" }, + bmp: { bytes: [0x42, 0x4d], type: "bmp" }, + webp: { + bytes: [0x52, 0x49, 0x46, 0x46], + type: "webp", + offset4: [0x57, 0x45, 0x42, 0x50], + }, +}; + +/** + * Validates image buffer by checking magic bytes to determine actual image type. + * @param {Buffer} buffer - The image buffer to validate + * @returns {{valid: boolean, type: string|null}} Validation result with detected type + */ +function validateImageMagicBytes(buffer) { + if (!buffer || buffer.length < 12) { + return { valid: false, type: null }; + } + + for (const [, signature] of Object.entries(IMAGE_MAGIC_BYTES)) { + const matches = signature.bytes.every((byte, i) => buffer[i] === byte); + if (matches) { + if (signature.offset4) { + const offset4Matches = signature.offset4.every( + (byte, i) => buffer[8 + i] === byte + ); + if (!offset4Matches) continue; + } + return { valid: true, type: signature.type }; + } + } + + return { valid: false, type: null }; +} + +/** + * Fetches an image from a URL or processes a base64 data string. + * @param {string} src - Image source (URL or base64) + * @param {Function} log - Logging function + * @returns {Promise<{buffer: Buffer, type: string, width: number, height: number} | null>} + */ +async function fetchImage(src, log) { + try { + let imageBuffer; + let imageType = "png"; + + if (src.startsWith("data:")) { + const match = src.match(/^data:image\/(\w+);base64,(.+)$/); + if (match) { + imageBuffer = Buffer.from(match[2], "base64"); + + if (imageBuffer.length > MAX_IMAGE_SIZE) { + log( + `create-docx-file: Base64 image too large (${(imageBuffer.length / 1024 / 1024).toFixed(2)}MB), max ${MAX_IMAGE_SIZE / 1024 / 1024}MB` + ); + return null; + } + + const validation = validateImageMagicBytes(imageBuffer); + if (!validation.valid) { + log( + `create-docx-file: Base64 data is not a valid image (magic bytes check failed)` + ); + return null; + } + imageType = validation.type; + + log( + `create-docx-file: Processed base64 image, type: ${imageType}, size: ${imageBuffer.length} bytes` + ); + } else { + log(`create-docx-file: Invalid base64 image format, skipping`); + return null; + } + } else if (src.startsWith("http://") || src.startsWith("https://")) { + let parsedUrl; + try { + parsedUrl = new URL(src); + } catch { + log(`create-docx-file: Invalid URL: ${src}`); + return null; + } + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + log(`create-docx-file: Invalid URL protocol: ${parsedUrl.protocol}`); + return null; + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + IMAGE_FETCH_TIMEOUT_MS + ); + + try { + const response = await fetch(src, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + log( + `create-docx-file: Failed to fetch image from ${src}: ${response.status}` + ); + return null; + } + + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_IMAGE_SIZE) { + log( + `create-docx-file: Image too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(2)}MB), max ${MAX_IMAGE_SIZE / 1024 / 1024}MB` + ); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + imageBuffer = Buffer.from(arrayBuffer); + + if (imageBuffer.length > MAX_IMAGE_SIZE) { + log( + `create-docx-file: Downloaded image too large (${(imageBuffer.length / 1024 / 1024).toFixed(2)}MB), max ${MAX_IMAGE_SIZE / 1024 / 1024}MB` + ); + return null; + } + + const validation = validateImageMagicBytes(imageBuffer); + if (!validation.valid) { + log( + `create-docx-file: Fetched content is not a valid image (magic bytes check failed)` + ); + return null; + } + imageType = validation.type; + + log( + `create-docx-file: Fetched remote image from ${src}, type: ${imageType}, size: ${imageBuffer.length} bytes` + ); + } catch (fetchError) { + clearTimeout(timeoutId); + if (fetchError.name === "AbortError") { + log( + `create-docx-file: Image fetch timed out after ${IMAGE_FETCH_TIMEOUT_MS}ms: ${src}` + ); + } else { + log( + `create-docx-file: Error fetching image from ${src}: ${fetchError.message}` + ); + } + return null; + } + } else { + log(`create-docx-file: Unsupported image source: ${src}`); + return null; + } + + const { imageSize: getImageSize } = await import("image-size"); + let width = 400; + let height = 300; + + try { + const dimensions = getImageSize(imageBuffer); + if (dimensions.width && dimensions.height) { + width = dimensions.width; + height = dimensions.height; + + const maxWidth = 600; + const maxHeight = 400; + + if (width > maxWidth) { + const scale = maxWidth / width; + width = maxWidth; + height = Math.round(height * scale); + } + + if (height > maxHeight) { + const scale = maxHeight / height; + height = maxHeight; + width = Math.round(width * scale); + } + } + } catch (sizeError) { + log( + `create-docx-file: Could not determine image size: ${sizeError.message}, using defaults` + ); + } + + return { buffer: imageBuffer, type: imageType, width, height }; + } catch (error) { + log(`create-docx-file: Image processing error: ${error.message}`); + return null; + } +} + +/** + * Converts HTML to docx paragraph children (TextRun, etc.) + * @param {Element} element - DOM element to process + * @param {Object} docx - The docx library + * @param {Object} styles - Current text styles + * @param {Function} log - Logging function + * @returns {Promise} Array of docx elements + */ +async function processInlineElements(element, docx, styles = {}, log) { + const { TextRun, ExternalHyperlink, ImageRun } = docx; + const children = []; + + for (const node of element.childNodes) { + if (node.nodeType === 3) { + const text = node.textContent; + if (text && text.trim()) { + children.push( + new TextRun({ + text, + bold: styles.bold || false, + italics: styles.italics || false, + strike: styles.strike || false, + font: styles.font || "Calibri", + size: styles.size || 24, + color: styles.color, + }) + ); + } else if (text && text.includes(" ")) { + children.push(new TextRun({ text: " " })); + } + } else if (node.nodeType === 1) { + const tagName = node.tagName.toLowerCase(); + + switch (tagName) { + case "strong": + case "b": + children.push( + ...(await processInlineElements( + node, + docx, + { ...styles, bold: true }, + log + )) + ); + break; + case "em": + case "i": + children.push( + ...(await processInlineElements( + node, + docx, + { ...styles, italics: true }, + log + )) + ); + break; + case "del": + case "s": + children.push( + ...(await processInlineElements( + node, + docx, + { ...styles, strike: true }, + log + )) + ); + break; + case "code": + children.push( + new TextRun({ + text: node.textContent, + font: "Consolas", + size: 20, + shading: { fill: "E8E8E8" }, + }) + ); + break; + case "a": { + const href = node.getAttribute("href"); + const isValidHref = href && /^https?:\/\//i.test(href); + if (isValidHref) { + children.push( + new ExternalHyperlink({ + children: [ + new TextRun({ + text: node.textContent, + style: "Hyperlink", + color: "0563C1", + underline: { type: "single" }, + }), + ], + link: href, + }) + ); + } else { + children.push(new TextRun({ text: node.textContent })); + } + break; + } + case "img": { + const src = node.getAttribute("src"); + if (src) { + const imageData = await fetchImage(src, log); + if (imageData) { + children.push( + new ImageRun({ + data: imageData.buffer, + transformation: { + width: imageData.width, + height: imageData.height, + }, + type: imageData.type, + }) + ); + } + } + break; + } + case "br": + children.push(new TextRun({ break: 1 })); + break; + default: + children.push( + ...(await processInlineElements(node, docx, styles, log)) + ); + } + } + } + + return children; +} + +/** + * Converts an HTML table to docx Table. + * @param {Element} tableElement - The table DOM element + * @param {Object} docx - The docx library + * @param {Function} log - Logging function + * @param {Object} theme - Theme color configuration + * @returns {Promise} A docx Table + */ +async function processTable(tableElement, docx, log, theme = null) { + const { + Table, + TableRow, + TableCell, + Paragraph, + WidthType, + BorderStyle, + AlignmentType, + TextRun, + TableLayoutType, + } = docx; + + const colors = theme || DOCUMENT_STYLES.themes.neutral; + const rows = []; + const tableRows = tableElement.querySelectorAll("tr"); + + const firstRow = tableRows[0]; + const columnCount = firstRow ? firstRow.querySelectorAll("th, td").length : 1; + const columnWidthPercent = Math.floor(100 / columnCount); + + let dataRowIndex = 0; + for (const tr of tableRows) { + const cells = []; + const cellElements = tr.querySelectorAll("th, td"); + const isHeader = tr.querySelector("th") !== null; + + let shadingFill; + if (isHeader) { + shadingFill = colors.tableHeader; + } else if (dataRowIndex % 2 === 1) { + shadingFill = "F9F9F9"; + } + + for (const cell of cellElements) { + const cellChildren = await processInlineElements(cell, docx, {}, log); + cells.push( + new TableCell({ + children: [ + new Paragraph({ + children: + cellChildren.length > 0 + ? cellChildren + : [new TextRun({ text: "" })], + alignment: AlignmentType.LEFT, + }), + ], + width: { size: columnWidthPercent, type: WidthType.PERCENTAGE }, + shading: shadingFill ? { fill: shadingFill } : undefined, + }) + ); + } + + if (cells.length > 0) { + rows.push(new TableRow({ children: cells })); + } + + if (!isHeader) { + dataRowIndex++; + } + } + + return new Table({ + rows, + width: { size: 100, type: WidthType.PERCENTAGE }, + layout: TableLayoutType.FIXED, + borders: { + top: { style: BorderStyle.SINGLE, size: 1, color: colors.border }, + bottom: { style: BorderStyle.SINGLE, size: 1, color: colors.border }, + left: { style: BorderStyle.SINGLE, size: 1, color: colors.border }, + right: { style: BorderStyle.SINGLE, size: 1, color: colors.border }, + insideHorizontal: { + style: BorderStyle.SINGLE, + size: 1, + color: colors.border, + }, + insideVertical: { + style: BorderStyle.SINGLE, + size: 1, + color: colors.border, + }, + }, + }); +} + +/** + * Processes a list element (ul/ol) into docx paragraphs. + * @param {Element} listElement - The list DOM element + * @param {Object} docx - The docx library + * @param {boolean} isOrdered - Whether the list is ordered + * @param {number} level - Nesting level + * @param {Function} log - Logging function + * @returns {Promise} Array of docx Paragraphs + */ +async function processList(listElement, docx, isOrdered, level = 0, log) { + const { Paragraph } = docx; + const paragraphs = []; + const items = listElement.querySelectorAll(":scope > li"); + + for (const li of items) { + const inlineChildren = []; + const nestedLists = []; + + for (const child of li.childNodes) { + if (child.nodeType === 1) { + const tagName = child.tagName.toLowerCase(); + if (tagName === "ul" || tagName === "ol") { + nestedLists.push({ element: child, ordered: tagName === "ol" }); + } else { + const wrapper = li.ownerDocument.createElement("span"); + wrapper.appendChild(child.cloneNode(true)); + inlineChildren.push( + ...(await processInlineElements(wrapper, docx, {}, log)) + ); + } + } else if (child.nodeType === 3 && child.textContent.trim()) { + const wrapper = li.ownerDocument.createElement("span"); + wrapper.textContent = child.textContent; + inlineChildren.push( + ...(await processInlineElements(wrapper, docx, {}, log)) + ); + } + } + + if (inlineChildren.length > 0) { + paragraphs.push( + new Paragraph({ + children: inlineChildren, + bullet: isOrdered ? undefined : { level }, + numbering: isOrdered + ? { reference: "default-numbering", level } + : undefined, + }) + ); + } + + for (const nested of nestedLists) { + paragraphs.push( + ...(await processList( + nested.element, + docx, + nested.ordered, + level + 1, + log + )) + ); + } + } + + return paragraphs; +} + +/** + * Converts HTML content to an array of docx elements. + * @param {string} html - The HTML content + * @param {Object} libs - The loaded libraries + * @param {Function} log - Logging function + * @param {Object} theme - Theme color configuration + * @returns {Promise} Array of docx elements for sections + */ +async function htmlToDocxElements(html, libs, log, theme = null) { + const { JSDOM, docx } = libs; + const { Paragraph, TextRun, HeadingLevel, AlignmentType, ImageRun } = docx; + + const colors = theme || DOCUMENT_STYLES.themes.neutral; + const dom = new JSDOM(html); + const body = dom.window.document.body; + const elements = []; + + const headingSizes = { + h1: { level: HeadingLevel.HEADING_1, size: 48 }, + h2: { level: HeadingLevel.HEADING_2, size: 40 }, + h3: { level: HeadingLevel.HEADING_3, size: 32 }, + h4: { level: HeadingLevel.HEADING_4, size: 28 }, + h5: { level: HeadingLevel.HEADING_5, size: 24 }, + h6: { level: HeadingLevel.HEADING_6, size: 22 }, + }; + + for (const child of body.children) { + const tagName = child.tagName.toLowerCase(); + + try { + if (headingSizes[tagName]) { + const { level, size } = headingSizes[tagName]; + const inlineChildren = await processInlineElements( + child, + docx, + { size, bold: true, color: colors.heading }, + log + ); + elements.push( + new Paragraph({ + children: + inlineChildren.length > 0 + ? inlineChildren + : [ + new TextRun({ + text: child.textContent, + bold: true, + size, + color: colors.heading, + }), + ], + heading: level, + spacing: { before: 240, after: 120 }, + }) + ); + } else if (tagName === "p") { + const inlineChildren = await processInlineElements( + child, + docx, + {}, + log + ); + if (inlineChildren.length > 0) { + elements.push( + new Paragraph({ + children: inlineChildren, + spacing: { after: 200 }, + }) + ); + } + } else if (tagName === "ul") { + elements.push(...(await processList(child, docx, false, 0, log))); + } else if (tagName === "ol") { + elements.push(...(await processList(child, docx, true, 0, log))); + } else if (tagName === "table") { + elements.push(await processTable(child, docx, log, colors)); + elements.push(new Paragraph({ children: [] })); + } else if (tagName === "blockquote") { + const inlineChildren = await processInlineElements( + child, + docx, + { italics: true, color: "666666" }, + log + ); + elements.push( + new Paragraph({ + children: inlineChildren, + indent: { left: 720 }, + border: { + left: { style: "single", size: 24, color: colors.accent }, + }, + spacing: { before: 200, after: 200 }, + }) + ); + } else if (tagName === "pre") { + const codeElement = child.querySelector("code"); + const codeText = codeElement + ? codeElement.textContent + : child.textContent; + const lines = codeText.split("\n"); + + for (const line of lines) { + elements.push( + new Paragraph({ + children: [ + new TextRun({ + text: line || " ", + font: DOCUMENT_STYLES.fonts.mono, + size: 20, + }), + ], + shading: { fill: "F5F5F5" }, + spacing: { before: 0, after: 0 }, + }) + ); + } + elements.push(new Paragraph({ children: [], spacing: { after: 200 } })); + } else if (tagName === "hr") { + elements.push( + new Paragraph({ + children: [], + border: { + bottom: { style: "single", size: 6, color: colors.border }, + }, + spacing: { before: 200, after: 200 }, + }) + ); + } else if (tagName === "img") { + const src = child.getAttribute("src"); + if (src) { + const imageData = await fetchImage(src, log); + if (imageData) { + elements.push( + new Paragraph({ + children: [ + new ImageRun({ + data: imageData.buffer, + transformation: { + width: imageData.width, + height: imageData.height, + }, + type: imageData.type, + }), + ], + alignment: AlignmentType.CENTER, + spacing: { before: 200, after: 200 }, + }) + ); + } + } + } else { + const inlineChildren = await processInlineElements( + child, + docx, + {}, + log + ); + if (inlineChildren.length > 0) { + elements.push(new Paragraph({ children: inlineChildren })); + } + } + } catch (err) { + log( + `create-docx-file: Error processing element ${tagName}: ${err.message}` + ); + } + } + + return elements; +} + +/** + * Creates a cover/title page section with colored background and centered title block. + * @param {Object} docx - The docx library + * @param {Object} options - Cover page options + * @param {string} options.title - Document title + * @param {string} options.subtitle - Optional subtitle + * @param {string} options.author - Optional author name + * @param {string} options.date - Optional date string + * @param {Object} options.theme - Theme color configuration + * @param {Object} options.margins - Margin configuration + * @param {Buffer|null} options.logoBuffer - Logo buffer for footer + * @returns {Object} Section configuration for cover page + */ +function createCoverPageSection(docx, options) { + const { Paragraph, TextRun, AlignmentType, Footer, ImageRun, SectionType } = + docx; + + const { title, subtitle, author, date, theme, margins, logoBuffer } = options; + + const coverChildren = []; + + coverChildren.push( + new Paragraph({ + children: [], + spacing: { before: 2400 }, + }) + ); + + coverChildren.push( + new Paragraph({ + children: [ + new TextRun({ + text: title, + bold: true, + size: 72, + color: theme.heading, + font: DOCUMENT_STYLES.fonts.heading, + }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 400 }, + }) + ); + + if (subtitle) { + coverChildren.push( + new Paragraph({ + children: [ + new TextRun({ + text: subtitle, + size: 32, + color: theme.accent, + font: DOCUMENT_STYLES.fonts.body, + }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 800 }, + }) + ); + } + + coverChildren.push( + new Paragraph({ + children: [], + spacing: { before: 4800 }, + }) + ); + + if (author) { + coverChildren.push( + new Paragraph({ + children: [ + new TextRun({ + text: author, + size: 24, + color: "666666", + font: DOCUMENT_STYLES.fonts.body, + }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 200 }, + }) + ); + } + + if (date) { + coverChildren.push( + new Paragraph({ + children: [ + new TextRun({ + text: date, + size: 22, + color: "888888", + font: DOCUMENT_STYLES.fonts.body, + }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 200 }, + }) + ); + } + + const coverFooterChildren = []; + if (logoBuffer) { + coverFooterChildren.push( + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new ImageRun({ + data: logoBuffer, + transformation: { width: 100, height: 16 }, + type: "png", + }), + ], + }) + ); + } + + return { + properties: { + page: { + margin: margins, + }, + type: SectionType.NEXT_PAGE, + }, + children: coverChildren, + footers: { + default: new Footer({ children: coverFooterChildren }), + }, + }; +} + +/** + * Creates a running header for content pages (pages 2+). + * @param {Object} docx - The docx library + * @param {string} documentTitle - The document title to display + * @param {Object} theme - Theme color configuration + * @returns {Object} Header configuration + */ +function createRunningHeader(docx, documentTitle, theme) { + const { + Header, + Paragraph, + TextRun, + AlignmentType, + Table, + TableRow, + TableCell, + WidthType, + BorderStyle, + } = docx; + + const noBorders = DOCUMENT_STYLES.borders.none; + + return new Header({ + children: [ + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + ...noBorders, + insideHorizontal: { style: "none" }, + insideVertical: { style: "none" }, + bottom: { style: BorderStyle.SINGLE, size: 6, color: theme.border }, + }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + width: { size: 70, type: WidthType.PERCENTAGE }, + borders: noBorders, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: documentTitle, + size: 18, + color: theme.footerText, + font: DOCUMENT_STYLES.fonts.body, + }), + ], + }), + ], + }), + new TableCell({ + width: { size: 30, type: WidthType.PERCENTAGE }, + borders: noBorders, + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [], + }), + ], + }), + ], + }), + ], + }), + new Paragraph({ children: [], spacing: { after: 200 } }), + ], + }); +} + +/** + * Creates a running footer with Page X of Y and branding. + * @param {Object} docx - The docx library + * @param {Buffer|null} logoBuffer - Logo buffer or null + * @param {Object} theme - Theme color configuration + * @returns {Object} Footer configuration + */ +function createRunningFooter(docx, logoBuffer, theme) { + const { + Footer, + Paragraph, + TextRun, + AlignmentType, + Table, + TableRow, + TableCell, + WidthType, + ImageRun, + PageNumber, + } = docx; + + const noBorders = DOCUMENT_STYLES.borders.none; + + const pageNumberParagraph = new Paragraph({ + alignment: AlignmentType.LEFT, + children: [ + new TextRun({ + children: ["Page ", PageNumber.CURRENT, " of ", PageNumber.TOTAL_PAGES], + size: 18, + color: theme.footerText, + font: DOCUMENT_STYLES.fonts.body, + }), + ], + }); + + const brandingCell = logoBuffer + ? new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new ImageRun({ + data: logoBuffer, + transformation: { width: 80, height: 13 }, + type: "png", + }), + ], + }) + : new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: "Generated by AnythingLLM", + size: 16, + color: theme.footerText, + font: DOCUMENT_STYLES.fonts.body, + }), + ], + }); + + return new Footer({ + children: [ + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + ...noBorders, + insideHorizontal: { style: "none" }, + insideVertical: { style: "none" }, + }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + width: { size: 50, type: WidthType.PERCENTAGE }, + borders: noBorders, + children: [pageNumberParagraph], + }), + new TableCell({ + width: { size: 50, type: WidthType.PERCENTAGE }, + borders: noBorders, + children: [brandingCell], + }), + ], + }), + ], + }), + ], + }); +} + +/** + * Default numbering configuration for ordered lists. + */ +const DEFAULT_NUMBERING_CONFIG = { + config: [ + { + reference: "default-numbering", + levels: [ + { + level: 0, + format: "decimal", + text: "%1.", + alignment: "start", + style: { paragraph: { indent: { left: 720, hanging: 360 } } }, + }, + { + level: 1, + format: "lowerLetter", + text: "%2.", + alignment: "start", + style: { paragraph: { indent: { left: 1440, hanging: 360 } } }, + }, + { + level: 2, + format: "lowerRoman", + text: "%3.", + alignment: "start", + style: { paragraph: { indent: { left: 2160, hanging: 360 } } }, + }, + ], + }, + ], +}; + +module.exports = { + DOCUMENT_STYLES, + getTheme, + getMargins, + loadLibraries, + htmlToDocxElements, + createCoverPageSection, + createRunningHeader, + createRunningFooter, + DEFAULT_NUMBERING_CONFIG, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/index.js b/server/utils/agents/aibitat/plugins/create-files/index.js new file mode 100644 index 0000000..5833bab --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/index.js @@ -0,0 +1,23 @@ +const { CreatePptxPresentation } = require("./pptx/create-presentation.js"); +const { CreateTextFile } = require("./text/create-text-file.js"); +const { CreatePdfFile } = require("./pdf/create-pdf-file.js"); +const { CreateExcelFile } = require("./xlsx/create-excel-file.js"); +const { CreateDocxFile } = require("./docx/create-docx-file.js"); + +const createFilesAgent = { + name: "create-files-agent", + startupConfig: { + params: {}, + }, + plugin: [ + CreatePptxPresentation, + CreateTextFile, + CreatePdfFile, + CreateExcelFile, + CreateDocxFile, + ], +}; + +module.exports = { + createFilesAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/lib.js b/server/utils/agents/aibitat/plugins/create-files/lib.js new file mode 100644 index 0000000..97359eb --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/lib.js @@ -0,0 +1,333 @@ +const path = require("path"); +const fs = require("fs/promises"); +const fsSync = require("fs"); +const { v4: uuidv4 } = require("uuid"); + +/** + * Manages file creation operations for binary document formats. + * Handles both browser download and filesystem write modes. + * All generated files are saved to storage/generated-files directory. + */ +class CreateFilesManager { + #outputDirectory = null; + #isInitialized = false; + + /** + * Gets the output directory for generated files. + * @returns {string} The output directory path (storage/generated-files) + */ + #getOutputDirectory() { + const storageRoot = + process.env.STORAGE_DIR || + path.resolve(__dirname, "../../../../../storage"); + return path.join(storageRoot, "generated-files"); + } + + /** + * Initializes the create-files manager and ensures output directory exists. + * @returns {Promise} The output directory path + */ + async #initialize() { + this.#outputDirectory = this.#getOutputDirectory(); + + try { + await fs.mkdir(this.#outputDirectory, { recursive: true }); + } catch (error) { + console.error( + `Warning: Could not create output directory ${this.#outputDirectory}: ${error.message}` + ); + } + + this.#isInitialized = true; + return this.#outputDirectory; + } + + /** + * Ensures the create-files manager is initialized before use. + * @returns {Promise} + */ + async ensureInitialized() { + if (!this.#isInitialized) await this.#initialize(); + } + + /** + * Checks if file creation tools are available. + * @returns {boolean} True if tools are available + */ + isToolAvailable() { + if (process.env.NODE_ENV === "development") return true; + return process.env.ANYTHING_LLM_RUNTIME === "docker"; + } + + /** + * Gets the output directory path. + * @returns {Promise} The output directory path + */ + async getOutputDirectory() { + await this.ensureInitialized(); + return this.#outputDirectory; + } + + /** + * Writes binary content (Buffer) to a file. + * @param {string} filePath - Validated absolute path to write to + * @param {Buffer} buffer - Binary content to write + * @returns {Promise} + */ + async writeBinaryFile(filePath, buffer) { + const parentDir = path.dirname(filePath); + const fileSizeBytes = buffer.length; + const fileSizeKB = (fileSizeBytes / 1024).toFixed(2); + const fileSizeMB = (fileSizeBytes / (1024 * 1024)).toFixed(2); + + console.log( + `[CreateFilesManager] writeBinaryFile starting - path: ${filePath}, size: ${fileSizeKB}KB (${fileSizeMB}MB)` + ); + + await fs.mkdir(parentDir, { recursive: true }); + await fs.writeFile(filePath, buffer); + + console.log( + `[CreateFilesManager] writeBinaryFile completed - file saved to: ${filePath}` + ); + } + + /** + * Gets the MIME type for a file extension. + * @param {string} extension - File extension (with or without dot) + * @returns {string} MIME type + */ + getMimeType(extension) { + const ext = extension.startsWith(".") ? extension : `.${extension}`; + const mimeTypes = { + ".pptx": + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".xlsx": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".docx": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".json": "application/json", + ".html": "text/html", + ".xml": "application/xml", + ".zip": "application/zip", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".webm": "video/webm", + }; + return mimeTypes[ext.toLowerCase()] || "application/octet-stream"; + } + + /** + * Checks if a file exists. + * @param {string} filePath - Path to check + * @returns {Promise} True if file exists + */ + async fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * Reads a file as a Buffer. + * @param {string} filePath - Path to the file + * @returns {Promise} File content as Buffer + */ + async readBinaryFile(filePath) { + return await fs.readFile(filePath); + } + + /** + * Registers an output to be persisted in the chat history. + * This allows files and other outputs to be re-rendered when viewing historical messages. + * @param {object} aibitat - The aibitat instance to register the output on + * @param {string} type - The type of output (e.g., "PptxFileDownload") + * @param {object} payload - The output payload data + */ + registerOutput(aibitat, type, payload) { + if (!aibitat) { + console.warn( + "[CreateFilesManager] Cannot register output - aibitat instance not provided" + ); + return; + } + + if (!aibitat._pendingOutputs) { + aibitat._pendingOutputs = []; + } + + aibitat._pendingOutputs.push({ type, payload }); + console.log( + `[CreateFilesManager] Registered output: type=${type}, total pending=${aibitat._pendingOutputs.length}` + ); + } + + /** + * Generates a standardized filename for generated files. + * Format: {fileType}-{fileUUID}.{extension} + * @param {string} fileType - Type identifier (e.g., 'pptx', 'xlsx') + * @param {string} extension - File extension (without dot) + * @returns {string} The generated filename + */ + generateFilename(fileType, extension) { + const fileUUID = uuidv4(); + return `${fileType}-${fileUUID}.${extension}`; + } + + /** + * Parses a generated filename to extract its components. + * @param {string} filename - The filename to parse + * @returns {{fileType: string, fileUUID: string, extension: string} | null} + */ + parseFilename(filename) { + const match = filename.match(/^([a-z]+)-([a-f0-9-]{36})\.(\w+)$/i); + if (!match) return null; + return { + fileType: match[1], + fileUUID: match[2], + extension: match[3], + }; + } + + /** + * Saves a generated file to storage and returns metadata for WebSocket/DB storage. + * This is the primary method for persisting agent-generated files. + * @param {object} params + * @param {string} params.fileType - Type identifier (e.g., 'pptx', 'xlsx') + * @param {string} params.extension - File extension (without dot) + * @param {Buffer} params.buffer - The file content as a Buffer + * @param {string} params.displayFilename - The user-friendly filename for display + * @returns {Promise<{filename: string, displayFilename: string, fileSize: number, storagePath: string}>} + */ + async saveGeneratedFile({ fileType, extension, buffer, displayFilename }) { + await this.ensureInitialized(); + + const filename = this.generateFilename(fileType, extension); + const storagePath = path.join(this.#outputDirectory, filename); + + await this.writeBinaryFile(storagePath, buffer); + + console.log( + `[CreateFilesManager] saveGeneratedFile - saved ${filename} (${(buffer.length / 1024).toFixed(2)}KB)` + ); + + return { + filename, + displayFilename, + fileSize: buffer.length, + storagePath, + }; + } + + /** + * Retrieves a generated file by its storage filename. + * @param {string} filename - The storage filename (must match {fileType}-{uuid}.{ext} format) + * @returns {Promise<{buffer: Buffer, storagePath: string} | null>} + */ + async getGeneratedFile(filename) { + await this.ensureInitialized(); + + // Defense-in-depth: validate filename format to prevent path traversal + if (!this.parseFilename(filename)) { + console.warn( + `[CreateFilesManager] getGeneratedFile - rejected invalid filename format: ${filename}` + ); + return null; + } + + const storagePath = path.join(this.#outputDirectory, filename); + const exists = await this.fileExists(storagePath); + if (!exists) return null; + + const buffer = await this.readBinaryFile(storagePath); + return { buffer, storagePath }; + } + + /** + * Sanitizes a filename for use in Content-Disposition header to prevent header injection. + * Removes/replaces characters that could be used for header manipulation. + * @param {string} filename - The filename to sanitize + * @returns {string} Sanitized filename safe for Content-Disposition header + */ + sanitizeFilenameForHeader(filename) { + if (!filename || typeof filename !== "string") return "download"; + return filename + .replace(/[\r\n"\\]/g, "_") + .replace(/[^\x20-\x7E]/g, "_") + .substring(0, 255); + } + + /** + * Removes characters that are illegal in XML 1.0 from a string, or - when + * given an array/object - recursively from every string it contains. + * + * OOXML documents (.docx/.xlsx/.pptx) embed their text directly into internal + * XML parts (e.g. word/document.xml). XML 1.0 §2.2 forbids every C0 control + * character except tab (U+0009), line feed (U+000A) and carriage return + * (U+000D). When one of the forbidden characters reaches the content the file + * is still a valid ZIP, but Office refuses to open it ("Word experienced an + * error trying to open the file."). The most common offender is a form feed + * (U+000C): an LLM that emits LaTeX such as `\frac` produces a `\f` JSON + * escape that decodes to U+000C before it ever reaches the generator. + * + * Stripping these characters yields a readable document instead of a corrupt + * one. Non-string scalars are returned untouched. + * @param {*} value - A string, or an array/object that may contain strings. + * @returns {*} The value with all invalid XML characters removed. + */ + stripInvalidXmlChars(value) { + if (typeof value === "string") + // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); + if (Array.isArray(value)) + return value.map((item) => this.stripInvalidXmlChars(item)); + if (value && typeof value === "object") { + const cleaned = {}; + for (const [key, val] of Object.entries(value)) + cleaned[key] = this.stripInvalidXmlChars(val); + return cleaned; + } + return value; + } + + /** + * Gets the AnythingLLM logo for branding. + * @param {Object} options + * @param {boolean} [options.forDarkBackground=false] - True to get light logo (for dark backgrounds), false for dark logo (for light backgrounds) + * @param {"buffer"|"dataUri"} [options.format="buffer"] - Return format: "buffer" for raw Buffer, "dataUri" for base64 data URI + * @returns {Buffer|string|null} Logo as Buffer, data URI string, or null if file not found + */ + getLogo({ forDarkBackground = false, format = "buffer" } = {}) { + // On Docker this is pre-packed images local to this lib. + // Does not honor Whitelabeling changes/preferences right now. + const assetsPath = path.join(__dirname, "assets"); + const filename = forDarkBackground + ? "anything-llm.png" + : "anything-llm-invert.png"; + try { + if (format === "dataUri") { + const base64 = fsSync.readFileSync( + path.join(assetsPath, filename), + "base64" + ); + return `image/png;base64,${base64}`; + } + return fsSync.readFileSync(path.join(assetsPath, filename)); + } catch { + return null; + } + } +} + +module.exports = new CreateFilesManager(); diff --git a/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js b/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js new file mode 100644 index 0000000..664f432 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js @@ -0,0 +1,138 @@ +const createFilesLib = require("../lib.js"); +const { applyBranding } = require("./utils.js"); + +module.exports.CreatePdfFile = { + name: "create-pdf-file", + plugin: function () { + return { + name: "create-pdf-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a PDF document from markdown or plain text content. " + + "The content will be styled and converted to a professional PDF document. " + + "Supports markdown formatting including headers, lists, code blocks, tables, and more.", + examples: [ + { + prompt: "Create a PDF report about quarterly sales", + call: JSON.stringify({ + filename: "quarterly-sales-report.pdf", + content: + "# Quarterly Sales Report\n\n## Q1 2024 Summary\n\n### Key Metrics\n- Total Revenue: $1.2M\n- Growth: 15% YoY\n- New Customers: 234\n\n### Top Products\n1. Product A - $400K\n2. Product B - $350K\n3. Product C - $250K\n\n## Recommendations\n\nBased on the analysis, we recommend focusing on...", + }), + }, + { + prompt: "Create a PDF document with meeting minutes", + call: JSON.stringify({ + filename: "meeting-minutes.pdf", + content: + "# Team Meeting Minutes\n\n**Date:** January 15, 2024\n**Attendees:** John, Sarah, Mike, Lisa\n\n## Agenda Items\n\n### 1. Project Status Update\nThe project is on track for Q2 delivery. Key milestones:\n- [ ] Phase 1 complete\n- [x] Phase 2 in progress\n- [ ] Phase 3 pending\n\n### 2. Budget Review\n| Category | Allocated | Spent |\n|----------|-----------|-------|\n| Development | $50,000 | $35,000 |\n| Marketing | $20,000 | $12,000 |\n\n### Action Items\n- John: Complete technical review by Friday\n- Sarah: Schedule stakeholder meeting", + }), + }, + { + prompt: "Create a PDF with code documentation", + call: JSON.stringify({ + filename: "api-documentation.pdf", + content: + "# API Documentation\n\n## Authentication\n\nAll API requests require a Bearer token:\n\n```javascript\nfetch('/api/data', {\n headers: {\n 'Authorization': 'Bearer YOUR_TOKEN'\n }\n});\n```\n\n## Endpoints\n\n### GET /api/users\n\nReturns a list of all users.\n\n**Response:**\n```json\n{\n \"users\": [...],\n \"total\": 100\n}\n```", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + filename: { + type: "string", + description: + "The filename for the PDF document. The .pdf extension will be added automatically if not provided.", + }, + content: { + type: "string", + description: + "The markdown or plain text content to convert to PDF. Supports full markdown syntax including headers (#, ##, ###), bold (**text**), italic (*text*), lists, code blocks, tables, and more.", + }, + }, + required: ["filename", "content"], + additionalProperties: false, + }, + handler: async function ({ + filename = "document.pdf", + content = "", + }) { + try { + this.super.handlerProps.log(`Using the create-pdf-file tool.`); + + const hasExtension = /\.pdf$/i.test(filename); + if (!hasExtension) filename = `${filename}.pdf`; + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { filename }, + description: `Create PDF document "${filename}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating PDF document "${filename}"` + ); + + const { markdownToPdf } = await import("@mintplex-labs/mdpdf"); + const { PDFDocument, rgb, StandardFonts } = await import( + "pdf-lib" + ); + + const rawBuffer = await markdownToPdf(content); + const pdfDoc = await PDFDocument.load(rawBuffer); + await applyBranding(pdfDoc, { rgb, StandardFonts }); + + const buffer = await pdfDoc.save(); + const bufferSizeKB = (buffer.length / 1024).toFixed(2); + const displayFilename = filename.split("/").pop(); + + const savedFile = await createFilesLib.saveGeneratedFile({ + fileType: "pdf", + extension: "pdf", + buffer, + displayFilename, + }); + + this.super.socket.send("fileDownloadCard", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + createFilesLib.registerOutput(this.super, "PdfFileDownload", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + this.super.introspect( + `${this.caller}: Successfully created PDF document "${displayFilename}"` + ); + + return `Successfully created PDF document "${displayFilename}" (${bufferSizeKB}KB).`; + } catch (e) { + this.super.handlerProps.log( + `create-pdf-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating PDF document: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/pdf/utils.js b/server/utils/agents/aibitat/plugins/create-files/pdf/utils.js new file mode 100644 index 0000000..d434d4a --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pdf/utils.js @@ -0,0 +1,70 @@ +const createFilesLib = require("../lib.js"); + +/** + * Applies AnythingLLM branding to a PDF document. + * Adds a logo watermark or fallback text to the bottom-right of each page. + * @param {PDFDocument} pdfDoc - The pdf-lib PDFDocument instance + * @param {Object} pdfLib - The pdf-lib module exports (rgb, StandardFonts) + * @returns {Promise} + */ +async function applyBranding(pdfDoc, { rgb, StandardFonts }) { + const font = await pdfDoc.embedFont(StandardFonts.HelveticaOblique); + const pages = pdfDoc.getPages(); + + const logoPng = createFilesLib.getLogo({ + forDarkBackground: false, + format: "buffer", + }); + const logoImage = logoPng ? await pdfDoc.embedPng(logoPng) : null; + + const logoWidth = 80; + const logoHeight = logoImage + ? (logoImage.height / logoImage.width) * logoWidth + : 0; + + const marginRight = 20; + const marginBottom = 20; + + for (const page of pages) { + const { width } = page.getSize(); + + if (logoImage) { + const createdWithText = "created with"; + const fontSize = 7; + const textWidth = font.widthOfTextAtSize(createdWithText, fontSize); + const logoX = width - marginRight - logoWidth; + + page.drawText(createdWithText, { + x: logoX + (logoWidth - textWidth) / 2, + y: marginBottom + logoHeight + 2, + size: fontSize, + font, + color: rgb(0.6, 0.6, 0.6), + opacity: 0.6, + }); + + page.drawImage(logoImage, { + x: logoX, + y: marginBottom, + width: logoWidth, + height: logoHeight, + opacity: 0.6, + }); + } else { + const fallbackText = "Created with AnythingLLM"; + const fontSize = 9; + const textWidth = font.widthOfTextAtSize(fallbackText, fontSize); + page.drawText(fallbackText, { + x: width - marginRight - textWidth, + y: marginBottom, + size: fontSize, + font, + color: rgb(0.6, 0.6, 0.6), + }); + } + } +} + +module.exports = { + applyBranding, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/pptx/create-presentation.js b/server/utils/agents/aibitat/plugins/create-files/pptx/create-presentation.js new file mode 100644 index 0000000..fe2d924 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pptx/create-presentation.js @@ -0,0 +1,353 @@ +const createFilesLib = require("../lib.js"); +const { getTheme, getAvailableThemes } = require("./themes.js"); +const { + renderTitleSlide, + renderSectionSlide, + renderContentSlide, + renderBlankSlide, +} = require("./utils.js"); +const { runSectionAgent } = require("./section-agent.js"); + +/** + * Extracts recent conversation history from the parent AIbitat's chat log + * to provide context to each section sub-agent. + * @param {Array} chats - The parent AIbitat's _chats array + * @param {number} [maxMessages=10] - Maximum messages to include + * @returns {string} Formatted conversation context + */ +function extractConversationContext(chats, maxMessages = 10) { + if (!Array.isArray(chats) || chats.length === 0) return ""; + + const recent = chats + .filter((c) => c.state === "success" && c.content) + .slice(-maxMessages); + + if (recent.length === 0) return ""; + + return recent + .map((c) => { + const content = + typeof c.content === "string" ? c.content.substring(0, 500) : ""; + return `${c.from}: ${content}`; + }) + .join("\n"); +} + +module.exports.CreatePptxPresentation = { + name: "create-pptx-presentation", + plugin: function () { + return { + name: "create-pptx-presentation", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a professional PowerPoint presentation (PPTX). " + + "Provide a title, theme, and section outlines with key points. " + + "Each section is independently researched and built by a focused sub-agent " + + "that can use web search and web scraping to gather data.", + examples: [ + { + prompt: "Create a presentation about project updates", + call: JSON.stringify({ + filename: "project-updates.pptx", + title: "Q1 Project Updates", + theme: "corporate", + sections: [ + { + title: "Overview", + keyPoints: [ + "Project on track for Q1 delivery", + "Team expanded by 2 new members", + "Budget within expectations", + ], + }, + { + title: "Key Achievements", + keyPoints: [ + "Launched new feature X", + "Reduced bug count by 40%", + "Improved performance by 25%", + ], + instructions: + "Include specific metrics and quarter-over-quarter comparisons", + }, + ], + }), + }, + { + prompt: "Create a dark themed presentation about AI trends", + call: JSON.stringify({ + filename: "ai-trends.pptx", + title: "AI Trends 2025", + theme: "dark", + sections: [ + { + title: "Large Language Models", + keyPoints: [ + "Model scaling trends", + "Open vs closed source landscape", + ], + instructions: + "Research the latest developments and include recent data", + }, + { + title: "AI in Enterprise", + keyPoints: ["Adoption rates", "Top use cases", "ROI data"], + }, + ], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + filename: { + type: "string", + description: + "The filename for the presentation (should end with .pptx).", + }, + title: { + type: "string", + description: + "The title of the presentation (shown on title slide).", + }, + author: { + type: "string", + description: + "Optional author name for the presentation metadata.", + }, + theme: { + type: "string", + enum: getAvailableThemes(), + description: + "Color theme for the presentation. Options: " + + getAvailableThemes().join(", "), + }, + sections: { + type: "array", + description: + "Section outlines for the presentation. Each section is independently researched and built by a focused sub-agent.", + items: { + type: "object", + properties: { + title: { + type: "string", + description: "The section title.", + }, + keyPoints: { + type: "array", + items: { type: "string" }, + description: + "Key points this section should cover. The sub-agent will expand these into detailed slides.", + }, + instructions: { + type: "string", + description: + "Optional guidance for the section builder (e.g. 'research recent statistics', 'compare with competitors', 'include a data table').", + }, + }, + required: ["title"], + }, + }, + }, + required: ["filename", "title", "sections"], + additionalProperties: false, + }, + + handler: async function ({ + filename = "presentation.pptx", + title = "Untitled Presentation", + author = "", + theme: themeName = "default", + sections = [], + }) { + try { + this.super.handlerProps.log( + `Using the create-pptx-presentation tool.` + ); + + // Strip XML 1.0 illegal control characters so PowerPoint can open + // the generated deck (slide content is sanitized after assembly). + title = createFilesLib.stripInvalidXmlChars(title); + author = createFilesLib.stripInvalidXmlChars(author); + + if (!filename.toLowerCase().endsWith(".pptx")) + filename += ".pptx"; + + const theme = getTheme(themeName); + const totalSections = sections.length; + + this.super.introspect( + `${this.caller}: Planning presentation "${title}" — ${totalSections} section${totalSections !== 1 ? "s" : ""}, ${theme.name} theme` + ); + + // Ask for approval BEFORE kicking off the expensive sub-agent work + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + filename, + title, + sectionCount: totalSections, + sectionTitles: sections.map((s) => s.title), + }, + description: `Create PowerPoint presentation "${title}" with ${totalSections} sections`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const conversationContext = extractConversationContext( + this.super._chats + ); + + // Run a focused sub-agent for each section sequentially. + // Sequential execution is intentional — local models typically serve + // one request at a time, and it keeps introspection events ordered. + const allSlides = []; + const allCitations = []; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + this.super.introspect( + `${this.caller}: [${i + 1}/${totalSections}] Building section "${section.title}"…` + ); + + const sectionResult = await runSectionAgent({ + parentAibitat: this.super, + section, + presentationTitle: title, + conversationContext, + sectionPrefix: `${i + 1}/${totalSections}`, + }); + + const slideCount = sectionResult.slides?.length || 0; + allSlides.push(...(sectionResult.slides || [])); + if (sectionResult.citations?.length > 0) + allCitations.push(...sectionResult.citations); + + this.super.introspect( + `${this.caller}: [${i + 1}/${totalSections}] Section "${section.title}" complete — ${slideCount} slide${slideCount !== 1 ? "s" : ""}` + ); + } + + // Roll up all citations from sub-agents to the parent so they + // appear as sources on the final assistant message. + if (allCitations.length > 0) this.super.addCitation(allCitations); + + // Assemble the final PPTX from all section outputs + this.super.introspect( + `${this.caller}: Assembling final deck — ${allSlides.length} slides total` + ); + + const PptxGenJS = require("pptxgenjs"); + const pptx = new PptxGenJS(); + + pptx.title = title; + if (author) pptx.author = author; + pptx.company = "AnythingLLM"; + + const totalSlideCount = allSlides.length; + + // Sub-agent output can carry XML 1.0 illegal control characters + // (e.g. a form feed from a LaTeX `\frac`); strip them recursively + // from every slide so PowerPoint can open the generated deck. + const cleanSlides = + createFilesLib.stripInvalidXmlChars(allSlides); + + // Title slide + const titleSlide = pptx.addSlide(); + renderTitleSlide(titleSlide, pptx, { title, author }, theme); + + // Render every slide produced by the section agents + cleanSlides.forEach((slideData, index) => { + const slide = pptx.addSlide(); + const slideNumber = index + 1; + const layout = slideData.layout || "content"; + + switch (layout) { + case "title": + case "section": + renderSectionSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlideCount + ); + break; + case "blank": + renderBlankSlide( + slide, + pptx, + theme, + slideNumber, + totalSlideCount + ); + break; + default: + renderContentSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlideCount + ); + break; + } + }); + + const buffer = await pptx.write({ outputType: "nodebuffer" }); + const bufferSizeKB = (buffer.length / 1024).toFixed(2); + const bufferSizeMB = (buffer.length / (1024 * 1024)).toFixed(2); + this.super.handlerProps.log( + `create-pptx-presentation: Generated buffer - size: ${bufferSizeKB}KB (${bufferSizeMB}MB), slides: ${totalSlideCount}, theme: ${theme.name}` + ); + + const displayFilename = filename.split("/").pop(); + + const savedFile = await createFilesLib.saveGeneratedFile({ + fileType: "pptx", + extension: "pptx", + buffer, + displayFilename, + }); + + this.super.socket.send("fileDownloadCard", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + createFilesLib.registerOutput(this.super, "PptxFileDownload", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + this.super.introspect( + `${this.caller}: Successfully created presentation "${title}"` + ); + + return `Successfully created presentation "${title}" with ${totalSlideCount} slides across ${totalSections} sections using the ${theme.name} theme.`; + } catch (e) { + this.super.handlerProps.log( + `create-pptx-presentation error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating presentation: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/pptx/section-agent.js b/server/utils/agents/aibitat/plugins/create-files/pptx/section-agent.js new file mode 100644 index 0000000..3ea76ee --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pptx/section-agent.js @@ -0,0 +1,257 @@ +const AIbitat = require("../../../index.js"); + +const SECTION_BUILDER_PROMPT = `You are a focused presentation section builder. Your ONLY task is to create detailed slides for ONE section of a PowerPoint presentation. + +You have access to web search and web scraping tools, but only use them when the topic genuinely requires up-to-date information you don't already know (e.g., current statistics, recent events, specific company data). For general knowledge topics, create slides directly from your existing knowledge. + +RULES: +- Create 2-5 slides for this section (no more) +- Each content slide should have 3-6 concise bullet points +- Be specific and data-driven when possible +- Include speaker notes with key talking points +- Do NOT add a title slide - only section content + +When finished, you MUST call the submit-section-slides tool with your slides. Do not respond with raw JSON - always use the tool. + +Available slide layouts: +- "section": Divider slide with title + optional subtitle +- "content": Bullet points with title + content array + optional notes + - May include "table": { "headers": ["Col1", "Col2"], "rows": [["a", "b"]] } +- "blank": Empty slide`; + +/** + * Spawns a focused child AIbitat agent to build slides for a single presentation section. + * The child reuses the parent's provider/model/socket so introspection events (tool calls, + * research progress) flow to the frontend in real-time. + * + * @param {Object} options + * @param {AIbitat} options.parentAibitat - The parent AIbitat instance (provides provider, socket, introspect) + * @param {Object} options.section - Section definition { title, keyPoints?, instructions? } + * @param {string} options.presentationTitle - Overall presentation title for context + * @param {string} [options.conversationContext] - Recent conversation history for context + * @param {string} [options.sectionPrefix] - Progress indicator like "1/5" for UI display + * @returns {Promise<{slides: Object[], citations: Object[]}>} Parsed section slides and accumulated citations + */ +async function runSectionAgent({ + parentAibitat, + section, + presentationTitle, + conversationContext = "", + sectionPrefix = "", +}) { + const log = parentAibitat.handlerProps?.log || console.log; + + const childAibitat = new AIbitat({ + provider: parentAibitat.defaultProvider.provider, + model: parentAibitat.defaultProvider.model, + chats: [], + handlerProps: parentAibitat.handlerProps, + maxToolCalls: 5, + }); + + // Share introspect so tool activity (web-search status, etc.) streams to the frontend + childAibitat.introspect = parentAibitat.introspect; + + // Filtered socket: pass through introspection but suppress reportStreamEvent + // so sub-agent chatter doesn't render in the UI as a chat message. + childAibitat.socket = { + send: (type, content) => { + if (type === "reportStreamEvent") return; + parentAibitat.socket?.send(type, content); + }, + }; + + // Only load the research tools this sub-agent needs + const { webBrowsing } = require("../../web-browsing.js"); + const { webScraping } = require("../../web-scraping.js"); + childAibitat.use(webBrowsing.plugin()); + childAibitat.use(webScraping.plugin()); + + // Internal tool for structured slide submission - not exposed as a public plugin + childAibitat.function({ + super: childAibitat, + name: "submit-section-slides", + description: + "Submit the completed slides for this presentation section. Call this tool when you have finished creating all slides.", + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + slides: { + type: "array", + description: "Array of slide objects for this section", + items: { + type: "object", + properties: { + layout: { + type: "string", + enum: ["section", "content", "blank"], + description: "The slide layout type", + }, + title: { + type: "string", + description: "The slide title", + }, + subtitle: { + type: "string", + description: "Optional subtitle (for section layout)", + }, + content: { + type: "array", + items: { type: "string" }, + description: "Bullet points (for content layout)", + }, + notes: { + type: "string", + description: "Speaker notes for this slide", + }, + table: { + type: "object", + description: "Optional table data", + properties: { + headers: { + type: "array", + items: { type: "string" }, + }, + rows: { + type: "array", + items: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + required: ["layout", "title"], + }, + }, + }, + required: ["slides"], + additionalProperties: false, + }, + handler: function ({ slides }) { + this.super._submittedSlides = slides; + return "Slides submitted successfully. Section complete."; + }, + }); + + const functions = Array.from(childAibitat.functions.values()); + const messages = [ + { role: "system", content: SECTION_BUILDER_PROMPT }, + { + role: "user", + content: buildSectionPrompt({ + section, + presentationTitle, + conversationContext, + }), + }, + ]; + + const provider = childAibitat.getProviderForConfig( + childAibitat.defaultProvider + ); + provider.attachHandlerProps(childAibitat.handlerProps); + + log( + `[SectionAgent] Running sub-agent for section: "${section.title}" with ${functions.length} tools` + ); + + let agentName = `@section-builder`; + if (sectionPrefix) agentName = `[${sectionPrefix}] ${agentName}`; + try { + if (provider.supportsAgentStreaming) { + await childAibitat.handleAsyncExecution( + provider, + messages, + functions, + agentName + ); + } else { + await childAibitat.handleExecution( + provider, + messages, + functions, + agentName + ); + } + } catch (error) { + log(`[SectionAgent] Error in section "${section.title}": ${error.message}`); + return { ...buildFallbackSlides(section), citations: [] }; + } + + // Collect any citations the child accumulated (from web-search, web-scrape, etc.) + const citations = childAibitat._pendingCitations || []; + + // Retrieve slides from the tool call (structured data, no parsing needed) + const slides = childAibitat._submittedSlides; + if (!Array.isArray(slides) || slides.length === 0) { + log( + `[SectionAgent] No slides submitted for "${section.title}", using fallback` + ); + return { ...buildFallbackSlides(section), citations }; + } + + log( + `[SectionAgent] Section "${section.title}" produced ${slides.length} slides, ${citations.length} citations` + ); + return { slides, citations }; +} + +function buildSectionPrompt({ + section, + presentationTitle, + conversationContext, +}) { + const parts = [ + `Build slides for this section of the presentation "${presentationTitle}":`, + `\nSection Title: ${section.title}`, + ]; + + if (section.keyPoints?.length > 0) { + parts.push( + `\nKey Points to Cover:\n${section.keyPoints.map((p) => `- ${p}`).join("\n")}` + ); + } + + if (section.instructions) { + parts.push(`\nSpecial Instructions: ${section.instructions}`); + } + + if (conversationContext) { + parts.push(`\nContext from the conversation:\n${conversationContext}`); + } + + parts.push( + `\nCreate 2-5 detailed slides and submit them using the submit-section-slides tool. Only use web search/scraping if you genuinely lack the information needed.` + ); + + return parts.join("\n"); +} + +/** + * Generates basic slides from the section definition when the sub-agent fails. + */ +function buildFallbackSlides(section) { + const slides = [ + { + layout: "section", + title: section.title, + subtitle: section.subtitle || "", + }, + ]; + + if (section.keyPoints?.length > 0) { + slides.push({ + layout: "content", + title: section.title, + content: section.keyPoints, + notes: `Key points for ${section.title}`, + }); + } + + return { slides }; +} + +module.exports = { runSectionAgent }; diff --git a/server/utils/agents/aibitat/plugins/create-files/pptx/test-themes.js b/server/utils/agents/aibitat/plugins/create-files/pptx/test-themes.js new file mode 100644 index 0000000..e5e01ab --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pptx/test-themes.js @@ -0,0 +1,143 @@ +/** + * Generate a preview presentation for every theme using the same rendering + * pipeline as the production tool. Run from repo root: + * + * node server/utils/agents/aibitat/plugins/create-files/pptx/test-themes.js + * + * Output → storage/generated-files/theme-previews/ + */ + +const path = require("path"); +const fs = require("fs"); +const PptxGenJS = require("pptxgenjs"); +const createFilesLib = require("../lib.js"); +const { getTheme, getAvailableThemes } = require("./themes.js"); +const { + renderTitleSlide, + renderSectionSlide, + renderContentSlide, + renderBlankSlide, +} = require("./utils.js"); + +const SAMPLE_SLIDES = [ + { + title: "Executive Summary", + content: [ + "Revenue grew 23% year-over-year to $4.2B", + "Operating margin expanded 180bps to 28.4%", + "Customer retention rate improved to 94.7%", + "Three strategic acquisitions completed in Q3", + ], + notes: "Emphasize the margin expansion story", + }, + { + layout: "section", + title: "Strategic Priorities", + subtitle: "Key initiatives for the next fiscal year", + }, + { + title: "Market Opportunity", + subtitle: "Total addressable market analysis", + content: [ + "Global TAM estimated at $180B by 2027", + "Our serviceable market represents $42B opportunity", + "Current market share: 8.3% with clear path to 15%", + "Three adjacent markets identified for expansion", + "Competitive moat strengthening through R&D investment", + ], + }, + { + title: "Financial Performance", + table: { + headers: ["Metric", "FY2024", "FY2025", "Growth"], + rows: [ + ["Revenue", "$3.4B", "$4.2B", "+23%"], + ["Gross Margin", "62.1%", "64.8%", "+270bps"], + ["Operating Income", "$910M", "$1.19B", "+31%"], + ["Free Cash Flow", "$780M", "$1.02B", "+31%"], + ], + }, + }, + { + title: "Next Steps & Timeline", + content: [ + "Q1: Launch Phase 2 of platform modernization", + "Q2: Complete integration of acquired entities", + "Q3: Enter two new geographic markets", + "Q4: Achieve $5B annual revenue run-rate", + ], + }, +]; + +async function generateThemePreview(themeName, outputDir) { + const theme = getTheme(themeName); + const pptx = new PptxGenJS(); + pptx.title = `${theme.name} Theme Preview`; + pptx.author = "AnythingLLM"; + pptx.company = "AnythingLLM"; + + const totalSlides = SAMPLE_SLIDES.length; + + const titleSlide = pptx.addSlide(); + renderTitleSlide( + titleSlide, + pptx, + { title: `${theme.name} Theme`, author: "AnythingLLM Theme Preview" }, + theme + ); + + SAMPLE_SLIDES.forEach((slideData, index) => { + const slide = pptx.addSlide(); + const slideNumber = index + 1; + const layout = slideData.layout || "content"; + + switch (layout) { + case "title": + case "section": + renderSectionSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlides + ); + break; + case "blank": + renderBlankSlide(slide, pptx, theme, slideNumber, totalSlides); + break; + default: + renderContentSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlides + ); + break; + } + }); + + const filename = `theme-preview-${themeName}.pptx`; + const filepath = path.join(outputDir, filename); + await pptx.writeFile({ fileName: filepath }); + console.log(` ✓ ${theme.name} → ${filename}`); +} + +async function main() { + const baseDir = await createFilesLib.getOutputDirectory(); + const outputDir = path.join(baseDir, "theme-previews"); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + console.log("Generating theme previews…\n"); + const themes = getAvailableThemes(); + for (const themeName of themes) { + await generateThemePreview(themeName, outputDir); + } + console.log(`\nDone! ${themes.length} previews saved to:\n ${outputDir}`); +} + +main().catch(console.error); diff --git a/server/utils/agents/aibitat/plugins/create-files/pptx/themes.js b/server/utils/agents/aibitat/plugins/create-files/pptx/themes.js new file mode 100644 index 0000000..7557e56 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pptx/themes.js @@ -0,0 +1,181 @@ +/** + * Curated presentation themes for pptxgenjs. + * + * Each theme is a complete design system: title-slide palette, content-slide + * palette, table styling, footer colors, and typography. The rendering code + * in utils.js consumes these tokens to produce consistent, professional slides. + * + * Themes: default · corporate · dark · minimal · creative + */ + +const THEMES = { + default: { + name: "Professional", + description: "Clean and versatile — works for any presentation", + + titleSlideBackground: "1E293B", + titleSlideTitleColor: "FFFFFF", + titleSlideSubtitleColor: "94A3B8", + titleSlideAccentColor: "3B82F6", + + background: "FFFFFF", + titleColor: "0F172A", + subtitleColor: "64748B", + bodyColor: "334155", + accentColor: "2563EB", + bulletColor: "2563EB", + + tableHeaderBg: "1E293B", + tableHeaderColor: "FFFFFF", + tableAltRowBg: "F8FAFC", + tableBorderColor: "E2E8F0", + + footerColor: "94A3B8", + footerLineColor: "E2E8F0", + + fontTitle: "Calibri", + fontBody: "Calibri", + }, + + corporate: { + name: "Corporate", + description: "Refined and authoritative — ideal for business and finance", + + titleSlideBackground: "0C1929", + titleSlideTitleColor: "FFFFFF", + titleSlideSubtitleColor: "7B96B5", + titleSlideAccentColor: "C9943E", + + background: "FFFFFF", + titleColor: "0C1929", + subtitleColor: "5A6D82", + bodyColor: "2C3E50", + accentColor: "1A5276", + bulletColor: "1A5276", + + tableHeaderBg: "0C1929", + tableHeaderColor: "FFFFFF", + tableAltRowBg: "F4F7FA", + tableBorderColor: "D5DBE2", + + footerColor: "8B9DB3", + footerLineColor: "D5DBE2", + + fontTitle: "Calibri", + fontBody: "Calibri", + }, + + dark: { + name: "Dark", + description: "Sleek dark theme — great for tech and product presentations", + + titleSlideBackground: "0F0F1A", + titleSlideTitleColor: "F8FAFC", + titleSlideSubtitleColor: "7C8DB5", + titleSlideAccentColor: "818CF8", + + background: "18181B", + titleColor: "F4F4F5", + subtitleColor: "A1A1AA", + bodyColor: "D4D4D8", + accentColor: "6366F1", + bulletColor: "818CF8", + + tableHeaderBg: "6366F1", + tableHeaderColor: "FFFFFF", + tableAltRowBg: "1F1F24", + tableBorderColor: "3F3F46", + + footerColor: "71717A", + footerLineColor: "3F3F46", + + fontTitle: "Calibri", + fontBody: "Calibri", + }, + + minimal: { + name: "Minimal", + description: "Ultra-clean with maximum whitespace — lets content speak", + + titleSlideBackground: "F5F5F5", + titleSlideTitleColor: "171717", + titleSlideSubtitleColor: "737373", + titleSlideAccentColor: "A3A3A3", + + background: "FFFFFF", + titleColor: "171717", + subtitleColor: "737373", + bodyColor: "404040", + accentColor: "525252", + bulletColor: "A3A3A3", + + tableHeaderBg: "262626", + tableHeaderColor: "FFFFFF", + tableAltRowBg: "FAFAFA", + tableBorderColor: "E5E5E5", + + footerColor: "A3A3A3", + footerLineColor: "E5E5E5", + + fontTitle: "Calibri", + fontBody: "Calibri Light", + }, + + creative: { + name: "Creative", + description: "Bold and expressive — perfect for pitches and creative work", + + titleSlideBackground: "2E1065", + titleSlideTitleColor: "FFFFFF", + titleSlideSubtitleColor: "C4B5FD", + titleSlideAccentColor: "A78BFA", + + background: "FFFFFF", + titleColor: "3B0764", + subtitleColor: "7C3AED", + bodyColor: "374151", + accentColor: "7C3AED", + bulletColor: "7C3AED", + + tableHeaderBg: "5B21B6", + tableHeaderColor: "FFFFFF", + tableAltRowBg: "FAF5FF", + tableBorderColor: "E9D5FF", + + footerColor: "A78BFA", + footerLineColor: "E9D5FF", + + fontTitle: "Calibri", + fontBody: "Calibri", + }, +}; + +/** + * Get a theme by name, falling back to default if not found. + * @param {string} themeName + * @returns {object} Theme configuration + */ +function getTheme(themeName) { + const key = (themeName || "default").toLowerCase().trim(); + return THEMES[key] || THEMES.default; +} + +/** + * @returns {string[]} Available theme identifiers + */ +function getAvailableThemes() { + return Object.keys(THEMES); +} + +/** + * @returns {object[]} Array of { id, name, description } for documentation + */ +function getThemeDescriptions() { + return Object.entries(THEMES).map(([id, t]) => ({ + id, + name: t.name, + description: t.description, + })); +} + +module.exports = { THEMES, getTheme, getAvailableThemes, getThemeDescriptions }; diff --git a/server/utils/agents/aibitat/plugins/create-files/pptx/utils.js b/server/utils/agents/aibitat/plugins/create-files/pptx/utils.js new file mode 100644 index 0000000..3306086 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/pptx/utils.js @@ -0,0 +1,378 @@ +const createFilesLib = require("../lib.js"); + +// All positioning assumes LAYOUT_16x9: 10 × 5.625 in. +const MARGIN_X = 0.7; +const CONTENT_W = 8.6; // 10 - 2 × MARGIN_X +const SLIDE_H = 5.625; + +function isDarkColor(hexColor) { + const hex = (hexColor || "FFFFFF").replace("#", ""); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5; +} + +function addBranding(slide, bgColor) { + const isDark = isDarkColor(bgColor); + const textColor = isDark ? "FFFFFF" : "000000"; + const logo = createFilesLib.getLogo({ + forDarkBackground: isDark, + format: "dataUri", + }); + + slide.addText("Created with", { + x: 7.85, + y: 5.06, + w: 1.85, + h: 0.12, + fontSize: 5.5, + color: textColor, + transparency: 78, + fontFace: "Calibri", + align: "center", + italic: true, + }); + + if (logo) { + slide.addImage({ + data: logo, + x: 8.025, + y: 5.17, + w: 1.5, + h: 0.24, + transparency: 78, + }); + } else { + slide.addText("AnythingLLM", { + x: 7.85, + y: 5.17, + w: 1.85, + h: 0.24, + fontSize: 8, + color: textColor, + transparency: 78, + fontFace: "Calibri", + align: "center", + }); + } +} + +function addTopAccentBar(slide, pptx, theme) { + slide.addShape(pptx.ShapeType.rect, { + x: 0, + y: 0, + w: "100%", + h: 0.05, + fill: { color: theme.accentColor }, + line: { color: theme.accentColor }, + }); +} + +function addAccentUnderline(slide, pptx, x, y, color) { + slide.addShape(pptx.ShapeType.rect, { + x, + y, + w: 1.5, + h: 0.035, + fill: { color }, + line: { color }, + }); +} + +function addSlideFooter(slide, pptx, theme, slideNumber, totalSlides) { + slide.addShape(pptx.ShapeType.rect, { + x: MARGIN_X, + y: 5.0, + w: CONTENT_W, + h: 0.007, + fill: { color: theme.footerLineColor }, + line: { color: theme.footerLineColor }, + }); + + slide.addText(`${slideNumber} / ${totalSlides}`, { + x: MARGIN_X, + y: 5.07, + w: 1.2, + h: 0.25, + fontSize: 8, + color: theme.footerColor, + fontFace: theme.fontBody, + align: "left", + }); +} + +function renderTitleSlide(slide, pptx, { title, author }, theme) { + slide.background = { color: theme.titleSlideBackground }; + + slide.addText(title || "Untitled", { + x: 1.0, + y: 1.3, + w: 8.0, + h: 1.4, + fontSize: 36, + bold: true, + color: theme.titleSlideTitleColor, + fontFace: theme.fontTitle, + align: "center", + valign: "bottom", + }); + + addAccentUnderline(slide, pptx, 4.25, 2.9, theme.titleSlideAccentColor); + + if (author) { + slide.addText(author, { + x: 1.5, + y: 3.15, + w: 7.0, + h: 0.45, + fontSize: 14, + color: theme.titleSlideSubtitleColor, + fontFace: theme.fontBody, + align: "center", + italic: true, + }); + } + + // Bottom accent strip + slide.addShape(pptx.ShapeType.rect, { + x: 0, + y: SLIDE_H - 0.1, + w: "100%", + h: 0.1, + fill: { color: theme.titleSlideAccentColor }, + line: { color: theme.titleSlideAccentColor }, + }); + + addBranding(slide, theme.titleSlideBackground); +} + +function renderSectionSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlides +) { + slide.background = { color: theme.titleSlideBackground }; + + slide.addText(slideData.title || "", { + x: 1.0, + y: 1.5, + w: 8.0, + h: 1.2, + fontSize: 32, + bold: true, + color: theme.titleSlideTitleColor, + fontFace: theme.fontTitle, + align: "center", + valign: "bottom", + }); + + addAccentUnderline(slide, pptx, 4.25, 2.9, theme.titleSlideAccentColor); + + if (slideData.subtitle) { + slide.addText(slideData.subtitle, { + x: 1.5, + y: 3.1, + w: 7.0, + h: 0.5, + fontSize: 16, + color: theme.titleSlideSubtitleColor, + fontFace: theme.fontBody, + align: "center", + }); + } + + const numColor = isDarkColor(theme.titleSlideBackground) + ? "FFFFFF" + : "000000"; + slide.addText(`${slideNumber} / ${totalSlides}`, { + x: MARGIN_X, + y: 5.1, + w: 1.2, + h: 0.25, + fontSize: 8, + color: numColor, + transparency: 65, + fontFace: theme.fontBody, + align: "left", + }); + + addBranding(slide, theme.titleSlideBackground); + + if (slideData.notes) slide.addNotes(slideData.notes); +} + +function renderContentSlide( + slide, + pptx, + slideData, + theme, + slideNumber, + totalSlides +) { + slide.background = { color: theme.background }; + + addTopAccentBar(slide, pptx, theme); + + let contentStartY = 0.4; + + if (slideData.title) { + slide.addText(slideData.title, { + x: MARGIN_X, + y: 0.3, + w: CONTENT_W, + h: 0.65, + fontSize: 24, + bold: true, + color: theme.titleColor, + fontFace: theme.fontTitle, + valign: "bottom", + }); + contentStartY = 1.0; + + if (slideData.subtitle) { + slide.addText(slideData.subtitle, { + x: MARGIN_X, + y: 1.0, + w: CONTENT_W, + h: 0.3, + fontSize: 13, + color: theme.subtitleColor, + fontFace: theme.fontBody, + }); + contentStartY = 1.35; + } + + addAccentUnderline( + slide, + pptx, + MARGIN_X, + contentStartY + 0.05, + theme.accentColor + ); + contentStartY += 0.25; + } + + const footerY = 5.0; + const contentHeight = footerY - contentStartY - 0.15; + + if (slideData.table) { + addTableContent(slide, pptx, slideData.table, theme, contentStartY); + } else { + addBulletContent( + slide, + slideData.content, + theme, + contentStartY, + contentHeight + ); + } + + addSlideFooter(slide, pptx, theme, slideNumber, totalSlides); + addBranding(slide, theme.background); + + if (slideData.notes) slide.addNotes(slideData.notes); +} + +function renderBlankSlide(slide, pptx, theme, slideNumber, totalSlides) { + slide.background = { color: theme.background }; + addSlideFooter(slide, pptx, theme, slideNumber, totalSlides); + addBranding(slide, theme.background); +} + +function addBulletContent(slide, content, theme, startY, maxHeight) { + if (!Array.isArray(content) || content.length === 0) return; + + const bulletPoints = content.map((text) => ({ + text, + options: { + fontSize: 15, + color: theme.bodyColor, + fontFace: theme.fontBody, + bullet: { code: "25AA", color: theme.bulletColor }, + paraSpaceAfter: 10, + }, + })); + + slide.addText(bulletPoints, { + x: MARGIN_X, + y: startY, + w: CONTENT_W, + h: maxHeight, + valign: "top", + }); +} + +function addTableContent(slide, pptx, tableData, theme, startY) { + if (!tableData) return; + + const rows = []; + + if (tableData.headers?.length > 0) { + rows.push( + tableData.headers.map((header) => ({ + text: header, + options: { + bold: true, + fontSize: 12, + fontFace: theme.fontBody, + color: theme.tableHeaderColor, + fill: { color: theme.tableHeaderBg }, + align: "left", + valign: "middle", + margin: [4, 8, 4, 8], + }, + })) + ); + } + + if (tableData.rows?.length > 0) { + tableData.rows.forEach((row, idx) => { + rows.push( + row.map((cell) => ({ + text: cell, + options: { + fontSize: 11, + fontFace: theme.fontBody, + color: theme.bodyColor, + fill: { + color: idx % 2 === 1 ? theme.tableAltRowBg : theme.background, + }, + align: "left", + valign: "middle", + margin: [4, 8, 4, 8], + }, + })) + ); + }); + } + + if (rows.length === 0) return; + + const colCount = rows[0].length; + slide.addTable(rows, { + x: MARGIN_X, + y: startY, + w: CONTENT_W, + colW: CONTENT_W / colCount, + rowH: 0.4, + border: { type: "solid", pt: 0.5, color: theme.tableBorderColor }, + }); +} + +module.exports = { + isDarkColor, + addBranding, + addTopAccentBar, + addAccentUnderline, + addSlideFooter, + renderTitleSlide, + renderSectionSlide, + renderContentSlide, + renderBlankSlide, + addBulletContent, + addTableContent, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/text/create-text-file.js b/server/utils/agents/aibitat/plugins/create-files/text/create-text-file.js new file mode 100644 index 0000000..4d85cd5 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/text/create-text-file.js @@ -0,0 +1,158 @@ +const createFilesLib = require("../lib.js"); + +module.exports.CreateTextFile = { + name: "create-text-file", + plugin: function () { + return { + name: "create-text-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a text file with arbitrary content. " + + "Provide the content and an optional file extension (defaults to .txt). " + + "Common extensions include .txt, .md, .json, .csv, .html, .xml, .yaml, .log, etc.", + examples: [ + { + prompt: "Create a text file with meeting notes", + call: JSON.stringify({ + filename: "meeting-notes.txt", + content: + "Meeting Notes - Q1 Planning\n\nAttendees: John, Sarah, Mike\n\nAgenda:\n1. Review Q4 results\n2. Set Q1 goals\n3. Assign tasks\n\nAction Items:\n- John: Prepare budget report\n- Sarah: Draft marketing plan\n- Mike: Schedule follow-up meeting", + }), + }, + { + prompt: "Create a markdown file with project documentation", + call: JSON.stringify({ + filename: "README.md", + extension: "md", + content: + "# Project Name\n\n## Overview\nThis project provides...\n\n## Installation\n```bash\nnpm install\n```\n\n## Usage\nRun the application with...", + }), + }, + { + prompt: "Create a JSON configuration file", + call: JSON.stringify({ + filename: "config.json", + extension: "json", + content: JSON.stringify( + { + appName: "MyApp", + version: "1.0.0", + settings: { + debug: false, + maxConnections: 100, + }, + }, + null, + 2 + ), + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + filename: { + type: "string", + description: + "The filename for the text file. If no extension is provided, the extension parameter will be used (defaults to .txt).", + }, + extension: { + type: "string", + description: + "The file extension to use (without the dot). Defaults to 'txt'. Common options: txt, md, json, csv, html, xml, yaml, log, etc.", + default: "txt", + }, + content: { + type: "string", + description: + "The text content to write to the file. Can be any arbitrary text including multi-line content.", + }, + }, + required: ["filename", "content"], + additionalProperties: false, + }, + handler: async function ({ + filename = "document.txt", + extension = "txt", + content = "", + }) { + try { + this.super.handlerProps.log(`Using the create-text-file tool.`); + + const normalizedExt = extension.toLowerCase().replace(/^\./, ""); + const hasExtension = /\.\w+$/.test(filename); + + if (!hasExtension) { + filename = `${filename}.${normalizedExt}`; + } + + const finalExtension = filename.split(".").pop().toLowerCase(); + + this.super.introspect( + `${this.caller}: Creating text file "${filename}"` + ); + + const buffer = Buffer.from(content, "utf-8"); + const bufferSizeKB = (buffer.length / 1024).toFixed(2); + + this.super.handlerProps.log( + `create-text-file: Generated buffer - size: ${bufferSizeKB}KB, extension: ${finalExtension}` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { filename, extension: finalExtension }, + description: `Create text file "${filename}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const displayFilename = filename.split("/").pop(); + + const savedFile = await createFilesLib.saveGeneratedFile({ + fileType: "text", + extension: finalExtension, + buffer, + displayFilename, + }); + + this.super.socket.send("fileDownloadCard", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + createFilesLib.registerOutput(this.super, "TextFileDownload", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + this.super.introspect( + `${this.caller}: Successfully created text file "${displayFilename}"` + ); + + return `Successfully created text file "${displayFilename}" (${bufferSizeKB}KB).`; + } catch (e) { + this.super.handlerProps.log( + `create-text-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating text file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/xlsx/create-excel-file.js b/server/utils/agents/aibitat/plugins/create-files/xlsx/create-excel-file.js new file mode 100644 index 0000000..0e36a6c --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/xlsx/create-excel-file.js @@ -0,0 +1,367 @@ +const createFilesLib = require("../lib.js"); +const { + parseCSV, + validateCSVData, + detectDelimiter, + inferCellType, + applyBranding, + autoFitColumns, + applyHeaderStyle, + applyZebraStriping, + freezePanes, +} = require("./utils.js"); + +module.exports.CreateExcelFile = { + name: "create-excel-file", + plugin: function () { + return { + name: "create-excel-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create an Excel spreadsheet (.xlsx) from CSV data. " + + "Supports multiple sheets, automatic type detection (numbers, dates, booleans), " + + "header styling, column auto-fit, zebra striping, and frozen panes. " + + "Provide data in CSV format with comma, semicolon, tab, or pipe delimiters.", + examples: [ + { + prompt: "Create an Excel file with sales data", + call: JSON.stringify({ + filename: "sales-report.xlsx", + sheets: [ + { + name: "Q1 Sales", + csvData: + "Product,Region,Sales,Date\nWidget A,North,1250.50,2024-01-15\nWidget B,South,980.00,2024-01-20\nWidget C,East,1100.25,2024-02-01", + options: { + headerStyle: true, + autoFit: true, + freezeHeader: true, + }, + }, + ], + }), + }, + { + prompt: "Create a multi-sheet Excel workbook with employee data", + call: JSON.stringify({ + filename: "employee-directory.xlsx", + sheets: [ + { + name: "Employees", + csvData: + "ID,Name,Department,Salary,Start Date\n1,John Smith,Engineering,85000,2022-03-15\n2,Jane Doe,Marketing,72000,2021-08-01\n3,Bob Wilson,Sales,68000,2023-01-10", + options: { + headerStyle: true, + zebraStripes: true, + freezeHeader: true, + }, + }, + { + name: "Departments", + csvData: + "Department,Head,Budget\nEngineering,Alice Brown,500000\nMarketing,Carol White,250000\nSales,Dan Green,300000", + options: { headerStyle: true, autoFit: true }, + }, + ], + }), + }, + { + prompt: "Create a simple spreadsheet from CSV data", + call: JSON.stringify({ + filename: "data-export.xlsx", + csvData: + "Name,Email,Status\nAlice,alice@example.com,Active\nBob,bob@example.com,Pending\nCharlie,charlie@example.com,Active", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + filename: { + type: "string", + description: + "The filename for the Excel file. The .xlsx extension will be added automatically if not provided.", + }, + csvData: { + type: "string", + description: + "CSV data for a single-sheet workbook. Use comma, semicolon, tab, or pipe as delimiter. " + + "For multiple sheets, use the 'sheets' parameter instead.", + }, + sheets: { + type: "array", + description: + "Array of sheet definitions for multi-sheet workbooks. Each sheet has a name, csvData, and optional styling options.", + items: { + type: "object", + properties: { + name: { + type: "string", + description: + "The name of the worksheet (max 31 characters).", + }, + csvData: { + type: "string", + description: "The CSV data for this sheet.", + }, + options: { + type: "object", + description: "Optional styling options for this sheet.", + properties: { + headerStyle: { + type: "boolean", + description: + "Apply styling to the header row (bold, colored background).", + }, + autoFit: { + type: "boolean", + description: + "Auto-fit column widths based on content.", + }, + freezeHeader: { + type: "boolean", + description: + "Freeze the header row so it stays visible when scrolling.", + }, + zebraStripes: { + type: "boolean", + description: + "Apply alternating row colors for better readability.", + }, + delimiter: { + type: "string", + description: + "Override auto-detected delimiter. One of: comma, semicolon, tab, pipe.", + }, + }, + }, + }, + required: ["name", "csvData"], + }, + }, + options: { + type: "object", + description: + "Default styling options applied to all sheets (can be overridden per-sheet).", + properties: { + headerStyle: { type: "boolean" }, + autoFit: { type: "boolean" }, + freezeHeader: { type: "boolean" }, + zebraStripes: { type: "boolean" }, + }, + }, + }, + required: ["filename"], + additionalProperties: false, + }, + handler: async function ({ + filename = "spreadsheet.xlsx", + csvData = null, + sheets = null, + options = {}, + }) { + try { + this.super.handlerProps.log(`Using the create-excel-file tool.`); + + // Strip XML 1.0 illegal control characters from all cell content + // and sheet names so Excel can open the generated workbook. + csvData = createFilesLib.stripInvalidXmlChars(csvData); + sheets = createFilesLib.stripInvalidXmlChars(sheets); + + const hasExtension = /\.xlsx$/i.test(filename); + if (!hasExtension) filename = `${filename}.xlsx`; + + if (!csvData && (!sheets || sheets.length === 0)) { + return "Error: You must provide either 'csvData' for a single sheet or 'sheets' array for multiple sheets."; + } + + const sheetDefinitions = sheets + ? sheets + : [ + { + name: "Sheet1", + csvData, + options: {}, + }, + ]; + + for (const sheet of sheetDefinitions) { + if (!sheet.csvData || sheet.csvData.trim() === "") { + return `Error: Sheet "${sheet.name || "unnamed"}" has no CSV data.`; + } + } + + const sheetCount = sheetDefinitions.length; + this.super.introspect( + `${this.caller}: Creating Excel file "${filename}" with ${sheetCount} sheet(s)` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + filename, + sheetCount, + sheetNames: sheetDefinitions.map((s) => s.name), + }, + description: `Create Excel spreadsheet "${filename}" with ${sheetCount} sheet(s)`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.default.Workbook(); + + workbook.creator = "AnythingLLM"; + workbook.created = new Date(); + workbook.modified = new Date(); + + const allWarnings = []; + + for (const sheetDef of sheetDefinitions) { + let sheetName = (sheetDef.name || "Sheet").substring(0, 31); + sheetName = sheetName.replace(/[*?:\\/[\]]/g, "_"); + + const sheetOptions = { + ...options, + ...(sheetDef.options || {}), + }; + + const delimiterMap = { + comma: ",", + semicolon: ";", + tab: "\t", + pipe: "|", + }; + const delimiter = sheetOptions.delimiter + ? delimiterMap[sheetOptions.delimiter] || + sheetOptions.delimiter + : detectDelimiter(sheetDef.csvData); + + const parsedData = parseCSV(sheetDef.csvData, delimiter); + const validation = validateCSVData(parsedData); + + if (!validation.valid) { + return `Error in sheet "${sheetName}": ${validation.error}`; + } + + if (validation.warnings) { + allWarnings.push( + ...validation.warnings.map((w) => `${sheetName}: ${w}`) + ); + } + + const worksheet = workbook.addWorksheet(sheetName); + + for ( + let rowIndex = 0; + rowIndex < parsedData.length; + rowIndex++ + ) { + const rowData = parsedData[rowIndex]; + const row = worksheet.getRow(rowIndex + 1); + + for ( + let colIndex = 0; + colIndex < rowData.length; + colIndex++ + ) { + const cellValue = rowData[colIndex]; + const cell = row.getCell(colIndex + 1); + const typedValue = + rowIndex === 0 ? cellValue : inferCellType(cellValue); + + cell.value = typedValue; + + if (typedValue instanceof Date) { + cell.numFmt = "yyyy-mm-dd"; + } else if ( + typeof typedValue === "number" && + cellValue.includes("%") + ) { + cell.numFmt = "0.00%"; + } + } + + row.commit(); + } + + if (sheetOptions.autoFit !== false) { + autoFitColumns(worksheet); + } + + if (sheetOptions.headerStyle !== false) { + applyHeaderStyle(worksheet); + } + + if (sheetOptions.zebraStripes) { + applyZebraStriping(worksheet); + } + + if (sheetOptions.freezeHeader !== false) { + freezePanes(worksheet, 1, 0); + } + } + + applyBranding(workbook); + + const buffer = await workbook.xlsx.writeBuffer(); + const bufferSizeKB = (buffer.length / 1024).toFixed(2); + const displayFilename = filename.split("/").pop(); + + this.super.handlerProps.log( + `create-excel-file: Generated buffer - size: ${bufferSizeKB}KB, sheets: ${sheetDefinitions.length}` + ); + + const savedFile = await createFilesLib.saveGeneratedFile({ + fileType: "xlsx", + extension: "xlsx", + buffer: Buffer.from(buffer), + displayFilename, + }); + + this.super.socket.send("fileDownloadCard", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + createFilesLib.registerOutput(this.super, "ExcelFileDownload", { + filename: savedFile.displayFilename, + storageFilename: savedFile.filename, + fileSize: savedFile.fileSize, + }); + + this.super.introspect( + `${this.caller}: Successfully created Excel file "${displayFilename}"` + ); + + let result = `Successfully created Excel spreadsheet "${displayFilename}" (${bufferSizeKB}KB) with ${sheetDefinitions.length} sheet(s).`; + + if (allWarnings.length > 0) { + result += `\n\nWarnings:\n${allWarnings.map((w) => `- ${w}`).join("\n")}`; + } + + return result; + } catch (e) { + this.super.handlerProps.log( + `create-excel-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Excel file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/create-files/xlsx/utils.js b/server/utils/agents/aibitat/plugins/create-files/xlsx/utils.js new file mode 100644 index 0000000..cf7432d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-files/xlsx/utils.js @@ -0,0 +1,333 @@ +/** + * Parses CSV string into a 2D array of values. + * Handles quoted fields, embedded commas, and newlines within quotes. + * @param {string} csvString - The CSV content to parse + * @param {string} [delimiter=","] - The field delimiter + * @returns {string[][]} 2D array of parsed values + */ +function parseCSV(csvString, delimiter = ",") { + const rows = []; + let currentRow = []; + let currentField = ""; + let inQuotes = false; + + for (let i = 0; i < csvString.length; i++) { + const char = csvString[i]; + const nextChar = csvString[i + 1]; + + if (inQuotes) { + if (char === '"' && nextChar === '"') { + currentField += '"'; + i++; + } else if (char === '"') { + inQuotes = false; + } else { + currentField += char; + } + } else { + if (char === '"') { + inQuotes = true; + } else if (char === delimiter) { + currentRow.push(currentField.trim()); + currentField = ""; + } else if (char === "\r" && nextChar === "\n") { + currentRow.push(currentField.trim()); + rows.push(currentRow); + currentRow = []; + currentField = ""; + i++; + } else if (char === "\n" || char === "\r") { + currentRow.push(currentField.trim()); + rows.push(currentRow); + currentRow = []; + currentField = ""; + } else { + currentField += char; + } + } + } + + if (currentField || currentRow.length > 0) { + currentRow.push(currentField.trim()); + rows.push(currentRow); + } + + return rows.filter((row) => row.some((cell) => cell !== "")); +} + +/** + * Validates CSV data structure. + * @param {string[][]} data - Parsed CSV data + * @returns {{valid: boolean, error?: string, warnings?: string[]}} + */ +function validateCSVData(data) { + const warnings = []; + + if (!data || data.length === 0) { + return { valid: false, error: "CSV data is empty" }; + } + + if (data.length === 1 && data[0].length === 1 && !data[0][0]) { + return { valid: false, error: "CSV data contains no meaningful content" }; + } + + const columnCounts = data.map((row) => row.length); + const maxColumns = Math.max(...columnCounts); + const minColumns = Math.min(...columnCounts); + + if (maxColumns !== minColumns) { + warnings.push( + `Inconsistent column count: rows have between ${minColumns} and ${maxColumns} columns. Missing cells will be empty.` + ); + } + + if (maxColumns > 16384) { + return { + valid: false, + error: `CSV has ${maxColumns} columns, exceeding Excel's limit of 16,384 columns`, + }; + } + + if (data.length > 1048576) { + return { + valid: false, + error: `CSV has ${data.length} rows, exceeding Excel's limit of 1,048,576 rows`, + }; + } + + return { valid: true, warnings: warnings.length > 0 ? warnings : undefined }; +} + +/** + * Attempts to detect the delimiter used in a CSV string. + * @param {string} csvString - The CSV content + * @returns {string} Detected delimiter (comma, semicolon, tab, or pipe) + */ +function detectDelimiter(csvString) { + const firstLine = csvString.split(/\r?\n/)[0] || ""; + const delimiters = [",", ";", "\t", "|"]; + let bestDelimiter = ","; + let maxCount = 0; + + for (const delimiter of delimiters) { + const count = (firstLine.match(new RegExp(`\\${delimiter}`, "g")) || []) + .length; + if (count > maxCount) { + maxCount = count; + bestDelimiter = delimiter; + } + } + + return bestDelimiter; +} + +/** + * Attempts to convert a string value to an appropriate type (number, date, boolean, or string). + * @param {string} value - The string value to convert + * @returns {string|number|Date|boolean} The converted value + */ +function inferCellType(value) { + if (value === "" || value === null || value === undefined) { + return ""; + } + + const trimmed = value.trim(); + const lowerTrimmed = trimmed.toLowerCase(); + + if (lowerTrimmed === "true") return true; + if (lowerTrimmed === "false") return false; + + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const num = parseFloat(trimmed); + if (!isNaN(num) && isFinite(num)) { + return num; + } + } + + if (/^-?\d{1,3}(,\d{3})*(\.\d+)?$/.test(trimmed)) { + const num = parseFloat(trimmed.replace(/,/g, "")); + if (!isNaN(num) && isFinite(num)) { + return num; + } + } + + const currencyMatch = trimmed.match(/^[$€£¥₹]?\s*(-?\d+(?:[,.\d]*\d)?)\s*$/); + if (currencyMatch) { + const num = parseFloat(currencyMatch[1].replace(/,/g, "")); + if (!isNaN(num) && isFinite(num)) { + return num; + } + } + + if (/^\d+(\.\d+)?%$/.test(trimmed)) { + const num = parseFloat(trimmed) / 100; + if (!isNaN(num) && isFinite(num)) { + return num; + } + } + + const datePatterns = [ + /^\d{4}-\d{2}-\d{2}$/, + /^\d{2}\/\d{2}\/\d{4}$/, + /^\d{2}-\d{2}-\d{4}$/, + /^\d{4}\/\d{2}\/\d{2}$/, + ]; + + for (const pattern of datePatterns) { + if (pattern.test(trimmed)) { + const date = new Date(trimmed); + if (!isNaN(date.getTime())) { + return date; + } + } + } + + return value; +} + +/** + * Applies AnythingLLM branding to an Excel workbook. + * Adds a subtle "Created with AnythingLLM" text row below the data on each sheet. + * @param {import('exceljs').Workbook} workbook - The ExcelJS workbook instance + */ +function applyBranding(workbook) { + for (const worksheet of workbook.worksheets) { + const lastRow = worksheet.rowCount || 1; + const lastCol = worksheet.columnCount || 1; + + const brandingRowNum = lastRow + 2; + + if (lastCol > 1) { + worksheet.mergeCells(brandingRowNum, 1, brandingRowNum, lastCol); + } + + const brandingCell = worksheet.getCell(brandingRowNum, 1); + brandingCell.value = "Created with AnythingLLM"; + brandingCell.font = { + italic: true, + size: 9, + color: { argb: "FF999999" }, + }; + brandingCell.alignment = { + horizontal: "right", + vertical: "middle", + }; + } +} + +/** + * Auto-fits column widths based on content. + * @param {import('exceljs').Worksheet} worksheet - The worksheet to auto-fit + * @param {number} [minWidth=8] - Minimum column width + * @param {number} [maxWidth=50] - Maximum column width + */ +function autoFitColumns(worksheet, minWidth = 8, maxWidth = 50) { + worksheet.columns.forEach((column, colIndex) => { + let maxLength = minWidth; + + worksheet.eachRow({ includeEmpty: false }, (row) => { + const cell = row.getCell(colIndex + 1); + const cellValue = cell.value; + let cellLength = minWidth; + + if (cellValue !== null && cellValue !== undefined) { + if (typeof cellValue === "string") { + cellLength = cellValue.length; + } else if (cellValue instanceof Date) { + cellLength = 12; + } else if (typeof cellValue === "number") { + cellLength = cellValue.toString().length; + } else if (typeof cellValue === "object" && cellValue.richText) { + cellLength = cellValue.richText.reduce( + (acc, rt) => acc + (rt.text?.length || 0), + 0 + ); + } else { + cellLength = String(cellValue).length; + } + } + + maxLength = Math.max(maxLength, cellLength); + }); + + column.width = Math.min(maxLength + 2, maxWidth); + }); +} + +/** + * Applies header styling to the first row of a worksheet. + * @param {import('exceljs').Worksheet} worksheet - The worksheet to style + * @param {Object} [options] - Styling options + * @param {boolean} [options.bold=true] - Make headers bold + * @param {string} [options.fill] - Background color (ARGB format, e.g., 'FF4472C4') + * @param {string} [options.fontColor] - Font color (ARGB format, e.g., 'FFFFFFFF') + */ +function applyHeaderStyle( + worksheet, + { bold = true, fill = "FF4472C4", fontColor = "FFFFFFFF" } = {} +) { + const headerRow = worksheet.getRow(1); + if (!headerRow || headerRow.cellCount === 0) return; + + headerRow.eachCell((cell) => { + cell.font = { + bold, + color: { argb: fontColor }, + }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: fill }, + }; + cell.alignment = { + vertical: "middle", + horizontal: "center", + }; + }); + + headerRow.height = 20; +} + +/** + * Applies alternating row colors (zebra striping) to a worksheet. + * @param {import('exceljs').Worksheet} worksheet - The worksheet to style + * @param {string} [evenColor='FFF2F2F2'] - Color for even rows (ARGB format) + * @param {number} [startRow=2] - Row to start alternating from (skips header) + */ +function applyZebraStriping(worksheet, evenColor = "FFF2F2F2", startRow = 2) { + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber >= startRow && rowNumber % 2 === 0) { + row.eachCell((cell) => { + if (!cell.fill || cell.fill.type !== "pattern") { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: evenColor }, + }; + } + }); + } + }); +} + +/** + * Freezes the header row and optionally first columns. + * @param {import('exceljs').Worksheet} worksheet - The worksheet to modify + * @param {number} [rows=1] - Number of rows to freeze + * @param {number} [columns=0] - Number of columns to freeze + */ +function freezePanes(worksheet, rows = 1, columns = 0) { + worksheet.views = [{ state: "frozen", xSplit: columns, ySplit: rows }]; +} + +module.exports = { + parseCSV, + validateCSVData, + detectDelimiter, + inferCellType, + applyBranding, + autoFitColumns, + applyHeaderStyle, + applyZebraStriping, + freezePanes, +}; diff --git a/server/utils/agents/aibitat/plugins/create-scheduled-job/cronUtils.js b/server/utils/agents/aibitat/plugins/create-scheduled-job/cronUtils.js new file mode 100644 index 0000000..7c0d3b6 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-scheduled-job/cronUtils.js @@ -0,0 +1,157 @@ +/** + * UTC offset in minutes for a given IANA timezone at a specific instant. + * Throws RangeError if `timeZone` is not a valid IANA identifier. + * @param {string} timeZone + * @param {Date} [at] + * @returns {number} Minutes to add to UTC to get wall-clock time in the zone. + */ +function tzOffsetMinutes(timeZone, at = new Date()) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const p = Object.fromEntries( + dtf.formatToParts(at).map((part) => [part.type, part.value]) + ); + const asUTC = Date.UTC( + Number(p.year), + Number(p.month) - 1, + Number(p.day), + p.hour === "24" ? 0 : Number(p.hour), + Number(p.minute), + Number(p.second) + ); + return Math.round((asUTC - at.getTime()) / 60000); +} + +/** + * Convert a local hour + minute to UTC hour + minute for a given IANA timezone. + * @param {number} localHour + * @param {number} localMinute + * @param {string} timeZone + * @returns {{ hour: number, minute: number }} + */ +function localToUtcHM(localHour, localMinute, timeZone) { + const offset = tzOffsetMinutes(timeZone); + let total = localHour * 60 + localMinute - offset; + total = ((total % 1440) + 1440) % 1440; + return { hour: Math.floor(total / 60), minute: total % 60 }; +} + +/** + * Convert the hour/minute fields of a 5-field cron expression from a user's + * local timezone to UTC. Returns the original string unchanged if the pattern + * has no specific hour (e.g. every-minute or every-N-hours schedules). + * + * @param {string} cron - 5-field cron expression in local time. + * @param {string} timeZone - IANA timezone (e.g. "America/New_York"). + * @returns {string} 5-field cron expression in UTC. + */ +function convertCronLocalToUtc(cron, timeZone) { + if (!cron || typeof cron !== "string") return cron; + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return cron; + + const [minute, hour, dom, month, dow] = parts; + + // Only shift when both fields are plain integers (specific time, not a wildcard/step). + if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return cron; + + const utc = localToUtcHM(Number(hour), Number(minute), timeZone); + return `${utc.minute} ${utc.hour} ${dom} ${month} ${dow}`; +} + +/** + * Flatten the Scheduled Jobs tool catalog into a single Set of valid tool IDs. + * @param {Awaited>} catalog + * @returns {Set} + */ +function catalogIdSet(catalog) { + const ids = new Set(); + for (const category of catalog) { + for (const item of category.items || []) ids.add(item.id); + } + return ids; +} + +/** + * Filter the catalog down to tools that are configured and ready to use, + * dropping anything still requiring setup (e.g. Gmail/Calendar/Outlook with no + * credentials, SQL with no connection). This mirrors the manual Scheduled Jobs + * UI, which disables selection of `requiresSetup` tools. A tool is treated as + * not-ready if either the item or its category is flagged `requiresSetup`. + */ +function readyToolsCatalog(catalog) { + return catalog + .map((category) => ({ + ...category, + items: (category.items || []).filter( + (item) => !item.requiresSetup && !category.requiresSetup + ), + })) + .filter((category) => category.items.length > 0); +} + +/** + * Render the tool catalog as a readable, grouped text block for the agent. + * @param {ReturnType} catalog + * @returns {string} + */ +function renderCatalog(catalog) { + if (!catalog?.length) return "No tools are available for scheduled jobs."; + return catalog + .map((category) => { + const lines = (category.items || []).map((item) => { + const setup = item.requiresSetup ? " [requires setup]" : ""; + const desc = item.description ? ` - ${item.description}` : ""; + return ` - ${item.id}${setup}${desc}`; + }); + return `${category.name}:\n${lines.join("\n")}`; + }) + .join("\n\n"); +} + +/** + * Build an actionable correction message when the agent passes tool IDs that + * can't be used. Separates tools that exist but still need setup from IDs that + * don't exist at all, then lists the ready-to-use catalog to choose from. + * @param {string[]} rejected + * @param {ReturnType} fullCatalog + * @param {ReturnType} readyCatalog + * @returns {string} + */ +function rejectedToolsMessage(rejected, fullCatalog, readyCatalog) { + const allIds = catalogIdSet(fullCatalog); + const needsSetup = rejected.filter((id) => allIds.has(id)); + const unknown = rejected.filter((id) => !allIds.has(id)); + + const lines = []; + if (needsSetup.length > 0) + lines.push( + `These tools exist but are not configured yet, so they can't be added to a job: ${needsSetup.join( + ", " + )}. The user must set them up first in Settings > Agent Skills.` + ); + if (unknown.length > 0) + lines.push(`These tool IDs are not valid: ${unknown.join(", ")}.`); + + return `${lines.join( + "\n" + )}\n\nCall this tool with \`listTools: true\` to see valid IDs, or choose only from these ready-to-use tools:\n\n${renderCatalog( + readyCatalog + )}`; +} + +module.exports = { + convertCronLocalToUtc, + catalogIdSet, + readyToolsCatalog, + renderCatalog, + rejectedToolsMessage, +}; diff --git a/server/utils/agents/aibitat/plugins/create-scheduled-job/index.js b/server/utils/agents/aibitat/plugins/create-scheduled-job/index.js new file mode 100644 index 0000000..96138ec --- /dev/null +++ b/server/utils/agents/aibitat/plugins/create-scheduled-job/index.js @@ -0,0 +1,226 @@ +const { ScheduledJob } = require("../../../../../models/scheduledJob"); +const { BackgroundService } = require("../../../../BackgroundWorkers"); +const { UserMetaCache } = require("../../../../userLocale"); +const { + convertCronLocalToUtc, + catalogIdSet, + readyToolsCatalog, + renderCatalog, + rejectedToolsMessage, +} = require("./cronUtils"); + +const createScheduledJob = { + name: "create-scheduled-job", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a recurring Scheduled Job that automatically runs an agent prompt on a schedule (e.g. 'every weekday at 9am summarize my inbox and email me'). " + + "Provide `schedule` as a standard 5-field cron expression (minute hour dom month dow) in the USER'S LOCAL TIME - the server automatically converts to UTC. " + + "IMPORTANT - the job runs later on its own with NO chat context and can ONLY use the tools you list in `tools`. " + + "Tool IDs for scheduled jobs are NOT the same as agent plugin names - you MUST call this tool with `listTools: true` first to get the exact valid IDs, then call it again with your chosen `tools`. " + + "Never guess tool IDs - always discover them via `listTools: true` first.", + examples: [ + { + prompt: "Step 1 - always discover valid tool IDs first", + call: JSON.stringify({ listTools: true }), + }, + { + prompt: "Step 2 - create the job using IDs returned by listTools", + call: JSON.stringify({ + name: "Weekday inbox summary", + prompt: + "Summarize my unread inbox emails and send me a summary email.", + schedule: "0 9 * * 1-5", + tools: [ + "gmail-agent#gmail-get-inbox", + "gmail-agent#gmail-send-email", + ], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + listTools: { + type: "boolean", + description: + "Returns the catalog of valid tool IDs for scheduled jobs. ALWAYS call this first before specifying any `tools` - scheduled job tool IDs are different from agent plugin names and must be discovered, not guessed.", + }, + name: { + type: "string", + description: + "Short, human-readable name for the scheduled job.", + }, + prompt: { + type: "string", + description: + "The instruction the agent will execute each time the job runs. Be specific and self-contained - the job runs later with no chat context.", + }, + schedule: { + type: "string", + description: + "A standard 5-field cron expression in the user's LOCAL time (minute hour dom month dow). The server converts to UTC automatically. Examples: '0 9 * * 1-5' = weekdays at 09:00 local, '30 14 * * *' = daily at 14:30 local, '0 */2 * * *' = every 2 hours (no conversion needed).", + }, + tools: { + type: "array", + items: { type: "string" }, + description: + "The tool IDs (from `listTools`) the job is allowed to use when it runs. The job can ONLY use tools listed here, so include every tool the prompt needs to do its work. Omit only if the task genuinely needs no tools - the job will then run with no tools at all.", + }, + }, + additionalProperties: false, + }, + handler: async function (args = {}) { + try { + return await this.execute(args); + } catch (error) { + const message = error?.message ?? JSON.stringify(error); + this.super.handlerProps.log( + `create-scheduled-job error: ${message}` + ); + this.super.introspect( + `${this.caller}: Failed to create scheduled job: ${message}` + ); + return `There was an error creating the scheduled job: ${message}`; + } + }, + + execute: async function (args) { + // Only offer tools that are configured and ready to use - a job + // can't use a tool that still needs setup (e.g. Gmail without + // credentials), exactly like the manual Scheduled Jobs UI. + const fullCatalog = await ScheduledJob.availableTools(); + const catalog = readyToolsCatalog(fullCatalog); + + // Discovery mode: return the catalog of valid tool IDs. + if (args.listTools === true) { + return `Tools available to scheduled jobs (pass any of these IDs in the \`tools\` argument):\n\n${renderCatalog( + catalog + )}`; + } + + // Required fields. + if (!args.name?.trim()) return "A job `name` is required."; + if (!args.prompt?.trim()) return "A job `prompt` is required."; + if (!args.schedule?.trim()) + return "A `schedule` cron expression is required."; + + const localCron = args.schedule.trim(); + if (!ScheduledJob.isValidCron(localCron)) { + return `'${localCron}' is not a valid 5-field cron expression. Please provide a valid cron string (e.g. '0 9 * * 1-5' for weekdays at 09:00).`; + } + + // Convert the model's local-time cron to UTC using the user's + // timezone from the per-request locale cache (populated by the + // X-Timezone header on every authenticated request). + const userId = this.super.handlerProps.invocation?.user_id ?? null; + const { timezone } = UserMetaCache.get(userId); + const cron = convertCronLocalToUtc(localCron, timezone); + + // Resolve the tools the job may use. A scheduled job can ONLY use + // the tools stored on it, and - exactly like the manual Scheduled + // Jobs UI - no tools selected means the job runs with NO tools (see + // server/jobs/run-scheduled-job.js). If the agent supplied tools, + // validate them against the catalog; otherwise store null. + let tools = null; + if (Array.isArray(args.tools) && args.tools.length > 0) { + const readyIds = catalogIdSet(catalog); + const rejected = args.tools.filter((id) => !readyIds.has(id)); + + if (rejected.length > 0) + return rejectedToolsMessage(rejected, fullCatalog, catalog); + + tools = args.tools; + } + + // New jobs are enabled by default, so creating one is an activation. + const activation = await ScheduledJob.canActivate(); + if (!activation.allowed) { + return `Cannot create the job: the maximum of ${activation.limit} active scheduled jobs has been reached. Ask the user to disable an existing job first.`; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + name: args.name.trim(), + schedule: localCron, + tools: tools ?? [], + }, + description: `Create scheduled job "${args.name.trim()}" (${localCron})`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const { job, error } = await ScheduledJob.create({ + name: args.name.trim(), + prompt: args.prompt.trim(), + tools, + schedule: cron, + }); + if (error) return `Failed to create the scheduled job: ${error}`; + + // Register the job with the live scheduler. BackgroundService is a + // singleton, so this is the same instance the server booted. + new BackgroundService().addScheduledJob(job); + + this.super.introspect( + `${this.caller}: Created scheduled job "${job.name}" (local: ${localCron}, UTC: ${cron}, tz: ${timezone}).` + ); + + const nextRun = job.nextRunAt + ? new Date(job.nextRunAt).toISOString() + : "unknown"; + + // Emit a clickable "job created" card in the chat the user can + // click to open the job, mirroring how file-download cards work: + // send it live over the socket AND register it as a pending output + // so it persists and re-renders when the chat is reloaded. + const cardPayload = { + jobId: job.id, + jobName: job.name, + schedule: localCron, + nextRun, + }; + this.super.socket?.send?.("scheduledJobCreated", cardPayload); + if (!Array.isArray(this.super._pendingOutputs)) + this.super._pendingOutputs = []; + this.super._pendingOutputs.push({ + type: "scheduledJobCreated", + payload: cardPayload, + }); + + const toolNote = tools?.length + ? `It may use these tools: ${tools.join(", ")}.` + : "No tools were selected, so it runs with no tools (base language model only)."; + const tzNote = + localCron !== cron + ? ` (converted from \`${localCron}\` ${timezone} to \`${cron}\` UTC)` + : ` (\`${cron}\` UTC)`; + return ( + `Created scheduled job "${job.name}" (#${job.id}). ` + + `Schedule: \`${localCron}\`${tzNote}. ${toolNote} Next run: ${nextRun}. ` + + `The user can manage it from Settings > Scheduled Jobs.` + ); + }, + }); + }, + }; + }, +}; + +module.exports = { createScheduledJob }; diff --git a/server/utils/agents/aibitat/plugins/file-history.js b/server/utils/agents/aibitat/plugins/file-history.js new file mode 100644 index 0000000..2cab5e1 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/file-history.js @@ -0,0 +1,37 @@ +const fs = require("fs"); +const path = require("path"); + +/** + * Plugin to save chat history to a json file + */ +const fileHistory = { + name: "file-history-plugin", + startupConfig: { + params: {}, + }, + plugin: function ({ + filename = `history/chat-history-${new Date().toISOString()}.json`, + } = {}) { + return { + name: this.name, + setup(aibitat) { + const folderPath = path.dirname(filename); + // get path from filename + if (folderPath) { + fs.mkdirSync(folderPath, { recursive: true }); + } + + aibitat.onMessage(() => { + const content = JSON.stringify(aibitat.chats, null, 2); + fs.writeFile(filename, content, (err) => { + if (err) { + console.error(err); + } + }); + }); + }, + }; + }, +}; + +module.exports = { fileHistory }; diff --git a/server/utils/agents/aibitat/plugins/filesystem/copy-file.js b/server/utils/agents/aibitat/plugins/filesystem/copy-file.js new file mode 100644 index 0000000..ab18559 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/copy-file.js @@ -0,0 +1,127 @@ +const fs = require("fs/promises"); +const path = require("path"); +const filesystem = require("./lib.js"); + +async function copyRecursive(source, destination) { + const lstat = await fs.lstat(source); + + if (lstat.isSymbolicLink()) { + throw new Error( + `Cannot copy symbolic link: ${source}. Symlinks are not allowed during copy operations.` + ); + } + + if (lstat.isDirectory()) { + await fs.mkdir(destination, { recursive: true }); + const entries = await fs.readdir(source); + for (const entry of entries) { + await copyRecursive( + path.join(source, entry), + path.join(destination, entry) + ); + } + } else { + await fs.copyFile(source, destination); + } +} + +module.exports.FilesystemCopyFile = { + name: "filesystem-copy-file", + plugin: function () { + return { + name: "filesystem-copy-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Copy files and directories. Creates a duplicate of the source at the destination path. " + + "For directories, performs a recursive copy of all contents. If the destination exists, " + + "the operation will fail. Both source and destination must be within allowed directories.", + examples: [ + { + prompt: "Make a backup copy of config.json", + call: JSON.stringify({ + source: "config.json", + destination: "config.backup.json", + }), + }, + { + prompt: "Copy the templates folder to a new location", + call: JSON.stringify({ + source: "templates", + destination: "templates-backup", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + source: { + type: "string", + description: "The path of the file or directory to copy.", + }, + destination: { + type: "string", + description: + "The destination path where the copy should be created.", + }, + }, + required: ["source", "destination"], + additionalProperties: false, + }, + handler: async function ({ source = "", destination = "" }) { + try { + this.super.handlerProps.log( + `Using the filesystem-copy-file tool.` + ); + + const validSourcePath = await filesystem.validatePath(source); + const validDestPath = await filesystem.validatePath(destination); + + this.super.introspect( + `${this.caller}: Copying ${source} to ${destination}` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { source, destination }, + description: "Copy a file or directory to a new location", + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const destExists = await fs + .access(validDestPath) + .then(() => true) + .catch(() => false); + + if (destExists) { + return `Error: Destination "${destination}" already exists. Please choose a different destination.`; + } + + await copyRecursive(validSourcePath, validDestPath); + this.super.introspect( + `Successfully copied ${source} to ${destination}` + ); + return `Successfully copied ${source} to ${destination}`; + } catch (e) { + this.super.handlerProps.log( + `filesystem-copy-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error copying file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/create-directory.js b/server/utils/agents/aibitat/plugins/filesystem/create-directory.js new file mode 100644 index 0000000..f843085 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/create-directory.js @@ -0,0 +1,84 @@ +const fs = require("fs/promises"); +const filesystem = require("./lib.js"); + +module.exports.FilesystemCreateDirectory = { + name: "filesystem-create-directory", + plugin: function () { + return { + name: "filesystem-create-directory", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new directory or ensure a directory exists. Can create multiple " + + "nested directories in one operation. If the directory already exists, " + + "this operation will succeed silently. Perfect for setting up directory " + + "structures for projects or ensuring required paths exist. Only works within allowed directories.", + examples: [ + { + prompt: "Create a new folder called 'reports'", + call: JSON.stringify({ path: "reports" }), + }, + { + prompt: "Create nested directories for the new module", + call: JSON.stringify({ path: "src/modules/auth/utils" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: + "The path of the directory to create. Can include nested paths.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + handler: async function ({ path: dirPath = "" }) { + try { + this.super.handlerProps.log( + `Using the filesystem-create-directory tool.` + ); + + const validPath = await filesystem.validatePath(dirPath); + this.super.introspect( + `${this.caller}: Creating directory ${dirPath}` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { path: dirPath }, + description: "Create a new directory", + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + await fs.mkdir(validPath, { recursive: true }); + this.super.introspect( + `Successfully created directory ${dirPath}` + ); + return `Successfully created directory ${dirPath}`; + } catch (e) { + this.super.handlerProps.log( + `filesystem-create-directory error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating directory: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/edit-file.js b/server/utils/agents/aibitat/plugins/filesystem/edit-file.js new file mode 100644 index 0000000..a1965e7 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/edit-file.js @@ -0,0 +1,133 @@ +const filesystem = require("./lib.js"); + +module.exports.FilesystemEditFile = { + name: "filesystem-edit-file", + plugin: function () { + return { + name: "filesystem-edit-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Make line-based edits to a text file. Each edit replaces exact line sequences " + + "with new content. Returns a git-style diff showing the changes made. " + + "Use dryRun=true to preview changes without applying them. " + + "Only works within allowed directories.", + examples: [ + { + prompt: "Change the port number from 3000 to 8080 in config.js", + call: JSON.stringify({ + path: "config.js", + edits: [{ oldText: "port: 3000", newText: "port: 8080" }], + }), + }, + { + prompt: "Preview what would happen if I renamed the function", + call: JSON.stringify({ + path: "utils.js", + edits: [ + { + oldText: "function oldName()", + newText: "function newName()", + }, + ], + dryRun: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: "The path to the file to edit.", + }, + edits: { + type: "array", + items: { + type: "object", + properties: { + oldText: { + type: "string", + description: "Text to search for - must match exactly.", + }, + newText: { + type: "string", + description: "Text to replace with.", + }, + }, + required: ["oldText", "newText"], + }, + description: "Array of edit operations to apply.", + }, + dryRun: { + type: "boolean", + default: false, + description: + "If true, preview changes using git-style diff format without applying them.", + }, + }, + required: ["path", "edits"], + additionalProperties: false, + }, + handler: async function ({ + path: filePath = "", + edits = [], + dryRun = false, + }) { + try { + this.super.handlerProps.log( + `Using the filesystem-edit-file tool.` + ); + + if (!Array.isArray(edits) || edits.length === 0) { + return "Error: At least one edit operation must be provided."; + } + + const validPath = await filesystem.validatePath(filePath); + + this.super.introspect( + `${this.caller}: ${dryRun ? "Previewing" : "Applying"} ${edits.length} edit(s) to ${filePath}` + ); + + if (this.super.requestToolApproval && !dryRun) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { path: filePath, edits, dryRun }, + description: "Edit a file", + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + const result = await filesystem.applyFileEdits( + validPath, + edits, + dryRun + ); + + if (dryRun) + this.super.introspect(`Preview of changes to ${filePath}:`); + else this.super.introspect(`Successfully edited ${filePath}`); + + return result; + } catch (e) { + this.super.handlerProps.log( + `filesystem-edit-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error editing file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/get-file-info.js b/server/utils/agents/aibitat/plugins/filesystem/get-file-info.js new file mode 100644 index 0000000..085cd68 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/get-file-info.js @@ -0,0 +1,75 @@ +const filesystem = require("./lib.js"); + +module.exports.FilesystemGetFileInfo = { + name: "filesystem-get-file-info", + plugin: function () { + return { + name: "filesystem-get-file-info", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Retrieve detailed metadata about a file or directory. Returns comprehensive " + + "information including size, creation time, last modified time, permissions, " + + "and type. This tool is perfect for understanding file characteristics " + + "without reading the actual content. Only works within allowed directories.", + examples: [ + { + prompt: "What's the size of the database file?", + call: JSON.stringify({ path: "data/app.db" }), + }, + { + prompt: "When was the config file last modified?", + call: JSON.stringify({ path: "config.json" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: + "The path to the file or directory to get information about.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + handler: async function ({ path: filePath = "" }) { + try { + this.super.handlerProps.log( + `Using the filesystem-get-file-info tool.` + ); + + const validPath = await filesystem.validatePath(filePath); + + this.super.introspect( + `${this.caller}: Getting info for ${filePath}` + ); + + const info = await filesystem.getFileStats(validPath); + + const formatted = Object.entries(info) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); + + this.super.introspect( + `Successfully retrieved info for ${filePath}` + ); + + return formatted; + } catch (e) { + this.super.handlerProps.log( + `filesystem-get-file-info error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting file info: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/index.js b/server/utils/agents/aibitat/plugins/filesystem/index.js new file mode 100644 index 0000000..7a53638 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/index.js @@ -0,0 +1,33 @@ +const { FilesystemReadTextFile } = require("./read-text-file.js"); +const { FilesystemReadMultipleFiles } = require("./read-multiple-files.js"); +const { FilesystemWriteTextFile } = require("./write-text-file.js"); +const { FilesystemEditFile } = require("./edit-file.js"); +const { FilesystemCreateDirectory } = require("./create-directory.js"); +const { FilesystemListDirectory } = require("./list-directory.js"); +const { FilesystemMoveFile } = require("./move-file.js"); +const { FilesystemCopyFile } = require("./copy-file.js"); +const { FilesystemSearchFiles } = require("./search-files.js"); +const { FilesystemGetFileInfo } = require("./get-file-info.js"); + +const filesystemAgent = { + name: "filesystem-agent", + startupConfig: { + params: {}, + }, + plugin: [ + FilesystemReadTextFile, + FilesystemReadMultipleFiles, + FilesystemWriteTextFile, + FilesystemEditFile, + FilesystemCreateDirectory, + FilesystemListDirectory, + FilesystemMoveFile, + FilesystemCopyFile, + FilesystemSearchFiles, + FilesystemGetFileInfo, + ], +}; + +module.exports = { + filesystemAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/lib.js b/server/utils/agents/aibitat/plugins/filesystem/lib.js new file mode 100644 index 0000000..faf1e5d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/lib.js @@ -0,0 +1,787 @@ +const fs = require("fs/promises"); +const path = require("path"); +const os = require("os"); +const { randomBytes } = require("crypto"); +const { createTwoFilesPatch } = require("diff"); +const { humanFileSize } = require("../../../../helpers"); + +/** + * Manages filesystem operations with security constraints. + * Ensures all file operations stay within allowed directories. + */ +class FilesystemManager { + static FILE_READ_CHUNK_SIZE = 1024; + static CONTEXT_RESERVE_RATIO = 0.25; + static IMAGE_EXTENSIONS = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ]; + static IMAGE_MIME_TYPES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + }; + + /** + * Checks if the filesystem tool is available. + * The filesystem tool is only available when running in a docker container + * or in development mode. + * @returns {boolean} True if the tool is available + */ + isToolAvailable() { + if (process.env.NODE_ENV === "development") return true; + return process.env.ANYTHING_LLM_RUNTIME === "docker"; + } + + #allowedDirectories = []; + #isInitialized = false; + + /** + * Gets the default filesystem root path. + * @returns {string} The default filesystem root path + */ + #getDefaultFilesystemRoot() { + const storageRoot = + process.env.STORAGE_DIR || + path.resolve(__dirname, "../../../../../storage"); + return path.join(storageRoot, "anythingllm-fs"); + } + + /** + * Initializes the filesystem with default or configured directories. + * @param {string[]} [directories] - Optional array of directories to allow + * @returns {Promise} The initialized allowed directories + */ + async #initializeFilesystem(directories = null) { + if (directories && directories.length > 0) { + this.#allowedDirectories = directories.map((dir) => + path.resolve(this.#expandHome(dir)) + ); + } else { + const defaultRoot = this.#getDefaultFilesystemRoot(); + this.#allowedDirectories = [defaultRoot]; + } + + for (const dir of this.#allowedDirectories) { + try { + await fs.mkdir(dir, { recursive: true }); + } catch (error) { + console.error( + `Warning: Could not create directory ${dir}: ${error.message}` + ); + } + } + + this.#isInitialized = true; + return this.#allowedDirectories; + } + + /** + * Expands home directory tildes in paths. + * @param {string} filepath - The path to expand + * @returns {string} Expanded path + */ + #expandHome(filepath) { + if (filepath.startsWith("~/") || filepath === "~") { + return path.join(os.homedir(), filepath.slice(1)); + } + return filepath; + } + + /** + * Normalizes a path by standardizing format. + * @param {string} p - The path to normalize + * @returns {string} Normalized path + */ + #normalizePath(p) { + p = p.trim().replace(/^["']|["']$/g, ""); + if (p.startsWith("/")) + return p.replace(/\/+/g, "/").replace(/(? { + const normalizedDir = this.#normalizeAndValidatePath(dir); + if (!normalizedDir) return false; + + if (normalizedPath === normalizedDir) return true; + return normalizedPath.startsWith(normalizedDir + path.sep); + }); + } + + /** + * Resolves a relative path against allowed directories. + * @param {string} relativePath - The relative path to resolve + * @returns {string} The resolved absolute path + */ + #resolveRelativePathAgainstAllowedDirectories(relativePath) { + if (this.#allowedDirectories.length === 0) { + return path.resolve(process.cwd(), relativePath); + } + + for (const allowedDir of this.#allowedDirectories) { + const candidate = path.resolve(allowedDir, relativePath); + const normalizedCandidate = this.#normalizePath(candidate); + + if ( + this.#isPathWithinAllowedDirectories( + normalizedCandidate, + this.#allowedDirectories + ) + ) { + return candidate; + } + } + + return path.resolve(this.#allowedDirectories[0], relativePath); + } + + /** + * Normalizes line endings to Unix-style. + * @param {string} text - Text to normalize + * @returns {string} Text with normalized line endings + */ + #normalizeLineEndings(text) { + return text.replace(/\r\n/g, "\n"); + } + + /** + * Writes content to a file atomically using a temp file and rename. + * @param {string} filePath - Path to the file + * @param {string} content - Content to write + * @returns {Promise} + */ + async #atomicWrite(filePath, content) { + const tempPath = `${filePath}.${randomBytes(16).toString("hex")}.tmp`; + try { + await fs.writeFile(tempPath, content, "utf-8"); + await fs.rename(tempPath, filePath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch {} + throw error; + } + } + + /** + * Reads file content with collector API fallback. + * @param {string} filePath - Path to the file + * @param {Function} processContent - Function to process parsed content + * @param {Function} rawFallback - Function to call if collector fails + * @returns {Promise} Processed content + */ + async #withCollectorFallback(filePath, processContent, rawFallback) { + const parseResult = await this.#parseFileWithCollector(filePath); + if (parseResult.parsed && parseResult.content) { + return processContent(parseResult.content); + } + return rawFallback(); + } + + /** + * Creates a unified diff between two strings. + * @param {string} originalContent - Original content + * @param {string} newContent - New content + * @param {string} filepath - File path for diff header + * @returns {string} Unified diff string + */ + #createUnifiedDiff(originalContent, newContent, filepath = "file") { + const normalizedOriginal = this.#normalizeLineEndings(originalContent); + const normalizedNew = this.#normalizeLineEndings(newContent); + + return createTwoFilesPatch( + filepath, + filepath, + normalizedOriginal, + normalizedNew, + "original", + "modified" + ); + } + + /** + * Reads file content as text using direct file read. + * @param {string} filePath - Path to the file + * @param {string} encoding - File encoding + * @returns {Promise} File content + */ + async #readFileContentRaw(filePath, encoding = "utf-8") { + return await fs.readFile(filePath, encoding); + } + + /** + * Parses a file using the collector API to extract text content. + * @param {string} filePath - Absolute path to the file + * @returns {Promise<{content: string, parsed: boolean}>} Parsed content + */ + async #parseFileWithCollector(filePath) { + try { + const { CollectorApi } = require("../../../../collectorApi"); + const collectorApi = new CollectorApi(); + + const isOnline = await collectorApi.online(); + if (!isOnline) { + return { + content: null, + parsed: false, + error: "Collector service offline", + }; + } + + const filename = path.basename(filePath); + const result = await collectorApi.parseDocument(filename, { + absolutePath: filePath, + }); + + if (!result || !result.success) { + return { + content: null, + parsed: false, + error: result?.reason || "Failed to parse document", + }; + } + + if (result.content) return { content: result.content, parsed: true }; + if (result.documents && result.documents.length > 0) { + const content = result.documents + .map((doc) => doc.pageContent || doc.content || "") + .filter(Boolean) + .join("\n\n"); + if (content) return { content, parsed: true }; + } + + return { content: null, parsed: false, error: "No content in response" }; + } catch (error) { + return { content: null, parsed: false, error: error.message }; + } + } + + /** + * Gets the last N lines of a file using raw file operations. + * @param {string} filePath - Path to the file + * @param {number} numLines - Number of lines to return + * @returns {Promise} Last N lines of the file + */ + async #tailFileRaw(filePath, numLines) { + const stats = await fs.stat(filePath); + const fileSize = stats.size; + + if (fileSize === 0) return ""; + + const fileHandle = await fs.open(filePath, "r"); + try { + const lines = []; + let position = fileSize; + const chunk = Buffer.alloc(FilesystemManager.FILE_READ_CHUNK_SIZE); + let linesFound = 0; + let remainingText = ""; + + while (position > 0 && linesFound < numLines) { + const size = Math.min(FilesystemManager.FILE_READ_CHUNK_SIZE, position); + position -= size; + + const { bytesRead } = await fileHandle.read(chunk, 0, size, position); + if (!bytesRead) break; + + const readData = chunk.slice(0, bytesRead).toString("utf-8"); + const chunkText = readData + remainingText; + + const chunkLines = this.#normalizeLineEndings(chunkText).split("\n"); + + if (position > 0) { + remainingText = chunkLines[0]; + chunkLines.shift(); + } + + for ( + let i = chunkLines.length - 1; + i >= 0 && linesFound < numLines; + i-- + ) { + lines.unshift(chunkLines[i]); + linesFound++; + } + } + + return lines.join("\n"); + } finally { + await fileHandle.close(); + } + } + + /** + * Gets the first N lines of a file using raw file operations. + * @param {string} filePath - Path to the file + * @param {number} numLines - Number of lines to return + * @returns {Promise} First N lines of the file + */ + async #headFileRaw(filePath, numLines) { + const fileHandle = await fs.open(filePath, "r"); + try { + const lines = []; + let buffer = ""; + let bytesRead = 0; + const chunk = Buffer.alloc(FilesystemManager.FILE_READ_CHUNK_SIZE); + + while (lines.length < numLines) { + const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + buffer += chunk.slice(0, result.bytesRead).toString("utf-8"); + + const newLineIndex = buffer.lastIndexOf("\n"); + if (newLineIndex !== -1) { + const completeLines = buffer.slice(0, newLineIndex).split("\n"); + buffer = buffer.slice(newLineIndex + 1); + for (const line of completeLines) { + lines.push(line); + if (lines.length >= numLines) break; + } + } + } + + if (buffer.length > 0 && lines.length < numLines) { + lines.push(buffer); + } + + return lines.join("\n"); + } finally { + await fileHandle.close(); + } + } + + /** + * Gets the current allowed directories. + * @returns {string[]} Array of allowed directory paths + */ + getAllowedDirectories() { + return [...this.#allowedDirectories]; + } + + /** + * Ensures the filesystem is initialized before use. + * @returns {Promise} + */ + async ensureInitialized() { + if (!this.#isInitialized) await this.#initializeFilesystem(); + } + + /** + * Validates a path for security, ensuring it's within allowed directories. + * @param {string} requestedPath - The path to validate + * @returns {Promise} The validated absolute path + * @throws {Error} If path is outside allowed directories + */ + async validatePath(requestedPath) { + await this.ensureInitialized(); + const expandedPath = this.#expandHome(requestedPath); + const absolute = path.isAbsolute(expandedPath) + ? path.resolve(expandedPath) + : this.#resolveRelativePathAgainstAllowedDirectories(expandedPath); + + const normalizedRequested = this.#normalizePath(absolute); + + const isAllowed = this.#isPathWithinAllowedDirectories( + normalizedRequested, + this.#allowedDirectories + ); + if (!isAllowed) { + console.log( + `[validatePath] Access denied - path outside allowed directories: ${absolute} not in ${this.#allowedDirectories.join(", ")}` + ); + throw new Error(`Access denied - path outside allowed directories.`); + } + + try { + const realPath = await fs.realpath(absolute); + const normalizedReal = this.#normalizePath(realPath); + if ( + !this.#isPathWithinAllowedDirectories( + normalizedReal, + this.#allowedDirectories + ) + ) { + console.log( + `[validatePath] Access denied - symlink target outside allowed directories: ${realPath} not in ${this.#allowedDirectories.join(", ")}` + ); + throw new Error( + `Access denied - symlink target outside allowed directories.` + ); + } + return realPath; + } catch (error) { + if (error.code === "ENOENT") { + const parentDir = path.dirname(absolute); + try { + const realParentPath = await fs.realpath(parentDir); + const normalizedParent = this.#normalizePath(realParentPath); + if ( + !this.#isPathWithinAllowedDirectories( + normalizedParent, + this.#allowedDirectories + ) + ) { + console.log( + `[validatePath] Access denied - parent directory outside allowed directories: ${realParentPath} not in ${this.#allowedDirectories.join(", ")}` + ); + throw new Error( + `Access denied - parent directory outside allowed directories.` + ); + } + return absolute; + } catch { + throw new Error(`Parent directory does not exist: ${parentDir}`); + } + } + throw error; + } + } + + /** + * Gets detailed file statistics. + * @param {string} filePath - Path to the file + * @returns {Promise} File statistics + */ + async getFileStats(filePath) { + const stats = await fs.stat(filePath); + return { + size: stats.size, + sizeFormatted: humanFileSize(stats.size, true, 2), + created: stats.birthtime.toISOString(), + modified: stats.mtime.toISOString(), + accessed: stats.atime.toISOString(), + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + permissions: stats.mode.toString(8).slice(-3), + }; + } + + /** + * Reads file content, using the collector API to parse binary files. + * @param {string} filePath - Path to the file + * @param {string} encoding - File encoding (default: utf-8) + * @returns {Promise} File content + */ + async readFileContent(filePath, encoding = "utf-8") { + return this.#withCollectorFallback( + filePath, + (content) => content, + () => this.#readFileContentRaw(filePath, encoding) + ); + } + + /** + * Writes content to a file securely. + * @param {string} filePath - Path to the file + * @param {string} content - Content to write + * @returns {Promise} + */ + async writeFileContent(filePath, content) { + try { + await fs.writeFile(filePath, content, { encoding: "utf-8", flag: "wx" }); + } catch (error) { + if (error.code === "EEXIST") { + await this.#atomicWrite(filePath, content); + } else { + throw error; + } + } + } + + /** + * Applies edits to a file. + * @param {string} filePath - Path to the file + * @param {Array<{oldText: string, newText: string}>} edits - Array of edits + * @param {boolean} dryRun - If true, only preview changes + * @returns {Promise} Diff of changes + */ + async applyFileEdits(filePath, edits, dryRun = false) { + const content = this.#normalizeLineEndings( + await fs.readFile(filePath, "utf-8") + ); + + let modifiedContent = content; + for (const edit of edits) { + const normalizedOld = this.#normalizeLineEndings(edit.oldText); + const normalizedNew = this.#normalizeLineEndings(edit.newText); + + if (modifiedContent.includes(normalizedOld)) { + modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); + continue; + } + + const oldLines = normalizedOld.split("\n"); + const contentLines = modifiedContent.split("\n"); + let matchFound = false; + + for (let i = 0; i <= contentLines.length - oldLines.length; i++) { + const potentialMatch = contentLines.slice(i, i + oldLines.length); + + const isMatch = oldLines.every((oldLine, j) => { + const contentLine = potentialMatch[j]; + return oldLine.trim() === contentLine.trim(); + }); + + if (isMatch) { + const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ""; + const newLines = normalizedNew.split("\n").map((line, j) => { + if (j === 0) return originalIndent + line.trimStart(); + const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ""; + const newIndent = line.match(/^\s*/)?.[0] || ""; + if (oldIndent && newIndent) { + const relativeIndent = newIndent.length - oldIndent.length; + return ( + originalIndent + + " ".repeat(Math.max(0, relativeIndent)) + + line.trimStart() + ); + } + return line; + }); + + contentLines.splice(i, oldLines.length, ...newLines); + modifiedContent = contentLines.join("\n"); + matchFound = true; + break; + } + } + + if (!matchFound) { + throw new Error( + `Could not find exact match for edit:\n${edit.oldText}` + ); + } + } + + const diffResult = this.#createUnifiedDiff( + content, + modifiedContent, + filePath + ); + + let numBackticks = 3; + while (diffResult.includes("`".repeat(numBackticks))) { + numBackticks++; + } + const formattedDiff = `${"`".repeat(numBackticks)}diff\n${diffResult}${"`".repeat(numBackticks)}\n\n`; + + if (!dryRun) { + await this.#atomicWrite(filePath, modifiedContent); + } + + return formattedDiff; + } + + /** + * Gets the last N lines of a file. + * @param {string} filePath - Path to the file + * @param {number} numLines - Number of lines to return + * @returns {Promise} Last N lines of the file + */ + async tailFile(filePath, numLines) { + return this.#withCollectorFallback( + filePath, + (content) => { + const lines = this.#normalizeLineEndings(content).split("\n"); + return lines.slice(-numLines).join("\n"); + }, + () => this.#tailFileRaw(filePath, numLines) + ); + } + + /** + * Gets the first N lines of a file. + * @param {string} filePath - Path to the file + * @param {number} numLines - Number of lines to return + * @returns {Promise} First N lines of the file + */ + async headFile(filePath, numLines) { + return this.#withCollectorFallback( + filePath, + (content) => { + const lines = this.#normalizeLineEndings(content).split("\n"); + return lines.slice(0, numLines).join("\n"); + }, + () => this.#headFileRaw(filePath, numLines) + ); + } + + /** + * Searches for files matching a glob pattern. + * @param {string} rootPath - Root path to search from + * @param {string} pattern - Glob pattern to match + * @param {Object} options - Search options + * @param {string[]} options.excludePatterns - Patterns to exclude + * @returns {Promise} Array of matching file paths + */ + async searchFilesWithGlob(rootPath, pattern, options = {}) { + const minimatch = require("minimatch"); + const { excludePatterns = [] } = options; + const results = []; + const matchOptions = { dot: true, nocase: true }; + + const search = async (currentPath) => { + const entries = await fs.readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + + try { + await this.validatePath(fullPath); + + const relativePath = path.relative(rootPath, fullPath); + const shouldExclude = excludePatterns.some( + (excludePattern) => + minimatch(relativePath, excludePattern, matchOptions) || + minimatch(entry.name, excludePattern, matchOptions) + ); + + if (shouldExclude) continue; + + const matchesPath = minimatch(relativePath, pattern, matchOptions); + const matchesName = minimatch(entry.name, pattern, matchOptions); + + if (matchesPath || matchesName) { + results.push(fullPath); + } + + if (entry.isDirectory()) { + await search(fullPath); + } + } catch { + continue; + } + } + }; + + await search(rootPath); + return results; + } + + /** + * Truncates content if it exceeds the model's context limit. + * @param {string} content - The content to potentially truncate + * @param {object} aibitat - The aibitat instance with model/provider info + * @param {string} [truncationMessage] - Optional custom message + * @returns {{content: string, wasTruncated: boolean}} The content and truncation status + */ + truncateContentForContext(content, aibitat, truncationMessage = null) { + const { TokenManager } = require("../../../../helpers/tiktoken"); + const Provider = require("../../providers/ai-provider"); + + const contextLimit = Provider.contextLimit(aibitat.provider, aibitat.model); + const reserveForResponse = Math.floor( + contextLimit * FilesystemManager.CONTEXT_RESERVE_RATIO + ); + const maxTokens = contextLimit - reserveForResponse; + + const tokenManager = new TokenManager(aibitat.model); + const tokenCount = tokenManager.countFromString(content); + + if (tokenCount <= maxTokens) { + return { content, wasTruncated: false }; + } + + const avgCharsPerToken = content.length / tokenCount; + let targetChars = Math.floor(maxTokens * avgCharsPerToken); + let truncated = content.slice(0, targetChars); + + const lastNewline = truncated.lastIndexOf("\n"); + if (lastNewline > targetChars * 0.8) { + truncated = truncated.slice(0, lastNewline); + } + + const defaultMessage = + "[Content truncated - exceeds context limit. Consider reading smaller portions.]"; + const message = truncationMessage || defaultMessage; + + return { + content: truncated + "\n\n" + message, + wasTruncated: true, + }; + } + + /** + * Check if a file path points to an image file. + * @param {string} filePath - Path to the file + * @returns {boolean} True if the file is an image + */ + isImageFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return FilesystemManager.IMAGE_EXTENSIONS.includes(ext); + } + + /** + * Get the MIME type for an image file. + * @param {string} filePath - Path to the file + * @returns {string|null} MIME type or null if not an image + */ + getImageMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return FilesystemManager.IMAGE_MIME_TYPES[ext] || null; + } + + /** + * Read an image file and return it as an attachment object. + * @param {string} filePath - Validated absolute path to the image file + * @returns {Promise<{name: string, mime: string, contentString: string}|null>} Attachment object or null on error + */ + async readImageAsAttachment(filePath) { + try { + const mime = this.getImageMimeType(filePath); + if (!mime) return null; + + const buffer = await fs.readFile(filePath); + const base64 = buffer.toString("base64"); + const filename = path.basename(filePath); + + return { + name: filename, + mime, + contentString: `data:${mime};base64,${base64}`, + }; + } catch (error) { + console.error(`Error reading image file ${filePath}:`, error.message); + return null; + } + } +} + +module.exports = new FilesystemManager(); diff --git a/server/utils/agents/aibitat/plugins/filesystem/list-directory.js b/server/utils/agents/aibitat/plugins/filesystem/list-directory.js new file mode 100644 index 0000000..0e78585 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/list-directory.js @@ -0,0 +1,175 @@ +const fs = require("fs/promises"); +const path = require("path"); +const filesystem = require("./lib.js"); +const { humanFileSize } = require("../../../../helpers"); + +module.exports.FilesystemListDirectory = { + name: "filesystem-list-directory", + plugin: function () { + return { + name: "filesystem-list-directory", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get a detailed listing of all files and directories in a specified path. " + + "Results clearly distinguish between files and directories with [FILE] and [DIR] " + + "prefixes. Optionally includes file sizes and can sort by name or size. " + + "This tool is essential for understanding directory structure and " + + "finding specific files within a directory. Only works within allowed directories.", + examples: [ + { + prompt: "List all files in the current folder", + call: JSON.stringify({ path: "." }), + }, + { + prompt: "Show me the contents of the src directory with sizes", + call: JSON.stringify({ path: "src", includeSizes: true }), + }, + { + prompt: "List files in downloads sorted by size", + call: JSON.stringify({ + path: "downloads", + includeSizes: true, + sortBy: "size", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: "The path of the directory to list.", + }, + includeSizes: { + type: "boolean", + default: false, + description: "If true, include file sizes in the listing.", + }, + sortBy: { + type: "string", + enum: ["name", "size"], + default: "name", + description: "Sort entries by name or size.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + handler: async function ({ + path: dirPath = "", + includeSizes = false, + sortBy = "name", + }) { + try { + this.super.handlerProps.log( + `Using the filesystem-list-directory tool.` + ); + + const validPath = await filesystem.validatePath(dirPath); + + this.super.introspect( + `${this.caller}: Listing directory ${dirPath}` + ); + + const entries = await fs.readdir(validPath, { + withFileTypes: true, + }); + + if (!includeSizes) { + // Simple listing without sizes + const formatted = entries + .sort((a, b) => a.name.localeCompare(b.name)) + .map( + (entry) => + `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}` + ) + .join("\n"); + + this.super.introspect( + `Found ${entries.length} items in ${dirPath}` + ); + + return formatted || "Directory is empty"; + } + + // Detailed listing with sizes + const detailedEntries = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(validPath, entry.name); + try { + const stats = await fs.lstat(entryPath); + return { + name: entry.name, + isDirectory: entry.isDirectory(), + size: stats.size, + mtime: stats.mtime, + }; + } catch { + return { + name: entry.name, + isDirectory: entry.isDirectory(), + size: 0, + mtime: new Date(0), + }; + } + }) + ); + + // Sort entries + const sortedEntries = [...detailedEntries].sort((a, b) => { + if (sortBy === "size") { + return b.size - a.size; + } + return a.name.localeCompare(b.name); + }); + + // Format output + const formattedEntries = sortedEntries.map( + (entry) => + `${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${ + entry.isDirectory + ? "" + : humanFileSize(entry.size, true, 2).padStart(10) + }` + ); + + // Add summary + const totalFiles = detailedEntries.filter( + (e) => !e.isDirectory + ).length; + const totalDirs = detailedEntries.filter( + (e) => e.isDirectory + ).length; + const totalSize = detailedEntries.reduce( + (sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), + 0 + ); + + const summary = [ + "", + `Total: ${totalFiles} files, ${totalDirs} directories`, + `Combined size: ${humanFileSize(totalSize, true, 2)}`, + ]; + + this.super.introspect( + `Found ${entries.length} items (${totalFiles} files, ${totalDirs} directories) in ${dirPath}` + ); + + return [...formattedEntries, ...summary].join("\n"); + } catch (e) { + this.super.handlerProps.log( + `filesystem-list-directory error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error listing directory: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/move-file.js b/server/utils/agents/aibitat/plugins/filesystem/move-file.js new file mode 100644 index 0000000..6080726 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/move-file.js @@ -0,0 +1,95 @@ +const fs = require("fs/promises"); +const filesystem = require("./lib.js"); + +module.exports.FilesystemMoveFile = { + name: "filesystem-move-file", + plugin: function () { + return { + name: "filesystem-move-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Move or rename files and directories. Can move files between directories " + + "and rename them in a single operation. If the destination exists, the " + + "operation will fail. Works across different directories and can be used " + + "for simple renaming within the same directory. Both source and destination must be within allowed directories.", + examples: [ + { + prompt: "Rename config.json to config.backup.json", + call: JSON.stringify({ + source: "config.json", + destination: "config.backup.json", + }), + }, + { + prompt: "Move the report file to the archive folder", + call: JSON.stringify({ + source: "reports/monthly.pdf", + destination: "archive/monthly-march.pdf", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + source: { + type: "string", + description: "The path of the file or directory to move.", + }, + destination: { + type: "string", + description: + "The destination path where the file or directory should be moved to.", + }, + }, + required: ["source", "destination"], + additionalProperties: false, + }, + handler: async function ({ source = "", destination = "" }) { + try { + this.super.handlerProps.log( + `Using the filesystem-move-file tool.` + ); + + const validSourcePath = await filesystem.validatePath(source); + const validDestPath = await filesystem.validatePath(destination); + + this.super.introspect( + `${this.caller}: Moving ${source} to ${destination}` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { source, destination }, + description: "Move a file or directory to a new location", + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + await fs.rename(validSourcePath, validDestPath); + this.super.introspect( + `Successfully moved ${source} to ${destination}` + ); + return `Successfully moved ${source} to ${destination}`; + } catch (e) { + this.super.handlerProps.log( + `filesystem-move-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error moving file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/read-multiple-files.js b/server/utils/agents/aibitat/plugins/filesystem/read-multiple-files.js new file mode 100644 index 0000000..1088b18 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/read-multiple-files.js @@ -0,0 +1,157 @@ +const path = require("path"); +const filesystem = require("./lib.js"); + +module.exports.FilesystemReadMultipleFiles = { + name: "filesystem-read-multiple-files", + plugin: function () { + return { + name: "filesystem-read-multiple-files", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read multiple files at once when you know their exact paths. " + + "Supports many file types: text, code, PDFs, Word docs, audio/video (transcribed to text), and more. " + + "Image files (png, jpg, jpeg, gif, webp, svg, bmp) are automatically attached for you to view and analyze visually. " + + "IMPORTANT: If you don't know the file paths, use 'filesystem-search-files' first " + + "with 'includeFileContents: true' to find and read files in one step. " + + "Each file's content is returned with its path. Failed reads won't stop the operation.", + examples: [ + { + prompt: "Read both the package.json and README.md files", + call: JSON.stringify({ paths: ["package.json", "README.md"] }), + }, + { + prompt: "Compare the config files in dev and prod folders", + call: JSON.stringify({ + paths: ["dev/config.json", "prod/config.json"], + }), + }, + { + prompt: "Show me all the screenshots", + call: JSON.stringify({ + paths: ["screenshot1.png", "screenshot2.png"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + paths: { + type: "array", + items: { type: "string" }, + minItems: 1, + description: + "Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.", + }, + }, + required: ["paths"], + additionalProperties: false, + }, + handler: async function ({ paths = [] }) { + try { + this.super.handlerProps.log( + `Using the filesystem-read-multiple-files tool.` + ); + + if (!Array.isArray(paths) || paths.length === 0) { + return "Error: At least one file path must be provided."; + } + + this.super.introspect( + `${this.caller}: Reading ${paths.length} files` + ); + + const results = await Promise.all( + paths.map(async (filePath) => { + try { + const validPath = await filesystem.validatePath(filePath); + const filename = path.basename(validPath); + + if (filesystem.isImageFile(validPath)) { + const attachment = + await filesystem.readImageAsAttachment(validPath); + if (attachment) { + this.super.addToolAttachment?.(attachment); + return { + filePath, + content: `[Image "${filename}" attached for viewing]`, + success: true, + isImage: true, + }; + } + return { + filePath, + content: `Error - Could not read image file`, + success: false, + }; + } + + const content = await filesystem.readFileContent(validPath); + + this.super.addCitation?.({ + id: `fs-${Buffer.from(validPath).toString("base64url").slice(0, 32)}`, + title: filename, + text: content, + chunkSource: validPath, + score: null, + }); + + return { filePath, content, success: true }; + } catch (error) { + return { + filePath, + content: `Error - ${error.message}`, + success: false, + }; + } + }) + ); + + const combinedContent = results + .map((r) => + r.success + ? `${r.filePath}:\n${r.content}\n` + : `${r.filePath}: ${r.content}` + ) + .join("\n---\n"); + + const { content: finalContent, wasTruncated } = + filesystem.truncateContentForContext( + combinedContent, + this.super, + "[Content truncated - combined files exceed context limit. Consider reading fewer files at once.]" + ); + + if (wasTruncated) { + this.super.introspect( + `${this.caller}: Combined content was truncated to fit context limit` + ); + } + + const imageCount = results.filter((r) => r.isImage).length; + const textCount = results.filter( + (r) => r.success && !r.isImage + ).length; + let introspectMsg = `Successfully processed ${paths.length} files`; + if (imageCount > 0) { + introspectMsg += ` (${imageCount} image${imageCount > 1 ? "s" : ""} attached, ${textCount} text file${textCount !== 1 ? "s" : ""} read)`; + } + this.super.introspect(introspectMsg); + + return finalContent; + } catch (e) { + this.super.handlerProps.log( + `filesystem-read-multiple-files error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error reading files: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/read-text-file.js b/server/utils/agents/aibitat/plugins/filesystem/read-text-file.js new file mode 100644 index 0000000..a575812 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/read-text-file.js @@ -0,0 +1,142 @@ +const path = require("path"); +const filesystem = require("./lib.js"); + +module.exports.FilesystemReadTextFile = { + name: "filesystem-read-text-file", + plugin: function () { + return { + name: "filesystem-read-text-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read the contents of a file from the file system. " + + "Supports many file types: text, code, PDFs, Word docs, audio/video (transcribed to text), and more. " + + "Image files (png, jpg, jpeg, gif, webp, svg, bmp) are automatically attached for you to view and analyze visually. " + + "IMPORTANT: Only use this tool when you know the exact file path. " + + "If you don't know where a file is located, use 'filesystem-search-files' first " + + "to find it (e.g., search for '*.csv' or the filename). " + + "Use the 'head' parameter to read only the first N lines, or 'tail' for the last N lines (text files only). " + + "Only works within allowed directories.", + examples: [ + { + prompt: "Read the contents of config.json", + call: JSON.stringify({ path: "config.json" }), + }, + { + prompt: "Show me the last 50 lines of the log file", + call: JSON.stringify({ path: "logs/app.log", tail: 50 }), + }, + { + prompt: "Read just the first 10 lines of README.md", + call: JSON.stringify({ path: "README.md", head: 10 }), + }, + { + prompt: "Show me the screenshot.png image", + call: JSON.stringify({ path: "screenshot.png" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: + "The path to the file to read. Can be relative to the allowed directory or absolute within allowed directories.", + }, + head: { + type: "number", + description: + "If provided, returns only the first N lines of the file.", + }, + tail: { + type: "number", + description: + "If provided, returns only the last N lines of the file.", + }, + }, + required: ["path"], + additionalProperties: false, + }, + handler: async function ({ path: filePath = "", head, tail }) { + try { + this.super.handlerProps.log( + `Using the filesystem-read-text-file tool.` + ); + + if (head && tail) { + return "Error: Cannot specify both head and tail parameters simultaneously."; + } + + const validPath = await filesystem.validatePath(filePath); + + if (filesystem.isImageFile(validPath)) { + this.super.introspect( + `${this.caller}: Detected image file ${filePath}, attaching for viewing` + ); + const attachment = + await filesystem.readImageAsAttachment(validPath); + if (attachment) { + this.super.addToolAttachment?.(attachment); + const filename = path.basename(validPath); + return `Image file "${filename}" has been attached and is now visible in the conversation. You can describe what you see in the image.`; + } + return `Error: Could not read image file "${path.basename(validPath)}"`; + } + + this.super.introspect(`${this.caller}: Reading file ${filePath}`); + + let content; + if (tail) { + content = await filesystem.tailFile(validPath, tail); + this.super.introspect( + `Retrieved last ${tail} lines of ${filePath}` + ); + } else if (head) { + content = await filesystem.headFile(validPath, head); + this.super.introspect( + `Retrieved first ${head} lines of ${filePath}` + ); + } else { + content = await filesystem.readFileContent(validPath); + this.super.introspect(`Successfully read ${filePath}`); + } + + const { content: finalContent, wasTruncated } = + filesystem.truncateContentForContext( + content, + this.super, + "[Content truncated - file exceeds context limit. Use head/tail parameters to read specific portions.]" + ); + + if (wasTruncated) { + this.super.introspect( + `${this.caller}: File content was truncated to fit context limit` + ); + } + + const filename = path.basename(validPath); + this.super.addCitation?.({ + id: `fs-${Buffer.from(validPath).toString("base64url").slice(0, 32)}`, + title: filename, + text: finalContent, + chunkSource: validPath, + score: null, + }); + + return finalContent; + } catch (e) { + this.super.handlerProps.log( + `filesystem-read-text-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error reading file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/filesystem/search-files.js b/server/utils/agents/aibitat/plugins/filesystem/search-files.js new file mode 100644 index 0000000..50e9cea --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/search-files.js @@ -0,0 +1,469 @@ +const path = require("path"); +const filesystem = require("./lib.js"); +const { safeJsonParse } = require("../../../../http/index.js"); + +module.exports.FilesystemSearchFiles = { + name: "filesystem-search-files", + plugin: function () { + return { + name: "filesystem-search-files", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Search for files by name or content. USE THIS FIRST when you need to find a file " + + "but don't know its exact location. " + + "Two modes: 'glob' matches file paths/names (e.g., '*.csv', 'config'), " + + "'content' searches inside files using regex (like grep). " + + "Set 'includeFileContents: true' to also read and return the full contents of matching files " + + "in a single operation (useful when you need to find AND read files). " + + "Simple patterns like 'sales.csv' automatically match files containing that string anywhere.", + examples: [ + { + prompt: "Find all JavaScript files", + call: JSON.stringify({ + pattern: "**/*.js", + mode: "glob", + includeFileContents: false, + }), + }, + { + prompt: "Find all CSV files", + call: JSON.stringify({ + pattern: "*.csv", + mode: "glob", + }), + }, + { + prompt: "Search for error handling code", + call: JSON.stringify({ + pattern: "catch.*error", + mode: "content", + filePattern: "*.js", + includeFileContents: true, + maxFilesToRead: 3, + }), + }, + { + prompt: "Find the config file and show its contents", + call: JSON.stringify({ + pattern: "config", + mode: "glob", + includeFileContents: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + pattern: { + type: "string", + description: + "For glob mode: a glob pattern to match file paths. " + + "For content mode: the text or regex pattern to search for in file contents.", + }, + mode: { + type: "string", + enum: ["glob", "content"], + default: "glob", + description: + "Search mode: 'glob' for matching file paths, 'content' for searching file contents.", + }, + filePattern: { + type: "string", + description: + "For content mode only: glob pattern to filter which files to search (e.g., '*.js', '*.{ts,tsx}').", + }, + excludePatterns: { + type: "array", + items: { type: "string" }, + default: [], + description: + "Patterns to exclude from search (e.g., 'node_modules', '*.log').", + }, + caseSensitive: { + type: "boolean", + default: true, + description: + "For content mode: whether the search should be case-sensitive.", + }, + maxResults: { + type: "number", + default: 100, + description: "Maximum number of results to return.", + }, + includeFileContents: { + type: "boolean", + default: false, + description: + "If true, read and return the full contents of matching files (limited by maxFilesToRead). " + + "Useful when you need to analyze files, not just find them.", + }, + maxFilesToRead: { + type: "number", + default: 5, + description: + "When includeFileContents is true, maximum number of files to read contents from.", + }, + }, + required: ["pattern"], + additionalProperties: false, + }, + handler: async function ({ + pattern = "", + mode = "glob", + filePattern = "", + excludePatterns = [], + caseSensitive = true, + maxResults = 100, + includeFileContents = false, + maxFilesToRead = 5, + }) { + try { + this.super.handlerProps.log( + `Using the filesystem-search-files tool.` + ); + + await filesystem.ensureInitialized(); + const allowedDirs = filesystem.getAllowedDirectories(); + + if (allowedDirs.length === 0) { + return "Error: No allowed directories configured"; + } + + if (mode === "glob") { + const allResults = []; + const seenPaths = new Set(); + + // If pattern has no glob characters, convert to wildcard patterns + // e.g., "sales" matches files containing "sales" anywhere in the name + const hasGlobChars = /[*?[\]{}]/.test(pattern); + const effectivePatterns = hasGlobChars + ? [pattern] + : [`*${pattern}*`, `**/*${pattern}*`]; + + const patternNote = + effectivePatterns.length > 1 || + effectivePatterns[0] !== pattern + ? ` (using pattern: ${effectivePatterns.join(" or ")})` + : ""; + this.super.introspect( + `${this.caller}: Searching for "${pattern}"${patternNote} in ${allowedDirs.length} allowed director${allowedDirs.length === 1 ? "y" : "ies"}` + ); + + for (const dir of allowedDirs) { + try { + const { files } = searchFilesWithRipgrepGlob({ + searchPath: dir, + patterns: effectivePatterns, + excludePatterns, + maxResults: maxResults - allResults.length, + }); + + for (const filePath of files) { + if (!seenPaths.has(filePath)) { + seenPaths.add(filePath); + allResults.push(filePath); + } + } + } catch { + // Skip directories that fail (e.g., don't exist) + } + } + + const limitedResults = allResults.slice(0, maxResults); + this.super.introspect( + `Found ${allResults.length} matching files${allResults.length > maxResults ? ` (showing first ${maxResults})` : ""}` + ); + + if (limitedResults.length === 0) return "No matches found"; + + if (includeFileContents) { + return await readMatchingFileContents.call( + this, + limitedResults, + maxFilesToRead + ); + } + + return limitedResults.join("\n"); + } + + // Content search mode using ripgrep across all allowed directories + this.super.introspect( + `${this.caller}: Searching for "${pattern}" in file contents across ${allowedDirs.length} allowed director${allowedDirs.length === 1 ? "y" : "ies"}` + ); + + const allResults = []; + const seenKeys = new Set(); + + for (const dir of allowedDirs) { + try { + const results = searchWithRipgrep({ + searchPath: dir, + pattern, + filePattern, + excludePatterns, + caseSensitive, + maxResults: maxResults - allResults.length, + }); + + for (const result of results) { + const key = `${result.file}:${result.line}`; + if (!seenKeys.has(key)) { + seenKeys.add(key); + allResults.push(result); + } + } + + if (allResults.length >= maxResults) break; + } catch { + // Skip directories that fail + } + } + + this.super.introspect( + `Found ${allResults.length} matches${allResults.length > maxResults ? ` (showing first ${maxResults})` : ""}` + ); + + if (includeFileContents) { + const uniqueFiles = [...new Set(allResults.map((r) => r.file))]; + return await readMatchingFileContents.call( + this, + uniqueFiles, + maxFilesToRead + ); + } + + return formatSearchResults(allResults, maxResults); + } catch (e) { + this.super.handlerProps.log( + `filesystem-search-files error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error searching files: ${e.message}`; + } + }, + }); + }, + }; + }, +}; + +/** + * Search for files by glob pattern using ripgrep (fast file listing). + * @returns {{ files: string[], method: string }} + */ +function searchFilesWithRipgrepGlob({ + searchPath, + patterns, + excludePatterns = [], + maxResults = 100, +}) { + const { spawnSync } = require("child_process"); + let rgPath; + try { + ({ rgPath } = require("@vscode/ripgrep")); + } catch { + throw new Error("@vscode/ripgrep not installed"); + } + + // Build ripgrep arguments for file listing + const args = [ + "--files", // List files instead of searching content + "--no-ignore", // Search all files, even those in .gitignore + ]; + + // Add glob patterns (ripgrep uses --glob for filtering --files output) + for (const pattern of patterns) args.push("--glob", pattern); + for (const exclude of excludePatterns) args.push("--glob", `!${exclude}`); + + // The "--" prevents searchPath from being parsed as an option if it starts with "-" + // (defense against argument injection attacks) + args.push("--", searchPath); + const result = spawnSync(rgPath, args, { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.status > 1) { + throw new Error( + result.stderr || `ripgrep exited with code ${result.status}` + ); + } + + // unique files + const files = new Set(); + if (!result.stdout) return { files: Array.from(files), method: "ripgrep" }; + + const lines = result.stdout.trim().split("\n").filter(Boolean); + for (const line of lines) { + files.add(line); + if (files.size >= maxResults) break; + } + + return { files: Array.from(files), method: "ripgrep" }; +} + +/** + * Search file contents using @vscode/ripgrep binary directly via spawnSync. + */ +function searchWithRipgrep({ + searchPath, + pattern, + filePattern, + excludePatterns, + caseSensitive, + maxResults, +}) { + const { spawnSync } = require("child_process"); + let rgPath; + try { + ({ rgPath } = require("@vscode/ripgrep")); + } catch { + throw new Error("@vscode/ripgrep not installed"); + } + + // Build ripgrep arguments + const args = [ + "--json", // JSON output for structured parsing + "--line-number", // Include line numbers + "--no-ignore", // Search all files, even those in .gitignore + "--max-count", + String(maxResults), + ]; + + if (!caseSensitive) args.push("--ignore-case"); + if (filePattern) args.push("--glob", filePattern); + for (const exclude of excludePatterns) args.push("--glob", `!${exclude}`); + + // Security: prevent argument injection attacks where a malicious pattern like + // "--pre=/bin/sh" could cause ripgrep to execute arbitrary commands. + // The "--" separator tells ripgrep to treat everything after it as positional + // arguments, not options. The startsWith("-") check is defense-in-depth. + if (typeof pattern === "string" && pattern.startsWith("-")) { + throw new Error("search pattern must not start with '-'"); + } + args.push("--", pattern, searchPath); + const result = spawnSync(rgPath, args, { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, // 10MB + }); + + // Exit code 1 means no matches (not an error) + if (result.status > 1) { + throw new Error( + result.stderr || `ripgrep exited with code ${result.status}` + ); + } + + const results = []; + if (!result.stdout) return results; + const matches = safeJsonParse(result.stdout, []).filter( + (m) => m.type === "match" && m.data + ); + + for (const match of matches) { + results.push({ + file: match.data.path?.text || match.data.path, + line: match.data.line_number, + content: (match.data.lines?.text || "").trim(), + }); + } + + return results; +} + +/** + * Format search results for display. + */ +function formatSearchResults(results, maxResults) { + if (results.length === 0) { + return "No matches found"; + } + + const formatted = results + .slice(0, maxResults) + .map((r) => `${r.file}:${r.line}: ${r.content}`) + .join("\n"); + + const suffix = + results.length > maxResults + ? `\n\n... and ${results.length - maxResults} more matches` + : ""; + + return formatted + suffix; +} + +/** + * Read contents of matching files and add citations. + * @param {string[]} filePaths - Array of file paths to read + * @param {number} maxFiles - Maximum number of files to read + * @returns {Promise} Combined file contents + */ +async function readMatchingFileContents(filePaths, maxFiles) { + const filesToRead = filePaths.slice(0, maxFiles); + const skippedCount = filePaths.length - filesToRead.length; + + this.super.introspect( + `${this.caller}: Reading contents of ${filesToRead.length} file${filesToRead.length === 1 ? "" : "s"}${skippedCount > 0 ? ` (${skippedCount} more files not read)` : ""}` + ); + + const results = []; + for (const filePath of filesToRead) { + try { + const content = await filesystem.readFileContent(filePath); + const filename = path.basename(filePath); + + this.super.addCitation?.({ + id: `fs-${Buffer.from(filePath).toString("base64url").slice(0, 32)}`, + title: filename, + text: content, + chunkSource: filePath, + score: null, + }); + + results.push({ + path: filePath, + content, + success: true, + }); + } catch (error) { + results.push({ + path: filePath, + content: `Error reading file: ${error.message}`, + success: false, + }); + } + } + + const combinedContent = results + .map((r) => + r.success + ? `=== ${r.path} ===\n${r.content}` + : `=== ${r.path} ===\n${r.content}` + ) + .join("\n\n---\n\n"); + + const { content: finalContent, wasTruncated } = + filesystem.truncateContentForContext( + combinedContent, + this.super, + `[Content truncated - file contents exceed context limit. Try reducing maxFilesToRead or searching more specifically.]` + ); + + if (wasTruncated) { + this.super.introspect( + `${this.caller}: File contents were truncated to fit context limit` + ); + } + + const header = + skippedCount > 0 + ? `Found ${filePaths.length} matching files. Showing contents of first ${filesToRead.length}:\n\n` + : ""; + + return header + finalContent; +} diff --git a/server/utils/agents/aibitat/plugins/filesystem/write-text-file.js b/server/utils/agents/aibitat/plugins/filesystem/write-text-file.js new file mode 100644 index 0000000..5de693b --- /dev/null +++ b/server/utils/agents/aibitat/plugins/filesystem/write-text-file.js @@ -0,0 +1,90 @@ +const filesystem = require("./lib.js"); + +module.exports.FilesystemWriteTextFile = { + name: "filesystem-write-text-file", + plugin: function () { + return { + name: "filesystem-write-text-file", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new text file or completely overwrite an existing text file with new content. " + + "Use with caution as it will overwrite existing files without warning. " + + "Only handles text/plaintext content with proper encoding. Only works within allowed directories. " + + "For binary formats (PDF, DOCX, XLSX, PPTX), use the appropriate document creation tools instead.", + examples: [ + { + prompt: "Create a new config file with these settings", + call: JSON.stringify({ + path: "config.json", + content: '{"debug": true, "port": 3000}', + }), + }, + { + prompt: "Write a hello world Python script", + call: JSON.stringify({ + path: "hello.py", + content: 'print("Hello, World!")', + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + path: { + type: "string", + description: + "The path where the file should be created or overwritten.", + }, + content: { + type: "string", + description: "The content to write to the file.", + }, + }, + required: ["path", "content"], + additionalProperties: false, + }, + handler: async function ({ path: filePath = "", content = "" }) { + try { + this.super.handlerProps.log( + `Using the filesystem-write-text-file tool.` + ); + + const validPath = await filesystem.validatePath(filePath); + this.super.introspect( + `${this.caller}: Writing to file ${filePath}` + ); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { path: filePath, content }, + description: "Write content to a file", + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + await filesystem.writeFileContent(validPath, content); + this.super.introspect(`Successfully wrote to ${filePath}`); + return `Successfully wrote to ${filePath}`; + } catch (e) { + this.super.handlerProps.log( + `filesystem-write-text-file error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error writing file: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js b/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js new file mode 100644 index 0000000..c63b626 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js @@ -0,0 +1,75 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetMailboxStats = { + name: "gmail-get-mailbox-stats", + plugin: function () { + return { + name: "gmail-get-mailbox-stats", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Used for general account information. Reports Gmail mailbox statistics including unread counts for inbox, " + + "priority inbox, starred messages, and spam folder.", + examples: [ + { + prompt: "How much of my mailbox quota is remaining?", + call: JSON.stringify({}), + }, + { + prompt: "Show me my mailbox statistics", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the gmail-get-mailbox-stats tool.` + ); + + this.super.introspect( + `${this.caller}: Getting Gmail mailbox statistics` + ); + + const result = await gmailLib.getMailboxStats(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get mailbox stats - ${result.error}` + ); + return `Error getting mailbox statistics: ${result.error}`; + } + + const stats = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved mailbox statistics` + ); + + return ( + `Gmail Mailbox Statistics:\n\n` + + `Inbox Unread: ${stats.inboxUnreadCount}\n` + + `Priority Inbox Unread: ${stats.priorityInboxUnreadCount}\n` + + `Starred Unread: ${stats.starredUnreadCount}\n` + + `Spam Unread: ${stats.spamUnreadCount}\n\n` + + `Use gmail-search to find and read specific emails.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-get-mailbox-stats error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting mailbox statistics: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js new file mode 100644 index 0000000..1809d51 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js @@ -0,0 +1,220 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailCreateDraftReply = { + name: "gmail-create-draft-reply", + plugin: function () { + return { + name: "gmail-create-draft-reply", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a draft reply to an existing email thread in Gmail. " + + "The draft will be saved but not sent. You can choose to reply to all recipients or just the sender. " + + "Supports file attachments.", + examples: [ + { + prompt: "Create a draft reply to thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thank you for your email. I will review this and get back to you shortly.", + replyAll: false, + }), + }, + { + prompt: "Draft a reply-all response to the thread", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thanks everyone for your input. Here are my thoughts...", + replyAll: true, + }), + }, + { + prompt: "Create a draft reply with an attachment", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Please find the requested document attached.", + attachments: ["/Users/me/Documents/document.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to reply to.", + }, + body: { + type: "string", + description: "Plain text reply body content.", + }, + replyAll: { + type: "boolean", + description: + "Whether to reply to all recipients. Defaults to false (reply to sender only).", + default: false, + }, + cc: { + type: "string", + description: + "Additional CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the reply body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft reply.", + }, + }, + required: ["threadId", "body"], + additionalProperties: false, + }, + handler: async function ({ + threadId, + body, + replyAll = false, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the gmail-create-draft-reply tool.` + ); + + if (!threadId || !body) { + return "Error: 'threadId' and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + threadId, + replyAll, + attachmentCount: preparedAttachments.length, + }, + description: `Create Gmail draft reply to thread "${threadId}"${replyAll ? " (reply all)" : ""}${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating draft reply to thread ${threadId}${replyAll ? " (reply all)" : ""}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.createDraftReply( + threadId, + body, + replyAll, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft reply - ${result.error}` + ); + return `Error creating Gmail draft reply: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft reply (ID: ${draft.draftId})` + ); + + return ( + `Successfully created Gmail draft reply:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + `Reply Type: ${replyAll ? "Reply All" : "Reply"}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft reply has been saved and can be edited or sent later.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-create-draft-reply error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Gmail draft reply: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js new file mode 100644 index 0000000..9b2b0ee --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js @@ -0,0 +1,217 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailCreateDraft = { + name: "gmail-create-draft", + plugin: function () { + return { + name: "gmail-create-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new draft email in Gmail. The draft will be saved but not sent. " + + "You can optionally include CC, BCC recipients, HTML body content, and file attachments.", + examples: [ + { + prompt: + "Create a draft email to john@example.com about the meeting", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Tomorrow", + body: "Hi John,\n\nJust wanted to confirm our meeting tomorrow at 2pm.\n\nBest regards", + }), + }, + { + prompt: "Draft an email with CC recipients", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Please see the attached project update.", + cc: "manager@example.com", + }), + }, + { + prompt: "Create a draft with an attachment", + call: JSON.stringify({ + to: "john@example.com", + subject: "Report", + body: "Please find the report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + to, + subject, + body, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-create-draft tool.`); + + if (!to || !subject || !body) { + return "Error: 'to', 'subject', and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Create Gmail draft to "${to}" with subject "${subject}"${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating Gmail draft to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.createDraft( + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft - ${result.error}` + ); + return `Error creating Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft (ID: ${draft.draftId})` + ); + + return ( + `Successfully created Gmail draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been saved and can be edited or sent later.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-create-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js new file mode 100644 index 0000000..ce569e8 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailDeleteDraft = { + name: "gmail-delete-draft", + plugin: function () { + return { + name: "gmail-delete-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Delete a draft email from Gmail. " + + "This action is permanent and cannot be undone.", + examples: [ + { + prompt: "Delete the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to delete.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-delete-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Delete Gmail draft "${draftId}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Deleting Gmail draft ${draftId}` + ); + + const result = await gmailLib.deleteDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to delete draft - ${result.error}` + ); + return `Error deleting Gmail draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully deleted draft ${draftId}` + ); + + return `Successfully deleted Gmail draft (ID: ${draftId}).`; + } catch (e) { + this.super.handlerProps.log( + `gmail-delete-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error deleting Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js new file mode 100644 index 0000000..66c3829 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js @@ -0,0 +1,84 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetDraft = { + name: "gmail-get-draft", + plugin: function () { + return { + name: "gmail-get-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Retrieve a specific draft email by its ID. " + + "Returns the draft details including recipient, subject, and body content.", + examples: [ + { + prompt: "Get the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to retrieve.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-get-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + this.super.introspect( + `${this.caller}: Retrieving Gmail draft ${draftId}` + ); + + const result = await gmailLib.getDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get draft - ${result.error}` + ); + return `Error retrieving Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved draft (ID: ${draft.draftId})` + ); + + return ( + `Gmail Draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + (draft.cc ? `CC: ${draft.cc}\n` : "") + + (draft.bcc ? `BCC: ${draft.bcc}\n` : "") + + `Subject: ${draft.subject}\n` + + `Date: ${new Date(draft.date).toLocaleString()}\n` + + `\n--- Body ---\n${draft.body}` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-get-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error retrieving Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js new file mode 100644 index 0000000..e41241d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js @@ -0,0 +1,96 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailListDrafts = { + name: "gmail-list-drafts", + plugin: function () { + return { + name: "gmail-list-drafts", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all draft emails in Gmail. " + + "Returns a summary of each draft including ID, recipient, subject, and date.", + examples: [ + { + prompt: "List my email drafts", + call: JSON.stringify({ + limit: 25, + }), + }, + { + prompt: "Show me the first 10 drafts", + call: JSON.stringify({ + limit: 10, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of drafts to return (1-100). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log(`Using the gmail-list-drafts tool.`); + + this.super.introspect( + `${this.caller}: Listing Gmail drafts (limit: ${limit})` + ); + + const result = await gmailLib.listDrafts(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list drafts - ${result.error}` + ); + return `Error listing Gmail drafts: ${result.error}`; + } + + const { totalDrafts, returned, drafts } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalDrafts} total drafts, returning ${returned}` + ); + + if (totalDrafts === 0) { + return "No drafts found in Gmail."; + } + + const summary = drafts + .map( + (d, i) => + `${i + 1}. Draft ID: ${d.draftId}\n` + + ` To: ${d.to || "(no recipient)"}\n` + + ` Subject: ${d.subject || "(no subject)"}\n` + + ` Date: ${new Date(d.date).toLocaleString()}` + ) + .join("\n\n"); + + return ( + `Gmail Drafts (${returned} of ${totalDrafts} total):\n\n${summary}\n\n` + + `Use the draft ID with gmail-get-draft to view full content, ` + + `gmail-update-draft to edit, gmail-delete-draft to remove, ` + + `or gmail-send-draft to send.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-list-drafts error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error listing Gmail drafts: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js new file mode 100644 index 0000000..e06bc6b --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js @@ -0,0 +1,94 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailSendDraft = { + name: "gmail-send-draft", + plugin: function () { + return { + name: "gmail-send-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an existing draft email from Gmail. " + + "This will send the draft immediately and remove it from drafts. " + + "This action cannot be undone.", + examples: [ + { + prompt: "Send the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to send.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-send-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Send Gmail draft "${draftId}" - This will send the email immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending Gmail draft ${draftId}` + ); + + const result = await gmailLib.sendDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send draft - ${result.error}` + ); + return `Error sending Gmail draft: ${result.error}`; + } + + const { messageId, threadId } = result.data; + this.super.introspect( + `${this.caller}: Successfully sent draft as message ${messageId}` + ); + + return ( + `Successfully sent Gmail draft:\n` + + `Message ID: ${messageId}\n` + + `Thread ID: ${threadId}\n\n` + + `The email has been sent and removed from drafts.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-send-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error sending Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js new file mode 100644 index 0000000..8f7ae00 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js @@ -0,0 +1,217 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailUpdateDraft = { + name: "gmail-update-draft", + plugin: function () { + return { + name: "gmail-update-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing draft email in Gmail. " + + "You must provide the draft ID and the new content for the draft. " + + "Supports file attachments.", + examples: [ + { + prompt: "Update draft r123456 with new content", + call: JSON.stringify({ + draftId: "r123456", + to: "john@example.com", + subject: "Updated: Meeting Tomorrow", + body: "Hi John,\n\nThe meeting has been rescheduled to 3pm.\n\nBest regards", + }), + }, + { + prompt: "Update draft with an attachment", + call: JSON.stringify({ + draftId: "r123456", + to: "john@example.com", + subject: "Report", + body: "Please find the updated report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to update.", + }, + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["draftId", "to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + draftId, + to, + subject, + body, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-update-draft tool.`); + + if (!draftId || !to || !subject) { + return "Error: 'draftId', 'to', and 'subject' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + draftId, + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Update Gmail draft "${draftId}"${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating Gmail draft ${draftId}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.updateDraft( + draftId, + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update draft - ${result.error}` + ); + return `Error updating Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully updated draft (ID: ${draft.draftId})` + ); + + return ( + `Successfully updated Gmail draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been updated.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-update-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error updating Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/index.js b/server/utils/agents/aibitat/plugins/gmail/index.js new file mode 100644 index 0000000..506a9ed --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/index.js @@ -0,0 +1,73 @@ +// Get Inbox & Search +const { GmailGetInbox } = require("./search/gmail-get-inbox.js"); +const { GmailSearch } = require("./search/gmail-search.js"); +const { GmailReadThread } = require("./search/gmail-read-thread.js"); + +// Drafts +const { GmailCreateDraft } = require("./drafts/gmail-create-draft.js"); +const { + GmailCreateDraftReply, +} = require("./drafts/gmail-create-draft-reply.js"); +const { GmailUpdateDraft } = require("./drafts/gmail-update-draft.js"); +const { GmailGetDraft } = require("./drafts/gmail-get-draft.js"); +const { GmailListDrafts } = require("./drafts/gmail-list-drafts.js"); +const { GmailDeleteDraft } = require("./drafts/gmail-delete-draft.js"); +const { GmailSendDraft } = require("./drafts/gmail-send-draft.js"); + +// Send & Reply +const { GmailSendEmail } = require("./send/gmail-send-email.js"); +const { GmailReplyToThread } = require("./send/gmail-reply-to-thread.js"); + +// Thread Management +const { GmailMarkRead } = require("./threads/gmail-mark-read.js"); +const { GmailMarkUnread } = require("./threads/gmail-mark-unread.js"); +const { GmailMoveToTrash } = require("./threads/gmail-move-to-trash.js"); +const { GmailMoveToArchive } = require("./threads/gmail-move-to-archive.js"); +const { GmailMoveToInbox } = require("./threads/gmail-move-to-inbox.js"); + +// Account +const { + GmailGetMailboxStats, +} = require("./account/gmail-get-mailbox-stats.js"); + +const gmailAgent = { + name: "gmail-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Alias for easy access to the inbox + GmailGetInbox, + + // Search & Read (read-only) + GmailSearch, + GmailReadThread, + + // Drafts (modifying) + GmailCreateDraft, + GmailCreateDraftReply, + GmailUpdateDraft, + GmailGetDraft, + GmailListDrafts, + GmailDeleteDraft, + GmailSendDraft, + + // Send & Reply (modifying) + GmailSendEmail, + GmailReplyToThread, + + // Thread Management (modifying) + GmailMarkRead, + GmailMarkUnread, + GmailMoveToTrash, + GmailMoveToArchive, + GmailMoveToInbox, + + // Account (read-only) + GmailGetMailboxStats, + ], +}; + +module.exports = { + gmailAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/lib.js b/server/utils/agents/aibitat/plugins/gmail/lib.js new file mode 100644 index 0000000..c751444 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/lib.js @@ -0,0 +1,573 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const mime = require("mime"); +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { CollectorApi } = require("../../../../collectorApi"); +const { humanFileSize } = require("../../../../helpers"); +const { safeJsonParse } = require("../../../../http"); + +const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB limit for all attachments combined + +/** + * Validates and prepares a file attachment for email. + * Note: Does not check total size limit - caller should track cumulative size. + * @param {string} filePath - Absolute path to the file + * @returns {{success: boolean, attachment?: object, error?: string, fileInfo?: object}} + */ +function prepareAttachment(filePath) { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") { + return { + success: false, + error: "File attachments are not supported in Docker environments.", + }; + } + + if (!path.isAbsolute(filePath)) { + return { success: false, error: `Path must be absolute: ${filePath}` }; + } + + if (!fs.existsSync(filePath)) { + return { success: false, error: `File does not exist: ${filePath}` }; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return { success: false, error: `Path is not a file: ${filePath}` }; + } + + if (stats.size === 0) { + return { success: false, error: `File is empty: ${filePath}` }; + } + + try { + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString("base64"); + const fileName = path.basename(filePath); + const contentType = mime.getType(filePath) || "application/octet-stream"; + + return { + success: true, + attachment: { + name: fileName, + contentType, + data: base64Data, + }, + fileInfo: { + path: filePath, + name: fileName, + size: stats.size, + sizeFormatted: humanFileSize(stats.size, true), + contentType, + }, + }; + } catch (e) { + return { success: false, error: `Failed to read file: ${e.message}` }; + } +} + +/** + * Parse an attachment using the CollectorApi for secure content extraction. + * Writes the base64 data to a temp file, parses it, then cleans up. + * @param {Object} attachment - The attachment object with name, contentType, size, data (base64) + * @returns {Promise<{success: boolean, content: string|null, error: string|null}>} + */ +async function parseAttachment(attachment) { + const tempDir = os.tmpdir(); + const safeFilename = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const tempFilePath = path.join( + tempDir, + `gmail-attachment-${Date.now()}-${safeFilename}` + ); + + try { + const buffer = Buffer.from(attachment.data, "base64"); + fs.writeFileSync(tempFilePath, buffer); + + const collector = new CollectorApi(); + const result = await collector.parseDocument(safeFilename, { + absolutePath: tempFilePath, + }); + + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + if (!result.success) { + return { + success: false, + content: null, + error: result.reason || "Failed to parse attachment", + }; + } + + const textContent = result.documents + ?.map((doc) => doc.pageContent || doc.content || "") + .filter(Boolean) + .join("\n\n"); + + return { + success: true, + content: textContent || "(No text content extracted)", + error: null, + }; + } catch (e) { + if (fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch {} + } + return { success: false, content: null, error: e.message }; + } +} + +/** + * Collect attachments from messages and optionally parse them with user approval. + * Specific files may not show (images) and are pre-stripped by the app script. + * If two attachments have the same name, only the first one will be kept (handling fwd emails) + * @param {Object} context - The handler context (this) from the aibitat function + * @param {Array} messages - Array of message objects (single message should be wrapped in array) + * @returns {Promise<{allAttachments: Array, parsedContent: string}>} + */ +async function handleAttachments(context, messages) { + const allAttachments = []; + const uniqueAttachments = new Set(); + messages.forEach((msg, msgIndex) => { + if (msg.attachments?.length > 0) { + msg.attachments.forEach((att) => { + if (uniqueAttachments.has(att.name)) return; + uniqueAttachments.add(att.name); + allAttachments.push({ + ...att, + messageIndex: msgIndex + 1, + messageId: msg.id, + }); + }); + } + }); + + let parsedContent = ""; + const citations = []; + if (allAttachments.length > 0 && context.super.requestToolApproval) { + const attachmentNames = allAttachments.map((a) => a.name).join(", "); + + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { attachments: attachmentNames }, + description: `Parse attachments (${attachmentNames}) to extract text content?`, + }); + + if (approval.approved) { + context.super.introspect( + `${context.caller}: Parsing ${allAttachments.length} attachment(s)...` + ); + + const parsedResults = []; + for (const attachment of allAttachments) { + if (!attachment.data) continue; + context.super.introspect( + `${context.caller}: Parsing "${attachment.name}"...` + ); + const parseResult = await parseAttachment(attachment); + if (!parseResult.success) continue; + + citations.push({ + id: `gmail-attachment-${attachment.messageId}-${attachment.name}`, + title: attachment.name, + text: parseResult.content, + chunkSource: "gmail-attachment://" + attachment.name, + score: null, + }); + parsedResults.push({ + name: attachment.name, + messageIndex: attachment.messageIndex, + ...parseResult, + }); + } + + parsedContent = + "\n\n--- Parsed Attachment Content ---\n" + + parsedResults + .map((r) => `\n[Message ${r.messageIndex}: ${r.name}]\n${r.content}`) + .join("\n"); + + context.super.introspect( + `${context.caller}: Finished parsing attachments` + ); + } else { + context.super.introspect( + `${context.caller}: User declined to parse attachments` + ); + } + } + + citations.forEach((c) => context.super.addCitation?.(c)); + return { allAttachments, parsedContent }; +} + +/** + * Gmail Bridge Library + * Handles communication with the AnythingLLM Gmail Google Apps Script deployment. + */ +class GmailBridge { + #deploymentId = null; + #apiKey = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[36m[GmailBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + * Call this when configuration changes (e.g., deployment ID updated). + */ + reset() { + this.#deploymentId = null; + this.#apiKey = null; + this.#isInitialized = false; + } + + /** + * Gets the current Gmail agent configuration from system settings. + * @returns {Promise<{deploymentId?: string, apiKey?: string}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "gmail_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Gmail agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + gmail_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Initializes the Gmail bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Gmail integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await GmailBridge.getConfig(); + if (!config.deploymentId || !config.apiKey) { + return { + success: false, + error: + "Gmail integration is not configured. Please set the Deployment ID and API Key in the agent settings.", + }; + } + + this.#deploymentId = config.deploymentId; + this.#apiKey = config.apiKey; + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Gmail bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Gmail tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await GmailBridge.getConfig(); + return !!(config.deploymentId && config.apiKey); + } + + get maskedDeploymentId() { + if (!this.#deploymentId) return "(not configured)"; + return ( + this.#deploymentId.substring(0, 5) + + "..." + + this.#deploymentId.substring(this.#deploymentId.length - 5) + ); + } + + /** + * Gets the base URL for the Gmail Google Apps Script deployment. + * @returns {string} + */ + #getBaseUrl() { + this.#log(`Getting base URL for deployment ID ${this.maskedDeploymentId}`); + return `https://script.google.com/macros/s/${this.#deploymentId}/exec`; + } + + /** + * Makes a request to the Gmail Google Apps Script API. + * @param {string} action - The action to perform + * @param {object} params - Additional parameters for the action + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(action, params = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + return { success: false, error: initResult.error }; + } + + try { + const response = await fetch(this.#getBaseUrl(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-AnythingLLM-UA": "AnythingLLM-Gmail-Agent/1.0", + }, + body: JSON.stringify({ + key: this.#apiKey, + action, + ...params, + }), + }); + + if (!response.ok) { + return { + success: false, + error: `Gmail API request failed with status ${response.status}`, + }; + } + + const result = await response.json(); + + if (result.status === "error") { + return { success: false, error: result.error }; + } + + return { success: true, data: result.data, quota: result.quota }; + } catch (error) { + return { + success: false, + error: `Gmail API request failed: ${error.message}`, + }; + } + } + + /** + * Search emails using Gmail query syntax. + * @param {string} query - Gmail search query + * @param {number} limit - Maximum results to return + * @param {number} start - Starting offset + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async search(query = "is:inbox", limit = 10, start = 0) { + return this.request("search", { query, limit, start }); + } + + /** + * Read a full thread by ID. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readThread(threadId) { + return this.request("read_thread", { threadId }); + } + + /** + * Create a new draft email. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options (cc, bcc, htmlBody, etc.) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraft(to, subject, body, options = {}) { + return this.request("create_draft", { to, subject, body, ...options }); + } + + /** + * Create a draft reply to an existing thread. + * @param {string} threadId - The thread ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraftReply(threadId, body, replyAll = false, options = {}) { + return this.request("create_draft_reply", { + threadId, + body, + replyAll, + ...options, + }); + } + + /** + * Update an existing draft. + * @param {string} draftId - The draft ID + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateDraft(draftId, to, subject, body, options = {}) { + return this.request("update_draft", { + draftId, + to, + subject, + body, + ...options, + }); + } + + /** + * Get a specific draft by ID. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getDraft(draftId) { + return this.request("get_draft", { draftId }); + } + + /** + * List all drafts. + * @param {number} limit - Maximum drafts to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listDrafts(limit = 25) { + return this.request("list_drafts", { limit }); + } + + /** + * Delete a draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async deleteDraft(draftId) { + return this.request("delete_draft", { draftId }); + } + + /** + * Send an existing draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async sendDraft(draftId) { + return this.request("send_draft", { draftId }); + } + + /** + * Send an email immediately. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async sendEmail(to, subject, body, options = {}) { + return this.request("send_email", { to, subject, body, ...options }); + } + + /** + * Reply to a thread immediately. + * @param {string} threadId - The thread ID + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async replyToThread(threadId, body, replyAll = false, options = {}) { + return this.request("reply_to_thread", { + threadId, + body, + replyAll, + ...options, + }); + } + + /** + * Mark a thread as read. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async markRead(threadId) { + return this.request("mark_read", { threadId }); + } + + /** + * Mark a thread as unread. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async markUnread(threadId) { + return this.request("mark_unread", { threadId }); + } + + /** + * Move a thread to trash. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToTrash(threadId) { + return this.request("move_to_trash", { threadId }); + } + + /** + * Archive a thread. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToArchive(threadId) { + return this.request("move_to_archive", { threadId }); + } + + /** + * Move a thread to inbox. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToInbox(threadId) { + return this.request("move_to_inbox", { threadId }); + } + + /** + * Get mailbox statistics. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getMailboxStats() { + return this.request("get_mailbox_stats"); + } +} + +module.exports = new GmailBridge(); +module.exports.GmailBridge = GmailBridge; +module.exports.prepareAttachment = prepareAttachment; +module.exports.parseAttachment = parseAttachment; +module.exports.handleAttachments = handleAttachments; +module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js new file mode 100644 index 0000000..7b3cd93 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js @@ -0,0 +1,104 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetInbox = { + name: "gmail-get-inbox", + plugin: function () { + return { + name: "gmail-get-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get the inbox emails from Gmail. Returns the inbox emails with the details of the email. " + + "Supports optional query and limit parameters to filter the emails.", + examples: [ + { + prompt: "What's in my inbox?", + call: JSON.stringify({ + query: "", + limit: 10, + }), + }, + { + prompt: "Check my inbox for any emails from John Doe", + call: JSON.stringify({ + query: "from:john.doe@example.com", + limit: 10, + }), + }, + { + prompt: "Get my 5 most recent unread emails", + call: JSON.stringify({ + query: "is:unread newer_than:1d", + limit: 5, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Optional Gmail search query. Use Gmail query syntax like 'is:inbox', 'is:unread', 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Optional maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + }, + required: [], + additionalProperties: false, + }, + handler: async function ({ query = "", limit = 10 }) { + try { + this.super.handlerProps.log(`Using the gmail-get-inbox tool.`); + this.super.introspect( + `${this.caller}: Searching Gmail with query "${query}"` + ); + + let searchQuery = `is:inbox`; + if (query) searchQuery += ` ${query}`; + const result = await gmailLib.search(searchQuery, limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Gmail get inbox failed - ${result.error}` + ); + return `Error getting inbox from Gmail: ${result.error}`; + } + + const { threads, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} emails in inbox` + ); + + if (resultCount === 0) { + return `No emails found in inbox.`; + } + + const summary = threads + .map( + (t, i) => + `${i + 1}. [${t.isUnread ? "UNREAD" : "READ"}] "${t.subject}" (ID: ${t.id}, ${t.messageCount} messages, Last: ${new Date(t.lastMessageDate).toLocaleString()})` + ) + .join("\n"); + + return `Found ${resultCount} email threads:\n\n${summary}\n\nAlways include the full thread ID in the response. Use the thread ID with gmail-read-thread to read the full conversation.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-get-inbox error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting inbox from Gmail: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js new file mode 100644 index 0000000..05cdcc3 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js @@ -0,0 +1,122 @@ +const gmailLib = require("../lib.js"); +const { handleAttachments } = require("../lib.js"); + +module.exports.GmailReadThread = { + name: "gmail-read-thread", + plugin: function () { + return { + name: "gmail-read-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read a full email thread by its ID. Returns all messages in the thread " + + "including sender, recipients, subject, body, date, and attachment information. " + + "Use this after searching to read the full conversation.", + examples: [ + { + prompt: "Read the email thread with ID 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to read.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-read-thread tool.`); + + if (!threadId) { + return "Error: threadId is required."; + } + + this.super.introspect( + `${this.caller}: Reading Gmail thread ${threadId}` + ); + + const result = await gmailLib.readThread(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to read thread - ${result.error}` + ); + return `Error reading Gmail thread: ${result.error}`; + } + + const thread = result.data; + const labels = thread.labels?.length + ? `Labels: ${thread.labels.join(", ")}` + : "No labels"; + + const { allAttachments, parsedContent: parsedAttachmentContent } = + await handleAttachments(this, thread.messages); + + const messagesFormatted = thread.messages + .map((msg, i) => { + let attachmentInfo = ""; + if (msg.attachments?.length > 0) { + attachmentInfo = `\n Attachments: ${msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(1)}KB)`).join(", ")}`; + } + return ( + `--- Message ${i + 1} ---\n` + + `From: ${msg.from}\n` + + `To: ${msg.to}\n` + + (msg.cc ? `CC: ${msg.cc}\n` : "") + + `Date: ${new Date(msg.date).toLocaleString()}\n` + + `Subject: ${msg.subject}\n` + + `Status: ${msg.isUnread ? "UNREAD" : "READ"}${msg.isStarred ? ", STARRED" : ""}\n` + + `\n${msg.body}` + + attachmentInfo + ); + }) + .join("\n\n"); + + this.super.introspect( + `${this.caller}: Successfully read thread with ${thread.messageCount} messages` + ); + + this.super.addCitation?.({ + id: `gmail-thread-${thread.id}`, + title: thread.subject, + text: messagesFormatted, + chunkSource: `gmail-thread://${thread.permalink}`, + score: null, + }); + + return ( + `Thread: "${thread.subject}"\n` + + `Thread ID: ${thread.id}\n` + + `Messages: ${thread.messageCount}\n` + + `Total Attachments: ${allAttachments.length}\n` + + `Status: ${thread.isUnread ? "UNREAD" : "READ"}${thread.isImportant ? ", IMPORTANT" : ""}${thread.hasStarredMessages ? ", HAS STARRED" : ""}\n` + + `Location: ${thread.isInInbox ? "Inbox" : ""}${thread.isInSpam ? "Spam" : ""}${thread.isInTrash ? "Trash" : ""}\n` + + `${labels}\n` + + `Permalink: ${thread.permalink}\n\n` + + messagesFormatted + + parsedAttachmentContent + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-read-thread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error reading Gmail thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js new file mode 100644 index 0000000..8c9c21d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js @@ -0,0 +1,121 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailSearch = { + name: "gmail-search", + plugin: function () { + return { + name: "gmail-search", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Search emails in Gmail using Gmail query syntax. " + + "Supports full Gmail search including keywords and operators combined. " + + "Common operators: 'is:inbox', 'is:unread', 'is:starred', 'from:email', 'to:email', " + + "'subject:word', 'has:attachment', 'newer_than:7d', 'older_than:1m'. " + + "Combine with search terms: 'is:inbox meeting notes' finds inbox emails containing 'meeting notes'. " + + "Returns thread summaries with ID, subject, date, and unread status.", + examples: [ + { + prompt: "Search for unread emails in my inbox about the project", + call: JSON.stringify({ + query: "is:inbox is:unread project update", + limit: 10, + }), + }, + { + prompt: "Find emails from john@example.com about meetings", + call: JSON.stringify({ + query: "from:john@example.com meeting", + limit: 20, + }), + }, + { + prompt: + "Search for emails with attachments from last week about invoices", + call: JSON.stringify({ + query: "has:attachment newer_than:7d invoice", + limit: 15, + }), + }, + { + prompt: "Find starred emails about budget", + call: JSON.stringify({ + query: "is:starred budget", + limit: 10, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Gmail search query. Use Gmail query syntax like 'is:inbox', 'is:unread', 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + start: { + type: "number", + description: "Starting offset for pagination. Defaults to 0.", + default: 0, + }, + }, + required: ["query"], + additionalProperties: false, + }, + handler: async function ({ + query = "is:inbox", + limit = 10, + start = 0, + }) { + try { + this.super.handlerProps.log(`Using the gmail-search tool.`); + this.super.introspect( + `${this.caller}: Searching Gmail with query "${query}"` + ); + + const result = await gmailLib.search(query, limit, start); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Gmail search failed - ${result.error}` + ); + return `Error searching Gmail: ${result.error}`; + } + + const { threads, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} email threads matching query` + ); + + if (resultCount === 0) { + return `No emails found matching query "${query}".`; + } + + const summary = threads + .map( + (t, i) => + `${i + 1}. [${t.isUnread ? "UNREAD" : "READ"}] "${t.subject}" (ID: ${t.id}, ${t.messageCount} messages, Last: ${new Date(t.lastMessageDate).toLocaleString()})` + ) + .join("\n"); + + return `Found ${resultCount} email threads:\n\n${summary}\n\nAlways include the full thread ID in the response. Use the thread ID with gmail-read-thread to read the full conversation.`; + } catch (e) { + this.super.handlerProps.log(`gmail-search error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error searching Gmail: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js b/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js new file mode 100644 index 0000000..56cf5f9 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js @@ -0,0 +1,238 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailReplyToThread = { + name: "gmail-reply-to-thread", + plugin: function () { + return { + name: "gmail-reply-to-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Reply to an existing email thread immediately. " + + "This action sends the reply right away and cannot be undone. " + + "For composing replies that need review before sending, use gmail-create-draft-reply instead. " + + "Supports file attachments via absolute file paths (max 20MB total for all attachments combined).", + examples: [ + { + prompt: "Reply to thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thank you for your email. I've reviewed the proposal and have some feedback.", + replyAll: false, + }), + }, + { + prompt: "Reply all to the thread", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thanks everyone. I agree with the proposed timeline.", + replyAll: true, + }), + }, + { + prompt: "Reply with an attachment", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Please find the requested document attached.", + attachments: ["/Users/me/Documents/document.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to reply to.", + }, + body: { + type: "string", + description: "Plain text reply body content.", + }, + replyAll: { + type: "boolean", + description: + "Whether to reply to all recipients. Defaults to false (reply to sender only).", + default: false, + }, + cc: { + type: "string", + description: + "Additional CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the reply body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the reply.", + }, + }, + required: ["threadId", "body"], + additionalProperties: false, + }, + handler: async function ({ + threadId, + body, + replyAll = false, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the gmail-reply-to-thread tool.` + ); + + if (!threadId || !body) { + return "Error: 'threadId' and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to reply? ` + + `This file will be sent immediately.`, + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + threadId, + replyAll, + attachmentCount: preparedAttachments.length, + }, + description: `Reply to thread "${threadId}"${replyAll ? " (reply all)" : ""}${attachmentNote} - This will send immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Replying to thread ${threadId}${replyAll ? " (reply all)" : ""}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.replyToThread( + threadId, + body, + replyAll, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to reply to thread - ${result.error}` + ); + return `Error replying to thread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully replied to thread ${threadId}` + ); + + return ( + `Successfully replied to thread:\n` + + `Thread ID: ${threadId}\n` + + `Reply Type: ${replyAll ? "Reply All" : "Reply"}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe reply has been sent.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-reply-to-thread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error replying to thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js b/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js new file mode 100644 index 0000000..7b1cdde --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js @@ -0,0 +1,242 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailSendEmail = { + name: "gmail-send-email", + plugin: function () { + return { + name: "gmail-send-email", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an email immediately through Gmail. " + + "This action sends the email right away and cannot be undone. " + + "For composing emails that need review before sending, use gmail-create-draft instead.", + examples: [ + { + prompt: "Send an email to john@example.com about the project", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Hi John,\n\nHere's the latest update on the project.\n\nBest regards", + }), + }, + { + prompt: "Send an email with CC recipients", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Notes", + body: "Please find the meeting notes attached.", + cc: "manager@example.com, team@example.com", + }), + }, + { + prompt: "Send an email with an attachment", + call: JSON.stringify({ + to: "john@example.com", + subject: "Report", + body: "Please find the report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + replyTo: { + type: "string", + description: "Reply-to email address. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the email.", + }, + }, + required: ["to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + to, + subject, + body, + cc, + bcc, + htmlBody, + replyTo, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-send-email tool.`); + + if (!to || !subject) { + return "Error: 'to' and 'subject' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to email? ` + + `This file will be sent to ${to}.`, + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Send email to "${to}" with subject "${subject}"${attachmentNote} - This will send immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending email to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (replyTo) options.replyTo = replyTo; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.sendEmail( + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send email - ${result.error}` + ); + return `Error sending email: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully sent email to ${to}` + ); + + return ( + `Successfully sent email:\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (cc ? `CC: ${cc}\n` : "") + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe email has been sent.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-send-email error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error sending email: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js new file mode 100644 index 0000000..3b95ac4 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMarkRead = { + name: "gmail-mark-read", + plugin: function () { + return { + name: "gmail-mark-read", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Mark an email thread as read in Gmail. " + + "This will mark all messages in the thread as read.", + examples: [ + { + prompt: "Mark thread 18abc123def as read", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to mark as read.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-mark-read tool.`); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Mark Gmail thread "${threadId}" as read`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Marking thread ${threadId} as read` + ); + + const result = await gmailLib.markRead(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to mark thread as read - ${result.error}` + ); + return `Error marking thread as read: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully marked thread ${threadId} as read` + ); + + return `Successfully marked thread ${threadId} as read.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-mark-read error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error marking thread as read: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js new file mode 100644 index 0000000..ada7013 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMarkUnread = { + name: "gmail-mark-unread", + plugin: function () { + return { + name: "gmail-mark-unread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Mark an email thread as unread in Gmail. " + + "This will mark the thread as unread so it appears as a new message.", + examples: [ + { + prompt: "Mark thread 18abc123def as unread", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to mark as unread.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-mark-unread tool.`); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Mark Gmail thread "${threadId}" as unread`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Marking thread ${threadId} as unread` + ); + + const result = await gmailLib.markUnread(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to mark thread as unread - ${result.error}` + ); + return `Error marking thread as unread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully marked thread ${threadId} as unread` + ); + + return `Successfully marked thread ${threadId} as unread.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-mark-unread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error marking thread as unread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js new file mode 100644 index 0000000..b275b90 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToArchive = { + name: "gmail-move-to-archive", + plugin: function () { + return { + name: "gmail-move-to-archive", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Archive an email thread in Gmail. " + + "The thread will be removed from inbox but can still be found in All Mail or by searching.", + examples: [ + { + prompt: "Archive thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to archive.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-archive tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Archive Gmail thread "${threadId}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Archiving thread ${threadId}` + ); + + const result = await gmailLib.moveToArchive(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to archive thread - ${result.error}` + ); + return `Error archiving thread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully archived thread ${threadId}` + ); + + return `Successfully archived thread ${threadId}. It can still be found in All Mail or by searching.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-archive error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error archiving thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js new file mode 100644 index 0000000..76b7b86 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToInbox = { + name: "gmail-move-to-inbox", + plugin: function () { + return { + name: "gmail-move-to-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Move an email thread back to inbox in Gmail. " + + "Use this to unarchive a thread or move it from other locations to inbox.", + examples: [ + { + prompt: "Move thread 18abc123def to inbox", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to move to inbox.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-inbox tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Move Gmail thread "${threadId}" to inbox`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Moving thread ${threadId} to inbox` + ); + + const result = await gmailLib.moveToInbox(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to move thread to inbox - ${result.error}` + ); + return `Error moving thread to inbox: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully moved thread ${threadId} to inbox` + ); + + return `Successfully moved thread ${threadId} to inbox.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-inbox error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error moving thread to inbox: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js new file mode 100644 index 0000000..fe65ae0 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToTrash = { + name: "gmail-move-to-trash", + plugin: function () { + return { + name: "gmail-move-to-trash", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Move an email thread to trash in Gmail. " + + "The thread can be recovered from trash within 30 days.", + examples: [ + { + prompt: "Move thread 18abc123def to trash", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to move to trash.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-trash tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Move Gmail thread "${threadId}" to trash`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Moving thread ${threadId} to trash` + ); + + const result = await gmailLib.moveToTrash(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to move thread to trash - ${result.error}` + ); + return `Error moving thread to trash: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully moved thread ${threadId} to trash` + ); + + return `Successfully moved thread ${threadId} to trash. It can be recovered within 30 days.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-trash error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error moving thread to trash: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js new file mode 100644 index 0000000..3b1b649 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js @@ -0,0 +1,79 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetCalendar = { + name: "gcal-get-calendar", + plugin: function () { + return { + name: "gcal-get-calendar", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get detailed information about a specific Google Calendar by its ID. " + + "Returns calendar name, description, time zone, and settings.", + examples: [ + { + prompt: "Get details for my work calendar", + call: JSON.stringify({ + calendarId: "work@group.calendar.google.com", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + calendarId: { + type: "string", + description: + "The calendar ID. If omitted, returns the primary calendar.", + }, + }, + additionalProperties: false, + }, + handler: async function ({ calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-get-calendar tool.`); + this.super.introspect( + `${this.caller}: Fetching calendar details${calendarId ? ` for ${calendarId}` : " for primary calendar"}...` + ); + + const result = await googleCalendarLib.getCalendar(calendarId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get calendar - ${result.error}` + ); + return `Error getting calendar: ${result.error}`; + } + + const cal = result.data; + this.super.introspect( + `${this.caller}: Retrieved calendar "${cal.name}"` + ); + + return ( + `Calendar Details:\n` + + `Name: ${cal.name}\n` + + `ID: ${cal.calendarId}\n` + + `Description: ${cal.description || "(none)"}\n` + + `Time Zone: ${cal.timeZone}\n` + + `Primary: ${cal.isPrimary ? "Yes" : "No"}\n` + + `Owned by me: ${cal.isOwnedByMe ? "Yes" : "No"}\n` + + `Hidden: ${cal.isHidden ? "Yes" : "No"}\n` + + `Selected: ${cal.isSelected ? "Yes" : "No"}` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-get-calendar error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting calendar: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js new file mode 100644 index 0000000..019eef6 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js @@ -0,0 +1,82 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalListCalendars = { + name: "gcal-list-calendars", + plugin: function () { + return { + name: "gcal-list-calendars", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all Google Calendars the user owns or is subscribed to. " + + "Returns calendar names, IDs, time zones, and ownership information. " + + "Use this to discover available calendars before querying events.", + examples: [ + { + prompt: "What calendars do I have?", + call: JSON.stringify({}), + }, + { + prompt: "Show me all my Google Calendars", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the gcal-list-calendars tool.` + ); + this.super.introspect( + `${this.caller}: Fetching list of Google Calendars...` + ); + + const result = await googleCalendarLib.listCalendars(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list calendars - ${result.error}` + ); + return `Error listing calendars: ${result.error}`; + } + + const { totalCalendars, calendars } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalCalendars} calendar(s)` + ); + + if (totalCalendars === 0) { + return "No calendars found."; + } + + const summary = calendars + .map( + (cal, i) => + `${i + 1}. "${cal.name}"${cal.isPrimary ? " (Primary)" : ""}\n` + + ` ID: ${cal.calendarId}\n` + + ` Time Zone: ${cal.timeZone}\n` + + ` Owned by me: ${cal.isOwnedByMe ? "Yes" : "No"}` + ) + .join("\n\n"); + + return `Found ${totalCalendars} calendar(s):\n\n${summary}`; + } catch (e) { + this.super.handlerProps.log( + `gcal-list-calendars error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error listing calendars: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js new file mode 100644 index 0000000..3853471 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js @@ -0,0 +1,233 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalCreateEvent = { + name: "gcal-create-event", + plugin: function () { + return { + name: "gcal-create-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a calendar event with full control over all event properties. " + + "Supports timed events, all-day events, and recurring events. " + + "For simple events, consider using gcal-quick-add instead.", + examples: [ + { + prompt: + "Create a meeting called 'Team Standup' tomorrow from 9am to 9:30am", + call: JSON.stringify({ + title: "Team Standup", + startTime: "2025-01-16T09:00:00", + endTime: "2025-01-16T09:30:00", + }), + }, + { + prompt: "Create an all-day event for my birthday on March 15th", + call: JSON.stringify({ + title: "My Birthday", + allDay: true, + date: "2025-03-15", + }), + }, + { + prompt: "Create a weekly team meeting every Monday at 10am", + call: JSON.stringify({ + title: "Weekly Team Meeting", + startTime: "2025-01-20T10:00:00", + endTime: "2025-01-20T11:00:00", + recurrence: { + frequency: "weekly", + daysOfWeek: ["MONDAY"], + }, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + title: { + type: "string", + description: "The event title.", + }, + startTime: { + type: "string", + description: + "Start time in ISO datetime format (for timed events).", + }, + endTime: { + type: "string", + description: + "End time in ISO datetime format (for timed events).", + }, + allDay: { + type: "boolean", + description: "Set to true for all-day events.", + }, + date: { + type: "string", + description: + "Date in YYYY-MM-DD format (required for all-day events).", + }, + endDate: { + type: "string", + description: + "End date for multi-day all-day events (YYYY-MM-DD).", + }, + description: { + type: "string", + description: "Event description/notes.", + }, + location: { + type: "string", + description: "Event location.", + }, + guests: { + type: "array", + items: { type: "string" }, + description: "Array of guest email addresses.", + }, + sendInvites: { + type: "boolean", + description: "Whether to send invite emails to guests.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + recurrence: { + type: "object", + description: "Recurrence configuration for recurring events.", + properties: { + frequency: { + type: "string", + enum: ["daily", "weekly", "monthly", "yearly"], + description: "How often the event repeats.", + }, + interval: { + type: "number", + description: "Repeat every N periods (default 1).", + }, + count: { + type: "number", + description: "Number of occurrences.", + }, + until: { + type: "string", + description: "ISO date to end recurrence.", + }, + daysOfWeek: { + type: "array", + items: { type: "string" }, + description: + "For weekly recurrence: days like ['MONDAY', 'WEDNESDAY'].", + }, + }, + }, + }, + required: ["title"], + additionalProperties: false, + }, + handler: async function (params) { + try { + this.super.handlerProps.log(`Using the gcal-create-event tool.`); + + if (this.super.requestToolApproval) { + let timeInfo; + if (params.allDay) { + timeInfo = `All-day event on ${params.date}`; + if (params.endDate) timeInfo += ` to ${params.endDate}`; + } else { + timeInfo = `${params.startTime} - ${params.endTime}`; + } + const guestInfo = + params.guests?.length > 0 + ? ` with ${params.guests.length} guest(s): ${params.guests.join(", ")}` + : ""; + const recurrenceInfo = params.recurrence + ? ` (recurring: ${params.recurrence.frequency})` + : ""; + + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + title: params.title, + time: timeInfo, + guests: params.guests || [], + location: params.location, + recurrence: params.recurrence, + }, + description: `Create calendar event "${params.title}" - ${timeInfo}${guestInfo}${recurrenceInfo}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating event "${params.title}"...` + ); + + const result = await googleCalendarLib.createEvent(params); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create event - ${result.error}` + ); + return `Error creating event: ${result.error}`; + } + + const isRecurring = !!result.data.eventSeries; + const eventData = result.data.event || result.data.eventSeries; + + this.super.introspect( + `${this.caller}: Created ${isRecurring ? "recurring " : ""}event "${eventData.title}"` + ); + + let timeInfo; + if (params.allDay) { + timeInfo = `All-day event on ${params.date}`; + if (params.endDate) { + timeInfo += ` to ${params.endDate}`; + } + } else { + timeInfo = `${new Date(params.startTime).toLocaleString()} - ${new Date(params.endTime).toLocaleString()}`; + } + + let response = + `Event created successfully!\n\n` + + `Title: ${eventData.title}\n` + + `${timeInfo}\n` + + `${isRecurring ? "Event Series" : "Event"} ID: ${isRecurring ? eventData.eventSeriesId : eventData.eventId}\n` + + `Calendar ID: ${eventData.calendarId}`; + + if (params.location) { + response += `\nLocation: ${params.location}`; + } + if (params.guests?.length > 0) { + response += `\nGuests: ${params.guests.join(", ")}`; + } + if (isRecurring) { + response += `\nRecurrence: ${params.recurrence.frequency}`; + } + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-create-event error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js new file mode 100644 index 0000000..3aa3a3f --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js @@ -0,0 +1,116 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEvent = { + name: "gcal-get-event", + plugin: function () { + return { + name: "gcal-get-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get detailed information about a specific calendar event by its ID. " + + "Returns event title, time, location, description, guests, and RSVP status.", + examples: [ + { + prompt: "Get details for event abc123", + call: JSON.stringify({ eventId: "abc123" }), + }, + { + prompt: "Show me the meeting details for event xyz789", + call: JSON.stringify({ eventId: "xyz789" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The event ID to retrieve.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["eventId"], + additionalProperties: false, + }, + handler: async function ({ eventId, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-get-event tool.`); + this.super.introspect( + `${this.caller}: Fetching event ${eventId}...` + ); + + const result = await googleCalendarLib.getEvent( + eventId, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get event - ${result.error}` + ); + return `Error getting event: ${result.error}`; + } + + const event = result.data; + this.super.introspect( + `${this.caller}: Retrieved event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event: ${new Date(event.startDate).toLocaleDateString()}`; + if (event.endDate) { + timeInfo += ` to ${new Date(event.endDate).toLocaleDateString()}`; + } + } else { + timeInfo = `Time: ${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + const guestList = + event.guests?.length > 0 + ? event.guests + .map((g) => ` - ${g.name || g.email} (${g.status})`) + .join("\n") + : " (none)"; + + const eventDetails = + `Event Details:\n` + + `Title: ${event.title}\n` + + `Event ID: ${event.eventId}\n` + + `Calendar ID: ${event.calendarId}\n` + + `${timeInfo}\n` + + `Location: ${event.location || "(none)"}\n` + + `Description: ${event.description || "(none)"}\n` + + `Recurring: ${event.isRecurringEvent ? "Yes" : "No"}\n` + + `My Status: ${event.myStatus}\n` + + `Owned by me: ${event.isOwnedByMe ? "Yes" : "No"}\n` + + `Guests:\n${guestList}\n` + + `Created: ${new Date(event.dateCreated).toLocaleString()}\n` + + `Last Updated: ${new Date(event.lastUpdated).toLocaleString()}`; + + this.super.addCitation?.({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + return eventDetails; + } catch (e) { + this.super.handlerProps.log(`gcal-get-event error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error getting event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js new file mode 100644 index 0000000..d76c46e --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js @@ -0,0 +1,117 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEventsForDay = { + name: "gcal-get-events-for-day", + plugin: function () { + return { + name: "gcal-get-events-for-day", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get all calendar events for a specific day. " + + "Returns event titles, times, and IDs for the specified date.", + examples: [ + { + prompt: "What events do I have on January 15th, 2025?", + call: JSON.stringify({ date: "2025-01-15" }), + }, + { + prompt: "Show my schedule for March 20th", + call: JSON.stringify({ date: "2025-03-20" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + date: { + type: "string", + description: "The date to query in ISO format (YYYY-MM-DD).", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["date"], + additionalProperties: false, + }, + handler: async function ({ date, calendarId }) { + try { + this.super.handlerProps.log( + `Using the gcal-get-events-for-day tool.` + ); + this.super.introspect( + `${this.caller}: Fetching events for ${date}...` + ); + + const result = await googleCalendarLib.getEventsForDay( + date, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { eventCount, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${eventCount} event(s) for ${date}` + ); + + if (eventCount === 0) return `No events scheduled for ${date}.`; + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = "All day"; + } else { + const start = new Date(event.startTime).toLocaleTimeString( + [], + { hour: "2-digit", minute: "2-digit" } + ); + const end = new Date(event.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${start} - ${end}`; + } + const eventDetails = + `${i + 1}. "${event.title}" (${timeStr})\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + return `Events for ${date} (${eventCount} total):\n\n${summary}`; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-events-for-day error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js new file mode 100644 index 0000000..d633bd0 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js @@ -0,0 +1,159 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEvents = { + name: "gcal-get-events", + plugin: function () { + return { + name: "gcal-get-events", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get calendar events within a date range, optionally filtered by search query. " + + "Returns event titles, times, and IDs. Use this for custom date ranges.", + examples: [ + { + prompt: "Find all meetings next week", + call: JSON.stringify({ + startDate: "2025-01-20T00:00:00", + endDate: "2025-01-27T23:59:59", + query: "meeting", + limit: 25, + }), + }, + { + prompt: "Show events from January 1st to January 31st", + call: JSON.stringify({ + startDate: "2025-01-01T00:00:00", + endDate: "2025-01-31T23:59:59", + limit: 50, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + startDate: { + type: "string", + description: "Start of date range in ISO datetime format.", + }, + endDate: { + type: "string", + description: "End of date range in ISO datetime format.", + }, + query: { + type: "string", + description: "Optional text to search for in events.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + limit: { + type: "number", + description: "Max results to return (default 25, max 100).", + default: 25, + }, + }, + required: ["startDate", "endDate"], + additionalProperties: false, + }, + handler: async function ({ + startDate, + endDate, + query, + calendarId, + limit = 25, + }) { + try { + this.super.handlerProps.log(`Using the gcal-get-events tool.`); + this.super.introspect( + `${this.caller}: Fetching events from ${startDate} to ${endDate}${query ? ` matching "${query}"` : ""}...` + ); + + const result = await googleCalendarLib.getEvents( + startDate, + endDate, + calendarId, + query, + limit + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { totalEvents, returnedEvents, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalEvents} event(s), returning ${returnedEvents}` + ); + + if (totalEvents === 0) { + return `No events found between ${startDate} and ${endDate}${query ? ` matching "${query}"` : ""}.`; + } + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + const startTime = new Date(event.startTime); + const endTime = new Date(event.endTime); + const dateStr = startTime.toLocaleDateString(); + const startTimeStr = startTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTimeStr = endTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + + let response = `Found ${totalEvents} event(s)`; + if (returnedEvents < totalEvents) { + response += ` (showing ${returnedEvents})`; + } + response += `:\n\n${summary}`; + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-events error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js new file mode 100644 index 0000000..a0b4ea0 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js @@ -0,0 +1,249 @@ +const googleCalendarLib = require("../lib.js"); + +/** + * Helper to compute date ranges for common time periods. + * @param {string} period - "today", "week", or "month" + * @returns {{startDate: string, endDate: string, label: string}} + */ +function getDateRangeForPeriod(period) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let startDate, endDate, label; + + switch (period.toLowerCase()) { + case "today": + startDate = new Date(today); + endDate = new Date(today); + endDate.setHours(23, 59, 59, 999); + label = "today"; + break; + + case "tomorrow": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 1); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + label = "tomorrow"; + break; + + case "week": + case "this week": + startDate = new Date(today); + endDate = new Date(today); + endDate.setDate(endDate.getDate() + 7); + endDate.setHours(23, 59, 59, 999); + label = "the next 7 days"; + break; + + case "next week": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 7); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 7); + endDate.setHours(23, 59, 59, 999); + label = "next week"; + break; + + case "month": + case "this month": + startDate = new Date(today); + endDate = new Date(today); + endDate.setDate(endDate.getDate() + 30); + endDate.setHours(23, 59, 59, 999); + label = "the next 30 days"; + break; + + case "next month": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 30); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 30); + endDate.setHours(23, 59, 59, 999); + label = "next month"; + break; + + default: + throw new Error( + `Invalid period: "${period}". Use "today", "tomorrow", "week", "this week", "next week", "month", "this month", or "next month".` + ); + } + + return { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + label, + }; +} + +module.exports.GCalGetUpcomingEvents = { + name: "gcal-get-upcoming-events", + plugin: function () { + return { + name: "gcal-get-upcoming-events", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get upcoming calendar events using simple time period keywords. " + + "This is the preferred tool for common queries like 'what's on my calendar today?' " + + "Supports periods: 'today', 'tomorrow', 'week' (next 7 days), 'month' (next 30 days), " + + "'next week', and 'next month'. " + + "Automatically calculates the correct date range so you don't need to know the current date.", + examples: [ + { + prompt: "What's on my calendar today?", + call: JSON.stringify({ period: "today" }), + }, + { + prompt: "What meetings do I have this week?", + call: JSON.stringify({ period: "week", query: "meeting" }), + }, + { + prompt: "Show me my schedule for the month", + call: JSON.stringify({ period: "month", limit: 50 }), + }, + { + prompt: "What do I have tomorrow?", + call: JSON.stringify({ period: "tomorrow" }), + }, + { + prompt: "Find all project events next week", + call: JSON.stringify({ period: "next week", query: "project" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + period: { + type: "string", + description: + "Time period to query. Options: 'today', 'tomorrow', 'week' (next 7 days), 'this week', 'next week', 'month' (next 30 days), 'this month', 'next month'.", + enum: [ + "today", + "tomorrow", + "week", + "this week", + "next week", + "month", + "this month", + "next month", + ], + }, + query: { + type: "string", + description: "Optional text to search for in events.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + limit: { + type: "number", + description: "Max results to return (default 25, max 100).", + default: 25, + }, + }, + required: ["period"], + additionalProperties: false, + }, + handler: async function ({ period, query, calendarId, limit = 25 }) { + try { + this.super.handlerProps.log( + `Using the gcal-get-upcoming-events tool.` + ); + + const { startDate, endDate, label } = + getDateRangeForPeriod(period); + + this.super.introspect( + `${this.caller}: Fetching events for ${label}${query ? ` matching "${query}"` : ""}...` + ); + + const result = await googleCalendarLib.getEvents( + startDate, + endDate, + calendarId, + query, + limit + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { totalEvents, returnedEvents, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalEvents} event(s) for ${label}` + ); + + if (totalEvents === 0) { + return `No events scheduled for ${label}${query ? ` matching "${query}"` : ""}.`; + } + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + const startTime = new Date(event.startTime); + const endTime = new Date(event.endTime); + const dateStr = startTime.toLocaleDateString(); + const startTimeStr = startTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTimeStr = endTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + + let response = `Events for ${label}`; + if (returnedEvents < totalEvents) { + response += ` (${returnedEvents} of ${totalEvents})`; + } else { + response += ` (${totalEvents} total)`; + } + response += `:\n\n${summary}`; + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-upcoming-events error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js new file mode 100644 index 0000000..3729721 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js @@ -0,0 +1,114 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalQuickAdd = { + name: "gcal-quick-add", + plugin: function () { + return { + name: "gcal-quick-add", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a calendar event from a natural language description. " + + "Google Calendar will parse the description to extract the event title, date, and time. " + + "Examples: 'Meeting with John tomorrow at 3pm', 'Dentist appointment on Friday at 10am'.", + examples: [ + { + prompt: "Add a meeting with John tomorrow at 3pm", + call: JSON.stringify({ + description: "Meeting with John tomorrow at 3pm", + }), + }, + { + prompt: "Schedule lunch with Sarah next Tuesday at noon", + call: JSON.stringify({ + description: "Lunch with Sarah next Tuesday at noon", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + description: { + type: "string", + description: + "Natural language description of the event including title, date, and time.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["description"], + additionalProperties: false, + }, + handler: async function ({ description, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-quick-add tool.`); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + description, + calendarId, + }, + description: `Create calendar event from: "${description}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating event from "${description}"...` + ); + + const result = await googleCalendarLib.quickAdd( + description, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create event - ${result.error}` + ); + return `Error creating event: ${result.error}`; + } + + const event = result.data.event; + this.super.introspect( + `${this.caller}: Created event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event on ${new Date(event.startDate).toLocaleDateString()}`; + } else { + timeInfo = `${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + return ( + `Event created successfully!\n\n` + + `Title: ${event.title}\n` + + `${timeInfo}\n` + + `Event ID: ${event.eventId}\n` + + `Calendar ID: ${event.calendarId}` + ); + } catch (e) { + this.super.handlerProps.log(`gcal-quick-add error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error creating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js new file mode 100644 index 0000000..3de6f59 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js @@ -0,0 +1,125 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalSetMyStatus = { + name: "gcal-set-my-status", + plugin: function () { + return { + name: "gcal-set-my-status", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Set your RSVP status for a calendar event. " + + "Use this to accept, decline, or tentatively accept meeting invitations.", + examples: [ + { + prompt: "Accept the meeting invitation for event abc123", + call: JSON.stringify({ eventId: "abc123", status: "YES" }), + }, + { + prompt: "Decline event xyz789", + call: JSON.stringify({ eventId: "xyz789", status: "NO" }), + }, + { + prompt: "Mark myself as maybe for event def456", + call: JSON.stringify({ eventId: "def456", status: "MAYBE" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to RSVP to.", + }, + status: { + type: "string", + enum: ["YES", "NO", "MAYBE", "INVITED"], + description: + "Your RSVP status: YES (accept), NO (decline), MAYBE (tentative), or INVITED (reset to invited).", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["eventId", "status"], + additionalProperties: false, + }, + handler: async function ({ eventId, status, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-set-my-status tool.`); + + const statusActions = { + YES: "accept", + NO: "decline", + MAYBE: "tentatively accept", + INVITED: "reset to invited", + }; + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + eventId, + status, + }, + description: `Set RSVP status to ${status} (${statusActions[status] || status}) for event ${eventId}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Setting RSVP status to ${status} for event ${eventId}...` + ); + + const result = await googleCalendarLib.setMyStatus( + eventId, + status, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to set status - ${result.error}` + ); + return `Error setting RSVP status: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: RSVP status set to ${result.data.newStatus}` + ); + + const statusMessages = { + YES: "accepted", + NO: "declined", + MAYBE: "marked as tentative", + INVITED: "reset to invited", + }; + + return ( + `RSVP status updated!\n\n` + + `Event ID: ${result.data.eventId}\n` + + `Status: ${result.data.newStatus} (${statusMessages[result.data.newStatus] || result.data.newStatus})` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-set-my-status error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error setting RSVP status: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js new file mode 100644 index 0000000..e2e01a1 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js @@ -0,0 +1,173 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalUpdateEvent = { + name: "gcal-update-event", + plugin: function () { + return { + name: "gcal-update-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing calendar event. " + + "You can change the title, description, location, time, or guest list. " + + "Only provide the fields you want to update.", + examples: [ + { + prompt: "Change the title of event abc123 to 'Updated Meeting'", + call: JSON.stringify({ + eventId: "abc123", + title: "Updated Meeting", + }), + }, + { + prompt: "Move event xyz789 to 3pm", + call: JSON.stringify({ + eventId: "xyz789", + startTime: "2025-01-15T15:00:00", + endTime: "2025-01-15T16:00:00", + }), + }, + { + prompt: "Add a location to event abc123", + call: JSON.stringify({ + eventId: "abc123", + location: "Conference Room A", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to update.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + title: { + type: "string", + description: "New event title.", + }, + description: { + type: "string", + description: "New event description.", + }, + location: { + type: "string", + description: "New event location.", + }, + startTime: { + type: "string", + description: "New start time in ISO datetime format.", + }, + endTime: { + type: "string", + description: "New end time in ISO datetime format.", + }, + guests: { + type: "array", + items: { type: "string" }, + description: + "New guest list (replaces existing guests). Array of email addresses.", + }, + }, + required: ["eventId"], + additionalProperties: false, + }, + handler: async function ({ + eventId, + calendarId, + title, + description, + location, + startTime, + endTime, + guests, + }) { + try { + this.super.handlerProps.log(`Using the gcal-update-event tool.`); + + const updates = {}; + if (title !== undefined) updates.title = title; + if (description !== undefined) updates.description = description; + if (location !== undefined) updates.location = location; + if (startTime !== undefined) updates.startTime = startTime; + if (endTime !== undefined) updates.endTime = endTime; + if (guests !== undefined) updates.guests = guests; + + if (this.super.requestToolApproval) { + const updatedFields = Object.keys(updates).join(", "); + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + eventId, + updates, + }, + description: `Update calendar event ${eventId} - changing: ${updatedFields}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating event ${eventId}...` + ); + + const result = await googleCalendarLib.updateEvent( + eventId, + calendarId, + updates + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update event - ${result.error}` + ); + return `Error updating event: ${result.error}`; + } + + const event = result.data.event; + this.super.introspect( + `${this.caller}: Updated event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event on ${new Date(event.startDate).toLocaleDateString()}`; + } else { + timeInfo = `${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + const updatedFields = Object.keys(updates).join(", "); + + return ( + `Event updated successfully!\n\n` + + `Updated fields: ${updatedFields}\n\n` + + `Title: ${event.title}\n` + + `${timeInfo}\n` + + `Location: ${event.location || "(none)"}\n` + + `Event ID: ${event.eventId}` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-update-event error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error updating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/index.js b/server/utils/agents/aibitat/plugins/google-calendar/index.js new file mode 100644 index 0000000..3aa04cf --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/index.js @@ -0,0 +1,40 @@ +const { GCalListCalendars } = require("./calendars/gcal-list-calendars.js"); +const { GCalGetCalendar } = require("./calendars/gcal-get-calendar.js"); +const { GCalGetEvent } = require("./events/gcal-get-event.js"); +const { GCalGetEventsForDay } = require("./events/gcal-get-events-for-day.js"); +const { GCalGetEvents } = require("./events/gcal-get-events.js"); +const { + GCalGetUpcomingEvents, +} = require("./events/gcal-get-upcoming-events.js"); +const { GCalQuickAdd } = require("./events/gcal-quick-add.js"); +const { GCalCreateEvent } = require("./events/gcal-create-event.js"); +const { GCalUpdateEvent } = require("./events/gcal-update-event.js"); +const { GCalSetMyStatus } = require("./events/gcal-set-my-status.js"); + +const googleCalendarAgent = { + name: "google-calendar-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Calendars (read-only) + GCalListCalendars, + GCalGetCalendar, + + // Events - Read (read-only) + GCalGetEvent, + GCalGetEventsForDay, + GCalGetEvents, + GCalGetUpcomingEvents, + + // Events - Write (modifying) + GCalQuickAdd, + GCalCreateEvent, + GCalUpdateEvent, + GCalSetMyStatus, + ], +}; + +module.exports = { + googleCalendarAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/lib.js b/server/utils/agents/aibitat/plugins/google-calendar/lib.js new file mode 100644 index 0000000..181af47 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/lib.js @@ -0,0 +1,288 @@ +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { safeJsonParse } = require("../../../../http"); + +/** + * Google Calendar Bridge Library + * Handles communication with the AnythingLLM Google Calendar Google Apps Script deployment. + */ +class GoogleCalendarBridge { + #deploymentId = null; + #apiKey = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[35m[GoogleCalendarBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + * Call this when configuration changes (e.g., deployment ID updated). + */ + reset() { + this.#deploymentId = null; + this.#apiKey = null; + this.#isInitialized = false; + } + + /** + * Gets the current Google Calendar agent configuration from system settings. + * @returns {Promise<{deploymentId?: string, apiKey?: string}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "google_calendar_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Google Calendar agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + google_calendar_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Initializes the Google Calendar bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Google Calendar integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await GoogleCalendarBridge.getConfig(); + if (!config.deploymentId || !config.apiKey) { + return { + success: false, + error: + "Google Calendar integration is not configured. Please set the Deployment ID and API Key in the agent settings.", + }; + } + + this.#deploymentId = config.deploymentId; + this.#apiKey = config.apiKey; + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Google Calendar bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Google Calendar tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await GoogleCalendarBridge.getConfig(); + return !!(config.deploymentId && config.apiKey); + } + + get maskedDeploymentId() { + if (!this.#deploymentId) return "(not configured)"; + return ( + this.#deploymentId.substring(0, 5) + + "..." + + this.#deploymentId.substring(this.#deploymentId.length - 5) + ); + } + + /** + * Gets the base URL for the Google Calendar Google Apps Script deployment. + * @returns {string} + */ + #getBaseUrl() { + this.#log(`Getting base URL for deployment ID ${this.maskedDeploymentId}`); + return `https://script.google.com/macros/s/${this.#deploymentId}/exec`; + } + + /** + * Makes a request to the Google Calendar Google Apps Script API. + * @param {string} action - The action to perform + * @param {object} params - Additional parameters for the action + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(action, params = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + return { success: false, error: initResult.error }; + } + + try { + const response = await fetch(this.#getBaseUrl(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-AnythingLLM-UA": "AnythingLLM-GoogleCalendar-Agent/1.0", + }, + body: JSON.stringify({ + key: this.#apiKey, + action, + ...params, + }), + }); + + if (!response.ok) { + return { + success: false, + error: `Google Calendar API request failed with status ${response.status}`, + }; + } + + const result = await response.json(); + + if (result.status === "error") { + return { success: false, error: result.error }; + } + + return { success: true, data: result.data }; + } catch (error) { + return { + success: false, + error: `Google Calendar API request failed: ${error.message}`, + }; + } + } + + /** + * List all calendars the user owns or is subscribed to. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listCalendars() { + return this.request("list_calendars"); + } + + /** + * Get details of a specific calendar by ID. + * @param {string} calendarId - The calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getCalendar(calendarId) { + return this.request("get_calendar", { calendarId }); + } + + /** + * Get a single event by ID. + * @param {string} eventId - The event ID + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEvent(eventId, calendarId) { + return this.request("get_event", { eventId, calendarId }); + } + + /** + * Get all events for a specific day. + * @param {string} date - ISO date string + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEventsForDay(date, calendarId) { + return this.request("get_events_for_day", { date, calendarId }); + } + + /** + * Get events within a date range, optionally filtered by search query. + * @param {string} startDate - ISO datetime string + * @param {string} endDate - ISO datetime string + * @param {string} calendarId - Optional calendar ID + * @param {string} query - Optional search query + * @param {number} limit - Max results (default 25, max 100) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEvents(startDate, endDate, calendarId, query, limit = 25) { + return this.request("get_events", { + startDate, + endDate, + calendarId, + query, + limit, + }); + } + + /** + * Create event from natural language description. + * @param {string} description - Natural language description + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async quickAdd(description, calendarId) { + return this.request("quick_add", { description, calendarId }); + } + + /** + * Create a single or recurring event (timed or all-day). + * @param {object} eventData - Event creation data + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createEvent(eventData) { + return this.request("create_event", eventData); + } + + /** + * Update an existing event. + * @param {string} eventId - The event ID + * @param {string} calendarId - Optional calendar ID + * @param {object} updates - Fields to update + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateEvent(eventId, calendarId, updates) { + return this.request("update_event", { + eventId, + calendarId, + ...updates, + }); + } + + /** + * Set your RSVP status for an event. + * @param {string} eventId - The event ID + * @param {string} status - RSVP status: "YES", "NO", "MAYBE", or "INVITED" + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async setMyStatus(eventId, status, calendarId) { + return this.request("set_my_status", { eventId, status, calendarId }); + } + + /** + * Get API version and available actions. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async version() { + return this.request("version"); + } +} + +module.exports = new GoogleCalendarBridge(); +module.exports.GoogleCalendarBridge = GoogleCalendarBridge; diff --git a/server/utils/agents/aibitat/plugins/http-socket.js b/server/utils/agents/aibitat/plugins/http-socket.js new file mode 100644 index 0000000..2ef479b --- /dev/null +++ b/server/utils/agents/aibitat/plugins/http-socket.js @@ -0,0 +1,251 @@ +const chalk = require("chalk"); +const { Telemetry } = require("../../../../models/telemetry"); +const { v4: uuidv4 } = require("uuid"); +const { skillIsAutoApproved } = require("../../../helpers/agents"); +const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval + +/** + * Get the IPC channel for worker communication. + * Bree workers use worker_threads internally but polyfill process.on("message") for receiving. + * Workers send via parentPort.postMessage(), receive via process.on("message"). + * @returns {{ send: Function, on: Function, removeListener: Function } | null} + */ +function getWorkerIPC() { + try { + const { parentPort } = require("node:worker_threads"); + if (parentPort) { + // Bree worker context: send via parentPort, receive via process (Bree polyfill) + return { + send: (msg) => parentPort.postMessage(msg), + on: (event, handler) => process.on(event, handler), + removeListener: (event, handler) => + process.removeListener(event, handler), + }; + } + } catch {} + + // Fallback for child_process workers + if (typeof process.send === "function") { + return { + send: (msg) => process.send(msg), + on: (event, handler) => process.on(event, handler), + removeListener: (event, handler) => + process.removeListener(event, handler), + }; + } + + return null; +} + +/** + * HTTP Interface plugin for Aibitat to emulate a websocket interface in the agent + * framework so we dont have to modify the interface for passing messages and responses + * in REST or WSS. + * + * When telegramChatId is provided, enables tool approval via Telegram inline keyboards + * using IPC messages to communicate with the parent TelegramBotService process. + */ +const httpSocket = { + name: "httpSocket", + startupConfig: { + params: { + handler: { + required: true, + }, + muteUserReply: { + required: false, + default: true, + }, + introspection: { + required: false, + default: true, + }, + telegramChatId: { + required: false, + default: null, + }, + }, + }, + plugin: function ({ + handler, + muteUserReply = true, // Do not post messages to "USER" back to frontend. + introspection = false, // when enabled will attach socket to Aibitat object with .introspect method which reports status updates to frontend. + telegramChatId = null, // When set, enables tool approval via Telegram IPC + }) { + return { + name: this.name, + setup(aibitat) { + aibitat.onError(async (error) => { + let errorMessage = + error?.message || "An error occurred while running the agent."; + console.error(chalk.red(` error: ${errorMessage}`), error); + aibitat.introspect( + `Error encountered while running: ${errorMessage}` + ); + handler.send( + JSON.stringify({ type: "wssFailure", content: errorMessage }) + ); + aibitat.terminate(); + }); + + aibitat.introspect = (messageText) => { + if (!introspection) return; // Dump thoughts when not wanted. + handler.send( + JSON.stringify({ type: "statusResponse", content: messageText }) + ); + }; + + // expose function for sockets across aibitat + // type param must be set or else msg will not be shown or handled in UI. + aibitat.socket = { + send: (type = "__unhandled", content = "") => { + handler.send(JSON.stringify({ type, content })); + }, + }; + + /** + * Request user approval before executing a tool/skill. + * Only available when running in Telegram context (telegramChatId is set). + * Sends IPC message to parent process which shows Telegram inline keyboard. + * + * @param {Object} options - The approval request options + * @param {string} options.skillName - The name of the skill/tool requesting approval + * @param {Object} [options.payload={}] - Optional payload data to display to the user + * @param {string} [options.description] - Optional description of what the skill will do + * @returns {Promise<{approved: boolean, message: string}>} - The approval result + */ + aibitat.requestToolApproval = async function ({ + skillName, + payload = {}, + description = null, + }) { + if (skillIsAutoApproved({ skillName })) { + return { + approved: true, + message: "Skill is auto-approved.", + }; + } + + const { + AgentSkillWhitelist, + } = require("../../../../models/agentSkillWhitelist"); + const isWhitelisted = await AgentSkillWhitelist.isWhitelisted( + skillName, + null + ); + if (isWhitelisted) { + console.log( + chalk.green(`Skill ${skillName} is whitelisted - auto-approved.`) + ); + return { + approved: true, + message: "Skill is whitelisted - auto-approved.", + }; + } + + // Tool approval only available in Telegram worker context + const ipc = getWorkerIPC(); + if (!telegramChatId || !ipc) { + console.log( + chalk.yellow( + `Tool approval requested for ${skillName} but no Telegram context available. Auto-denying for safety.` + ) + ); + return { + approved: false, + message: + "Tool approval is not available in this context. Operation denied.", + }; + } + + const requestId = uuidv4(); + console.log( + chalk.blue( + `Requesting tool approval for ${skillName} (${requestId})` + ) + ); + + // Send introspection message before the approval UI appears + aibitat.introspect( + `Requesting approval to execute: ${skillName}${description ? ` - ${description}` : ""}` + ); + + return new Promise((resolve) => { + let timeoutId = null; + + const messageHandler = (msg) => { + if (msg?.type !== "toolApprovalResponse") return; + if (msg?.requestId !== requestId) return; + + ipc.removeListener("message", messageHandler); + clearTimeout(timeoutId); + + if (msg.approved) { + console.log( + chalk.green(`Tool ${skillName} approved by user via Telegram`) + ); + return resolve({ + approved: true, + message: "User approved the tool execution.", + }); + } + + console.log( + chalk.yellow(`Tool ${skillName} denied by user via Telegram`) + ); + return resolve({ + approved: false, + message: "Tool call was rejected by the user.", + }); + }; + + ipc.on("message", messageHandler); + + // Send approval request to parent TelegramBotService process + ipc.send({ + type: "toolApprovalRequest", + requestId, + chatId: telegramChatId, + skillName, + payload, + description, + timeoutMs: TOOL_APPROVAL_TIMEOUT_MS, + }); + + timeoutId = setTimeout(() => { + ipc.removeListener("message", messageHandler); + console.log( + chalk.yellow( + `Tool approval request timed out after ${TOOL_APPROVAL_TIMEOUT_MS}ms` + ) + ); + resolve({ + approved: false, + message: + "Tool approval request timed out. User did not respond in time.", + }); + }, TOOL_APPROVAL_TIMEOUT_MS); + }); + }; + + // We can only receive one message response with HTTP + // so we end on first response. + aibitat.onMessage((message) => { + if (message.from !== "USER") + Telemetry.sendTelemetry("agent_chat_sent"); + if (message.from === "USER" && muteUserReply) return; + handler.send(JSON.stringify(message)); + handler.close(); + }); + + aibitat.onTerminate(() => { + handler.close(); + }); + }, + }; + }, +}; + +module.exports = { + httpSocket, +}; diff --git a/server/utils/agents/aibitat/plugins/index.js b/server/utils/agents/aibitat/plugins/index.js new file mode 100644 index 0000000..1434757 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/index.js @@ -0,0 +1,50 @@ +const { webBrowsing } = require("./web-browsing.js"); +const { webScraping } = require("./web-scraping.js"); +const { websocket } = require("./websocket.js"); +const { docSummarizer } = require("./summarize.js"); +const { chatHistory } = require("./chat-history.js"); +const { memory } = require("./memory.js"); +const { rechart } = require("./rechart.js"); +const { sqlAgent } = require("./sql-agent/index.js"); +const { filesystemAgent } = require("./filesystem/index.js"); +const { createFilesAgent } = require("./create-files/index.js"); +const { gmailAgent } = require("./gmail/index.js"); +const { outlookAgent } = require("./outlook/index.js"); +const { googleCalendarAgent } = require("./google-calendar/index.js"); +const { requestUserInput } = require("./request-user-input.js"); +const { createScheduledJob } = require("./create-scheduled-job/index.js"); + +module.exports = { + webScraping, + webBrowsing, + websocket, + docSummarizer, + chatHistory, + memory, + rechart, + sqlAgent, + filesystemAgent, + createFilesAgent, + gmailAgent, + outlookAgent, + googleCalendarAgent, + requestUserInput, + createScheduledJob, + + // Plugin name aliases so they can be pulled by slug as well. + [webScraping.name]: webScraping, + [webBrowsing.name]: webBrowsing, + [websocket.name]: websocket, + [docSummarizer.name]: docSummarizer, + [chatHistory.name]: chatHistory, + [memory.name]: memory, + [rechart.name]: rechart, + [sqlAgent.name]: sqlAgent, + [filesystemAgent.name]: filesystemAgent, + [createFilesAgent.name]: createFilesAgent, + [gmailAgent.name]: gmailAgent, + [outlookAgent.name]: outlookAgent, + [googleCalendarAgent.name]: googleCalendarAgent, + [requestUserInput.name]: requestUserInput, + [createScheduledJob.name]: createScheduledJob, +}; diff --git a/server/utils/agents/aibitat/plugins/memory.js b/server/utils/agents/aibitat/plugins/memory.js new file mode 100644 index 0000000..e3914c3 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/memory.js @@ -0,0 +1,169 @@ +const { v4 } = require("uuid"); +const { + getVectorDbClass, + resolveProviderConnector, +} = require("../../../helpers"); +const { Deduplicator } = require("../utils/dedupe"); + +const memory = { + name: "rag-memory", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup(aibitat) { + aibitat.function({ + super: aibitat, + tracker: new Deduplicator(), + name: this.name, + description: + "Search your local documents and workspace files for relevant information, or store information to long-term memory. Use search to find answers in uploaded documents, embedded files, or previously stored memories. Use store only when explicitly asked to remember or save something.", + examples: [ + { + prompt: "Check my files for information about the project", + call: JSON.stringify({ + action: "search", + content: "", + }), + }, + { + prompt: "What do you know about Plato's motives?", + call: JSON.stringify({ + action: "search", + content: "What are the facts about Plato's motives?", + }), + }, + { + prompt: "Remember that you are a robot", + call: JSON.stringify({ + action: "store", + content: "I am a robot, the user told me that i am.", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + action: { + type: "string", + enum: ["search", "store"], + description: + "The action we want to take to search for existing similar context or storage of new context.", + }, + content: { + type: "string", + description: + "The plain text to search our local documents with or to store in our vector database.", + }, + }, + additionalProperties: false, + }, + handler: async function ({ action = "", content = "" }) { + try { + const { isDuplicate } = this.tracker.isDuplicate(this.name, { + action, + content, + }); + if (isDuplicate) + return `This was a duplicated call and it's output will be ignored.`; + + let response = "There was nothing to do."; + if (action === "search") response = await this.search(content); + if (action === "store") response = await this.store(content); + + this.tracker.trackRun(this.name, { action, content }); + return response; + } catch (error) { + console.log(error); + return `There was an error while calling the function. ${error.message}`; + } + }, + search: async function (query = "") { + try { + const workspace = this.super.handlerProps.invocation.workspace; + const { connector: LLMConnector } = + await resolveProviderConnector({ + workspace, + prompt: query, + }); + const vectorDB = getVectorDbClass(); + const { contextTexts = [], sources = [] } = + await vectorDB.performSimilaritySearch({ + namespace: workspace.slug, + input: query, + LLMConnector, + topN: workspace?.topN ?? 4, + rerank: workspace?.vectorSearchMode === "rerank", + }); + + if (contextTexts.length === 0) { + this.super.introspect( + `${this.caller}: I didn't find anything locally that would help answer this question.` + ); + return "There was no additional context found for that query. We should search the web for this information."; + } + + this.super.introspect( + `${this.caller}: Found ${contextTexts.length} additional piece of context to help answer this question.` + ); + + this.super.addCitation?.(sources); + + let combinedText = "Additional context for query:\n"; + for (const text of contextTexts) combinedText += text + "\n\n"; + return combinedText; + } catch (error) { + this.super.handlerProps.log( + `memory.search raised an error. ${error.message}` + ); + return `An error was raised while searching the vector database. ${error.message}`; + } + }, + store: async function (content = "") { + try { + const workspace = this.super.handlerProps.invocation.workspace; + const vectorDB = getVectorDbClass(); + const { error } = await vectorDB.addDocumentToNamespace( + workspace.slug, + { + docId: v4(), + id: v4(), + url: "file://embed-via-agent.txt", + title: "agent-memory.txt", + docAuthor: "@agent", + description: "Unknown", + docSource: "a text file stored by the workspace agent.", + chunkSource: "", + published: new Date().toLocaleString(), + wordCount: content.split(" ").length, + pageContent: content, + token_count_estimate: 0, + }, + null + ); + + if (!!error) + return "The content was failed to be embedded properly."; + this.super.introspect( + `${this.caller}: I saved the content to long-term memory in this workspaces vector database.` + ); + return "The content given was successfully embedded. There is nothing else to do."; + } catch (error) { + this.super.handlerProps.log( + `memory.store raised an error. ${error.message}` + ); + return `Let the user know this action was not successful. An error was raised while storing data in the vector database. ${error.message}`; + } + }, + }); + }, + }; + }, +}; + +module.exports = { + memory, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js b/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js new file mode 100644 index 0000000..2013f49 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js @@ -0,0 +1,78 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookGetMailboxStats = { + name: "outlook-get-mailbox-stats", + plugin: function () { + return { + name: "outlook-get-mailbox-stats", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get Outlook mailbox statistics including folder counts and user profile information. " + + "Returns the total and unread counts for inbox, drafts, sent items, and deleted items.", + examples: [ + { + prompt: "How many unread emails do I have?", + call: JSON.stringify({}), + }, + { + prompt: "Show me my mailbox statistics", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the outlook-get-mailbox-stats tool.` + ); + this.super.introspect( + `${this.caller}: Getting Outlook mailbox statistics...` + ); + + const result = await outlookLib.getMailboxStats(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get mailbox stats - ${result.error}` + ); + return `Error getting mailbox statistics: ${result.error}`; + } + + const { email, displayName, folders } = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved mailbox statistics` + ); + + let folderStats = ""; + if (folders.inbox) + folderStats += `\nInbox: ${folders.inbox.total} total, ${folders.inbox.unread} unread`; + if (folders.drafts) + folderStats += `\nDrafts: ${folders.drafts.total} total`; + if (folders.sentitems) + folderStats += `\nSent Items: ${folders.sentitems.total} total`; + if (folders.deleteditems) + folderStats += `\nDeleted Items: ${folders.deleteditems.total} total`; + + return ( + `Outlook Mailbox Statistics:\n` + + `\nAccount: ${displayName} (${email})\n` + + `\nFolder Statistics:${folderStats}` + ); + } catch (e) { + return handleSkillError(this, "outlook-get-mailbox-stats", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js new file mode 100644 index 0000000..b07af29 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js @@ -0,0 +1,232 @@ +const outlookLib = require("../lib.js"); +const { prepareAttachmentsWithValidation, handleSkillError } = outlookLib; + +module.exports.OutlookCreateDraft = { + name: "outlook-create-draft", + plugin: function () { + return { + name: "outlook-create-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new draft email in Outlook. The draft will be saved but not sent. " + + "Can also create a draft reply to an existing message by providing replyToMessageId. " + + "You can optionally include CC, BCC recipients, HTML body content, and file attachments.", + examples: [ + { + prompt: + "Create a draft email to john@example.com about the meeting", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Tomorrow", + body: "Hi John,\n\nJust wanted to confirm our meeting tomorrow at 2pm.\n\nBest regards", + }), + }, + { + prompt: "Create a draft reply to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks for the update. I'll review and get back to you.", + }), + }, + { + prompt: "Create a draft reply-all to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks everyone for your input.", + replyAll: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + replyToMessageId: { + type: "string", + description: + "Message ID to reply to. If provided, creates a draft reply instead of a new draft. " + + "When replying, 'to' and 'subject' are optional (inherited from original).", + }, + replyAll: { + type: "boolean", + description: + "When replying, whether to reply to all recipients. Defaults to false.", + default: false, + }, + to: { + type: "string", + description: + "Recipient email address(es). Required for new drafts, optional for replies.", + }, + subject: { + type: "string", + description: + "Email subject line. Required for new drafts, optional for replies.", + }, + body: { + type: "string", + description: "Email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["body"], + additionalProperties: false, + }, + handler: async function ({ + replyToMessageId, + replyAll = false, + to, + subject, + body, + cc, + bcc, + isHtml, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the outlook-create-draft tool.` + ); + + const isReply = !!replyToMessageId; + + if (!isReply && (!to || !subject)) { + return "Error: 'to' and 'subject' are required for new drafts. For draft replies, provide 'replyToMessageId'."; + } + + if (!body) { + return "Error: 'body' is required."; + } + + const attachmentResult = await prepareAttachmentsWithValidation( + this, + attachments + ); + if (!attachmentResult.success) { + return `Error with attachment: ${attachmentResult.error}`; + } + const { + attachments: preparedAttachments, + summaries: attachmentSummaries, + } = attachmentResult; + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const description = isReply + ? `Create draft ${replyAll ? "reply-all" : "reply"} to message ${replyToMessageId}${attachmentNote}` + : `Create Outlook draft to "${to}" with subject "${subject}"${attachmentNote}`; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: isReply + ? { replyToMessageId, replyAll } + : { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + let result; + if (isReply) { + this.super.introspect( + `${this.caller}: Creating draft ${replyAll ? "reply-all" : "reply"} to message...` + ); + result = await outlookLib.createDraftReply( + replyToMessageId, + body, + replyAll + ); + } else { + this.super.introspect( + `${this.caller}: Creating Outlook draft to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = { isHtml }; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + result = await outlookLib.createDraft( + to, + subject, + body, + options + ); + } + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft - ${result.error}` + ); + return `Error creating draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft (ID: ${draft.draftId})` + ); + + if (isReply) { + return ( + `Successfully created draft ${replyAll ? "reply-all" : "reply"}:\n` + + `Draft ID: ${draft.draftId}\n` + + `Subject: ${draft.subject}\n` + + `Type: ${replyAll ? "Reply All" : "Reply"}\n` + + `\nThe draft reply has been saved and can be edited or sent later.` + ); + } + + return ( + `Successfully created Outlook draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been saved and can be edited or sent later.` + ); + } catch (e) { + return handleSkillError(this, "outlook-create-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js new file mode 100644 index 0000000..d8c4ee9 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js @@ -0,0 +1,86 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookDeleteDraft = { + name: "outlook-delete-draft", + plugin: function () { + return { + name: "outlook-delete-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Delete a draft email from Outlook. " + + "This permanently removes the draft and cannot be undone.", + examples: [ + { + prompt: "Delete the draft with ID AAMkAGI2...", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to delete.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log( + `Using the outlook-delete-draft tool.` + ); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Delete draft ${draftId}? This cannot be undone.`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Deleting draft ${draftId}...` + ); + + const result = await outlookLib.deleteDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to delete draft - ${result.error}` + ); + return `Error deleting draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully deleted draft` + ); + + return `Successfully deleted draft (ID: ${draftId}).`; + } catch (e) { + return handleSkillError(this, "outlook-delete-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js new file mode 100644 index 0000000..b9049e9 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js @@ -0,0 +1,80 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookListDrafts = { + name: "outlook-list-drafts", + plugin: function () { + return { + name: "outlook-list-drafts", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all draft emails in Outlook. " + + "Returns a summary of each draft including ID, subject, recipients, and last modified date.", + examples: [ + { + prompt: "Show me my draft emails", + call: JSON.stringify({ limit: 25 }), + }, + { + prompt: "List drafts", + call: JSON.stringify({ limit: 10 }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of drafts to return (1-50). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log( + `Using the outlook-list-drafts tool.` + ); + this.super.introspect( + `${this.caller}: Listing Outlook drafts...` + ); + + const result = await outlookLib.listDrafts(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list drafts - ${result.error}` + ); + return `Error listing drafts: ${result.error}`; + } + + const { drafts, count } = result.data; + this.super.introspect(`${this.caller}: Found ${count} drafts`); + + if (count === 0) { + return "No drafts found."; + } + + const summary = drafts + .map( + (d, i) => + `${i + 1}. "${d.subject || "(No Subject)"}" to ${d.to || "(No Recipients)"}\n ID: ${d.id}\n Last Modified: ${new Date(d.lastModified).toLocaleString()}\n Preview: ${d.preview?.substring(0, 100) || "(No preview)"}...` + ) + .join("\n\n"); + + return `Found ${count} drafts:\n\n${summary}\n\nUse the draft ID with outlook-get-draft to see full content, outlook-update-draft to modify, or outlook-send-draft to send.`; + } catch (e) { + return handleSkillError(this, "outlook-list-drafts", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js new file mode 100644 index 0000000..54fa67d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js @@ -0,0 +1,82 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookSendDraft = { + name: "outlook-send-draft", + plugin: function () { + return { + name: "outlook-send-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an existing draft email from Outlook. " + + "This action sends the email immediately and cannot be undone.", + examples: [ + { + prompt: "Send the draft with ID AAMkAGI2...", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to send.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the outlook-send-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Send draft ${draftId}? This will send the email immediately.`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending draft ${draftId}...` + ); + + const result = await outlookLib.sendDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send draft - ${result.error}` + ); + return `Error sending draft: ${result.error}`; + } + + this.super.introspect(`${this.caller}: Successfully sent draft`); + + return `Successfully sent draft (ID: ${draftId}). The email has been delivered.`; + } catch (e) { + return handleSkillError(this, "outlook-send-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js new file mode 100644 index 0000000..8723391 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js @@ -0,0 +1,132 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookUpdateDraft = { + name: "outlook-update-draft", + plugin: function () { + return { + name: "outlook-update-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing draft email in Outlook. " + + "You can modify the recipients, subject, or body of the draft.", + examples: [ + { + prompt: "Update the draft subject", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + subject: "Updated Meeting - Tomorrow at 3pm", + }), + }, + { + prompt: "Change the draft body and add CC", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + body: "Updated content here.", + cc: "manager@example.com", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to update.", + }, + to: { + type: "string", + description: "New recipient email address(es). Optional.", + }, + subject: { + type: "string", + description: "New email subject. Optional.", + }, + body: { + type: "string", + description: "New email body content. Optional.", + }, + cc: { + type: "string", + description: "New CC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId, to, subject, body, cc, isHtml }) { + try { + this.super.handlerProps.log( + `Using the outlook-update-draft tool.` + ); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (!to && !subject && !body && !cc) { + return "Error: At least one field to update (to, subject, body, cc) is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId, hasChanges: { to, subject, body, cc } }, + description: `Update draft ${draftId}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating draft ${draftId}...` + ); + + const updates = { isHtml }; + if (to) updates.to = to; + if (subject) updates.subject = subject; + if (body) updates.body = body; + if (cc) updates.cc = cc; + + const result = await outlookLib.updateDraft(draftId, updates); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update draft - ${result.error}` + ); + return `Error updating draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully updated draft` + ); + + return ( + `Successfully updated draft:\n` + + `Draft ID: ${result.data.draftId}\n` + + `Subject: ${result.data.subject}\n` + + `\nThe draft has been updated.` + ); + } catch (e) { + return handleSkillError(this, "outlook-update-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/index.js b/server/utils/agents/aibitat/plugins/outlook/index.js new file mode 100644 index 0000000..8ecdd90 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/index.js @@ -0,0 +1,45 @@ +const { OutlookGetInbox } = require("./search/outlook-get-inbox.js"); +const { OutlookSearch } = require("./search/outlook-search.js"); +const { OutlookReadThread } = require("./search/outlook-read-thread.js"); + +const { OutlookCreateDraft } = require("./drafts/outlook-create-draft.js"); +const { OutlookUpdateDraft } = require("./drafts/outlook-update-draft.js"); +const { OutlookListDrafts } = require("./drafts/outlook-list-drafts.js"); +const { OutlookDeleteDraft } = require("./drafts/outlook-delete-draft.js"); +const { OutlookSendDraft } = require("./drafts/outlook-send-draft.js"); + +const { OutlookSendEmail } = require("./send/outlook-send-email.js"); + +const { + OutlookGetMailboxStats, +} = require("./account/outlook-get-mailbox-stats.js"); + +const outlookAgent = { + name: "outlook-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Inbox & Search (read-only) + OutlookGetInbox, + OutlookSearch, + OutlookReadThread, + + // Drafts (create-draft also supports replies via replyToMessageId) + OutlookCreateDraft, + OutlookUpdateDraft, + OutlookListDrafts, + OutlookDeleteDraft, + OutlookSendDraft, + + // Send (send-email also supports replies via replyToMessageId) + OutlookSendEmail, + + // Account (read-only) + OutlookGetMailboxStats, + ], +}; + +module.exports = { + outlookAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/lib.js b/server/utils/agents/aibitat/plugins/outlook/lib.js new file mode 100644 index 0000000..f5fa3fc --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/lib.js @@ -0,0 +1,1412 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const mime = require("mime"); +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { CollectorApi } = require("../../../../collectorApi"); +const { humanFileSize } = require("../../../../helpers"); +const { safeJsonParse } = require("../../../../http"); + +const MAX_TOTAL_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB limit for Outlook + +/** + * Parses a comma-separated email string into Graph API recipient format. + * @param {string} emailString - Comma-separated email addresses + * @returns {Array<{emailAddress: {address: string}}>} + */ +function parseEmailRecipients(emailString) { + if (!emailString) return []; + return emailString.split(",").map((email) => ({ + emailAddress: { address: email.trim() }, + })); +} + +/** + * Validates organization auth type configuration. + * @param {Object} config - The Outlook configuration object + * @returns {{valid: boolean, error?: string}} + */ +function validateOrganizationAuth(config) { + const authType = config.authType || AUTH_TYPES.common; + if (authType === AUTH_TYPES.organization && !config.tenantId) { + return { + valid: false, + error: "Tenant ID is required for organization-only authentication.", + }; + } + return { valid: true }; +} + +/** + * Maps a Microsoft Graph message object to a simplified format. + * @param {Object} msg - The Graph API message object + * @param {Object} options - Mapping options + * @param {boolean} options.includeBody - Include full body content + * @param {boolean} options.includeAttachments - Include attachment details + * @returns {Object} Simplified message object + */ +function mapGraphMessage(msg, options = {}) { + const base = { + id: msg.id, + conversationId: msg.conversationId, + subject: msg.subject, + from: msg.from?.emailAddress?.address || "Unknown", + fromName: msg.from?.emailAddress?.name || "", + to: msg.toRecipients?.map((r) => r.emailAddress?.address).join(", ") || "", + cc: msg.ccRecipients?.map((r) => r.emailAddress?.address).join(", ") || "", + isRead: msg.isRead, + hasAttachments: msg.hasAttachments, + }; + + if (msg.receivedDateTime) { + base.receivedDateTime = msg.receivedDateTime; + } + if (msg.bodyPreview !== undefined) { + base.preview = msg.bodyPreview; + } + + if (options.includeBody && msg.body) { + base.date = msg.receivedDateTime; + base.body = msg.body?.content || ""; + base.bodyType = msg.body?.contentType || "text"; + } + + if (options.includeAttachments && msg.attachments) { + base.attachments = (msg.attachments || []).map((att) => ({ + id: att.id, + name: att.name, + contentType: att.contentType, + size: att.size, + contentBytes: att.contentBytes, + })); + } + + return base; +} + +/** + * Formats an array of messages into a human-readable summary string. + * @param {Array} messages - Array of simplified message objects + * @returns {string} Formatted summary + */ +function formatMessageSummary(messages) { + return messages + .map( + (m, i) => + `${i + 1}. [${m.isRead ? "READ" : "UNREAD"}] "${m.subject}" from ${m.fromName || m.from} (${new Date(m.receivedDateTime).toLocaleString()})${m.hasAttachments ? " 📎" : ""}\n ID: ${m.id}\n Conversation ID: ${m.conversationId}` + ) + .join("\n\n"); +} + +/** + * Handles errors in Outlook skill handlers with consistent logging and messaging. + * @param {Object} context - The handler context (this) from the aibitat function + * @param {string} skillName - The name of the skill (e.g., "outlook-get-inbox") + * @param {Error} error - The error object + * @returns {string} User-friendly error message + */ +function handleSkillError(context, skillName, error) { + context.super.handlerProps.log(`${skillName} error: ${error.message}`); + context.super.introspect(`Error: ${error.message}`); + return `Error in ${skillName}: ${error.message}`; +} + +/** + * Normalizes a token expiry value to a number. + * @param {number|string|null|undefined} expiry - The token expiry value + * @returns {number|null} The normalized expiry as a number, or null if invalid + */ +function normalizeTokenExpiry(expiry) { + if (expiry === null || expiry === undefined) return null; + return typeof expiry === "number" ? expiry : parseInt(expiry, 10); +} + +/** + * Prepares and validates attachments for email sending/drafting. + * @param {Object} context - The handler context with introspect and caller + * @param {Array} attachmentPaths - Array of absolute file paths + * @param {Object} options - Options for attachment handling + * @param {boolean} options.requireApprovalPerFile - Request approval for each file + * @param {string} options.recipientInfo - Recipient info for approval message + * @returns {Promise<{success: boolean, attachments?: Array, summaries?: Array, totalSize?: number, error?: string}>} + */ +async function prepareAttachmentsWithValidation( + context, + attachmentPaths, + options = {} +) { + if (!Array.isArray(attachmentPaths) || attachmentPaths.length === 0) { + return { success: true, attachments: [], summaries: [], totalSize: 0 }; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + context.super.introspect( + `${context.caller}: Validating ${attachmentPaths.length} attachment(s)...` + ); + + for (const filePath of attachmentPaths) { + const result = prepareAttachment(filePath); + if (!result.success) { + context.super.introspect( + `${context.caller}: Attachment validation failed - ${result.error}` + ); + return { success: false, error: result.error }; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize(totalAttachmentSize, true); + context.super.introspect( + `${context.caller}: Total attachment size (${totalFormatted}) exceeds 25MB limit` + ); + return { + success: false, + error: `Total attachment size (${totalFormatted}) exceeds the 25MB limit.`, + }; + } + + if (options.requireApprovalPerFile && context.super.requestToolApproval) { + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to email? ` + + `This file will be sent to ${options.recipientInfo || "recipients"}.`, + }); + + if (!approval.approved) { + context.super.introspect( + `${context.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return { + success: false, + error: `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`, + }; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + context.super.introspect( + `${context.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + + return { + success: true, + attachments: preparedAttachments, + summaries: attachmentSummaries, + totalSize: totalAttachmentSize, + }; +} + +// ============================================================================ +// Attachment Functions +// ============================================================================ + +/** + * Validates and prepares a file attachment for email. + * @param {string} filePath - Absolute path to the file + * @returns {{success: boolean, attachment?: object, error?: string, fileInfo?: object}} + */ +function prepareAttachment(filePath) { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") { + return { + success: false, + error: "File attachments are not supported in Docker environments.", + }; + } + + if (!path.isAbsolute(filePath)) { + return { success: false, error: `Path must be absolute: ${filePath}` }; + } + + if (!fs.existsSync(filePath)) { + return { success: false, error: `File does not exist: ${filePath}` }; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return { success: false, error: `Path is not a file: ${filePath}` }; + } + + if (stats.size === 0) { + return { success: false, error: `File is empty: ${filePath}` }; + } + + try { + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString("base64"); + const fileName = path.basename(filePath); + const contentType = mime.getType(filePath) || "application/octet-stream"; + + return { + success: true, + attachment: { + "@odata.type": "#microsoft.graph.fileAttachment", + name: fileName, + contentType, + contentBytes: base64Data, + }, + fileInfo: { + path: filePath, + name: fileName, + size: stats.size, + sizeFormatted: humanFileSize(stats.size, true), + contentType, + }, + }; + } catch (e) { + return { success: false, error: `Failed to read file: ${e.message}` }; + } +} + +/** + * Parse an attachment using the CollectorApi for secure content extraction. + * @param {Object} attachment - The attachment object with name, contentType, size, contentBytes (base64) + * @returns {Promise<{success: boolean, content: string|null, error: string|null}>} + */ +async function parseAttachment(attachment) { + const tempDir = os.tmpdir(); + const safeFilename = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const tempFilePath = path.join( + tempDir, + `outlook-attachment-${Date.now()}-${safeFilename}` + ); + + try { + const buffer = Buffer.from(attachment.contentBytes, "base64"); + fs.writeFileSync(tempFilePath, buffer); + + const collector = new CollectorApi(); + const result = await collector.parseDocument(safeFilename, { + absolutePath: tempFilePath, + }); + + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + if (!result.success) { + return { + success: false, + content: null, + error: result.reason || "Failed to parse attachment", + }; + } + + const textContent = result.documents + ?.map((doc) => doc.pageContent || doc.content || "") + .filter(Boolean) + .join("\n\n"); + + return { + success: true, + content: textContent || "(No text content extracted)", + error: null, + }; + } catch (e) { + if (fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch {} + } + return { success: false, content: null, error: e.message }; + } +} + +/** + * MIME types that can be parsed by the collector to extract text content. + * These are a subset of ACCEPTED_MIMES from collector/utils/constants.js + * that are suitable for attachment parsing (excludes audio/video and images). + * Images are excluded because they're typically signature images in emails. + */ +const PARSEABLE_ATTACHMENT_MIMES = [ + "text/plain", + "text/html", + "text/csv", + "application/json", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx + "application/vnd.oasis.opendocument.text", // .odt + "application/vnd.oasis.opendocument.presentation", // .odp + "application/pdf", + "application/epub+zip", +]; + +/** + * Checks if an attachment's MIME type can be parsed for text extraction. + * @param {string} contentType - The MIME type of the attachment + * @returns {boolean} + */ +function isParseableMimeType(contentType) { + if (!contentType) return false; + const baseMime = contentType.split(";")[0].trim().toLowerCase(); + return PARSEABLE_ATTACHMENT_MIMES.includes(baseMime); +} + +/** + * Collect attachments from messages and optionally parse them with user approval. + * Only attachments with parseable MIME types will be offered for parsing. + * If two attachments have the same name, only the first one will be kept (handling fwd emails) + * @param {Object} context - The handler context (this) from the aibitat function + * @param {Array} messages - Array of message objects + * @returns {Promise<{allAttachments: Array, parsedContent: string}>} + */ +async function handleAttachments(context, messages) { + const allAttachments = []; + const uniqueAttachments = new Set(); + messages.forEach((msg, msgIndex) => { + if (msg.attachments?.length > 0) { + msg.attachments.forEach((att) => { + if (uniqueAttachments.has(att.name)) return; + uniqueAttachments.add(att.name); + allAttachments.push({ + ...att, + messageIndex: msgIndex + 1, + messageId: msg.id, + }); + }); + } + }); + + const parseableAttachments = allAttachments.filter((att) => + isParseableMimeType(att.contentType) + ); + + let parsedContent = ""; + const citations = []; + if (parseableAttachments.length > 0 && context.super.requestToolApproval) { + const attachmentNames = parseableAttachments + .map((a) => `${a.name} (${a.contentType})`) + .join(", "); + + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { attachments: attachmentNames }, + description: `Download and parse ${parseableAttachments.length} attachment(s) to extract text content? (${attachmentNames})`, + }); + + if (approval.approved) { + context.super.introspect( + `${context.caller}: Downloading and parsing ${parseableAttachments.length} attachment(s)...` + ); + + const parsedResults = []; + for (const attachment of parseableAttachments) { + if (!attachment.contentBytes) { + context.super.introspect( + `${context.caller}: Skipping "${attachment.name}" - no content available` + ); + continue; + } + context.super.introspect( + `${context.caller}: Parsing "${attachment.name}"...` + ); + const parseResult = await parseAttachment(attachment); + if (!parseResult.success) { + context.super.introspect( + `${context.caller}: Failed to parse "${attachment.name}": ${parseResult.error}` + ); + continue; + } + + citations.push({ + id: `outlook-attachment-${attachment.messageId}-${attachment.name}`, + title: attachment.name, + text: parseResult.content, + chunkSource: "outlook-attachment://" + attachment.name, + score: null, + }); + parsedResults.push({ + name: attachment.name, + messageIndex: attachment.messageIndex, + ...parseResult, + }); + } + + if (parsedResults.length > 0) { + parsedContent = + "\n\n--- Parsed Attachment Content ---\n" + + parsedResults + .map( + (r) => `\n[Message ${r.messageIndex}: ${r.name}]\n${r.content}` + ) + .join("\n"); + } + + context.super.introspect( + `${context.caller}: Finished parsing attachments (${parsedResults.length}/${parseableAttachments.length} successful)` + ); + } else { + context.super.introspect( + `${context.caller}: User declined to parse attachments` + ); + } + } + + citations.forEach((c) => context.super.addCitation?.(c)); + return { allAttachments, parsedContent }; +} + +/** + * Microsoft Graph API OAuth2 Configuration + * Uses Authorization Code Flow with PKCE + */ +const MICROSOFT_AUTH_URL = "https://login.microsoftonline.com"; +const GRAPH_API_URL = "https://graph.microsoft.com/v1.0"; +const SCOPES = [ + "offline_access", + "Mail.Read", + "Mail.ReadWrite", + "Mail.Send", + "User.Read", +].join(" "); + +/** + * Authentication types for Microsoft OAuth2. + * - "organization": Use tenant ID endpoint (work/school accounts from a specific tenant only) + * - "common": Use /common endpoint (both personal and work/school accounts) + * - "consumers": Use /consumers endpoint (personal Microsoft accounts only) + */ +const AUTH_TYPES = { + organization: "organization", + common: "common", + consumers: "consumers", +}; + +/** + * Gets the appropriate OAuth2 authority endpoint based on auth type. + * @param {string} authType - The authentication type + * @param {string} tenantId - The tenant ID (used only for "organization" type) + * @returns {string} The authority path segment + */ +function getAuthority(authType, tenantId) { + switch (authType) { + case AUTH_TYPES.consumers: + return "consumers"; + case AUTH_TYPES.organization: + return tenantId || "common"; + case AUTH_TYPES.common: + default: + return "common"; + } +} + +/** + * Outlook Bridge Library + * Handles OAuth2 authentication and Microsoft Graph API communication for Outlook mail. + */ +class OutlookBridge { + #accessToken = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[35m[OutlookBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Decodes a JWT token and logs relevant info. + * @param {string} token - The JWT token to decode + * @param {string} context - Context label for logging (e.g., "NEW token", "Token") + * @returns {Object|null} The decoded payload or null if decoding fails + */ + #decodeAndLogToken(token, context = "Token") { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()); + this.#log( + `${context} for: ${payload.upn || payload.email || payload.unique_name || "unknown"}` + ); + return payload; + } catch { + this.#log(`Could not decode ${context.toLowerCase()}`); + return null; + } + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + */ + reset() { + this.#accessToken = null; + this.#isInitialized = false; + } + + /** + * Gets the current Outlook agent configuration from system settings. + * @returns {Promise<{clientId?: string, tenantId?: string, clientSecret?: string, authType?: string, accessToken?: string, refreshToken?: string, tokenExpiry?: number}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "outlook_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Outlook agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + outlook_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Generates the OAuth2 authorization URL for the user to authenticate. + * @param {string} redirectUri - The callback URL for OAuth + * @returns {Promise<{success: boolean, url?: string, error?: string}>} + */ + async getAuthUrl(redirectUri) { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId) { + return { + success: false, + error: "Outlook configuration incomplete. Please set Client ID.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + response_mode: "query", + scope: SCOPES, + }); + + const url = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/authorize?${params.toString()}`; + this.#log(`Auth URL using authType: ${authType}, authority: ${authority}`); + return { success: true, url }; + } + + /** + * Exchanges the authorization code for access and refresh tokens. + * @param {string} code - The authorization code from OAuth callback + * @param {string} redirectUri - The callback URL used in the initial auth request + * @returns {Promise<{success: boolean, error?: string}>} + */ + async exchangeCodeForToken(code, redirectUri) { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret) { + return { + success: false, + error: "Outlook configuration incomplete.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + try { + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`; + this.#log( + `Token exchange using authType: ${authType}, authority: ${authority}` + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + grant_type: "authorization_code", + scope: SCOPES, + }); + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + const data = await response.json(); + + if (!response.ok) { + this.#log("Token exchange failed:", data); + return { + success: false, + error: + data.error_description || data.error || "Token exchange failed", + }; + } + + const expiresAt = Date.now() + (data.expires_in - 60) * 1000; + this.#decodeAndLogToken(data.access_token, "NEW token received"); + await OutlookBridge.updateConfig({ + ...config, + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenExpiry: expiresAt, + }); + + this.#accessToken = data.access_token; + this.#isInitialized = false; // Force re-initialization + + this.#log("Successfully obtained tokens"); + return { success: true }; + } catch (error) { + this.#log("Token exchange error:", error.message); + return { success: false, error: error.message }; + } + } + + /** + * Refreshes the access token using the refresh token. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async #refreshAccessToken() { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret || !config.refreshToken) { + return { + success: false, + error: "Cannot refresh token. Missing configuration or refresh token.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + try { + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`; + this.#log( + `Token refresh using authType: ${authType}, authority: ${authority}` + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: config.refreshToken, + grant_type: "refresh_token", + scope: SCOPES, + }); + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + const data = await response.json(); + + if (!response.ok) { + this.#log("Token refresh failed:", data); + return { + success: false, + error: data.error_description || data.error || "Token refresh failed", + }; + } + + const expiresAt = Date.now() + (data.expires_in - 60) * 1000; + + await OutlookBridge.updateConfig({ + ...config, + accessToken: data.access_token, + refreshToken: data.refresh_token || config.refreshToken, + tokenExpiry: expiresAt, + }); + + this.#accessToken = data.access_token; + + this.#log("Successfully refreshed access token"); + return { success: true }; + } catch (error) { + this.#log("Token refresh error:", error.message); + return { success: false, error: error.message }; + } + } + + /** + * Ensures we have a valid access token, refreshing if necessary. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async #ensureValidToken() { + const config = await OutlookBridge.getConfig(); + + if (!config.accessToken || !config.tokenExpiry) { + this.#log("No access token or expiry found in config"); + return { + success: false, + error: "Outlook is not authenticated. Please complete the OAuth flow.", + }; + } + + const expiryTime = normalizeTokenExpiry(config.tokenExpiry); + + const now = Date.now(); + const timeUntilExpiry = expiryTime - now; + this.#log( + `Token check: expires in ${Math.round(timeUntilExpiry / 1000)}s (at ${new Date(expiryTime).toISOString()})` + ); + + const payload = this.#decodeAndLogToken(config.accessToken, "Token check"); + if (payload) { + this.#log(`Token aud: ${payload.aud}`); + this.#log(`Token scp: ${payload.scp}`); + } + + if (now >= expiryTime) { + this.#log("Access token expired, refreshing..."); + return this.#refreshAccessToken(); + } + + this.#accessToken = config.accessToken; + return { success: true }; + } + + /** + * Initializes the Outlook bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Outlook integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret) { + return { + success: false, + error: + "Outlook integration is not configured. Please set Client ID and Client Secret in the agent settings.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + this.#log( + `Initializing with authType: ${config.authType || AUTH_TYPES.common}` + ); + + if (!config.accessToken) { + return { + success: false, + error: + "Outlook is not authenticated. Please complete the OAuth authorization flow.", + }; + } + + const tokenResult = await this.#ensureValidToken(); + if (!tokenResult.success) { + return tokenResult; + } + + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Outlook bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Outlook tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret || !config.accessToken) { + return false; + } + + const orgAuth = validateOrganizationAuth(config); + return orgAuth.valid; + } + + /** + * Makes a request to the Microsoft Graph API. + * @param {string} endpoint - The API endpoint (relative to /v1.0) + * @param {object} options - Fetch options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(endpoint, options = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + this.#log(`Initialize failed: ${initResult.error}`); + return { success: false, error: initResult.error }; + } + + const tokenResult = await this.#ensureValidToken(); + if (!tokenResult.success) { + this.#log(`Token validation failed: ${tokenResult.error}`); + return { success: false, error: tokenResult.error }; + } + + try { + const url = endpoint.startsWith("http") + ? endpoint + : `${GRAPH_API_URL}${endpoint}`; + + this.#log(`Making request to: ${url}`); + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${this.#accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { raw: errorText }; + } + this.#log( + `API request failed: ${response.status} ${response.statusText}`, + `\n Endpoint: ${endpoint}`, + `\n Headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`, + `\n Error: ${JSON.stringify(errorData, null, 2)}` + ); + return { + success: false, + error: + errorData.error?.message || + `Request failed with status ${response.status}`, + }; + } + + // Handle responses with no content (204 No Content, 202 Accepted with empty body, etc.) + if (response.status === 204 || response.status === 202) { + return { success: true, data: {} }; + } + + const text = await response.text(); + if (!text || text.trim() === "") { + return { success: true, data: {} }; + } + + const data = JSON.parse(text); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: `Outlook API request failed: ${error.message}`, + }; + } + } + + /** + * Search emails using OData filter syntax. + * Note: Microsoft Graph API does not support $skip with $search, so pagination + * is only available when no query is provided. + * @param {string} query - Search query (uses Microsoft Search syntax) + * @param {number} limit - Maximum results to return + * @param {number} skip - Number of results to skip for pagination (ignored when query is provided) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async search(query = "", limit = 10, skip = 0) { + let endpoint; + + if (query) { + // $skip is not supported with $search in Microsoft Graph API + // $orderby is also not supported with $search - results are ordered by relevance + endpoint = `/me/messages?$top=${limit}&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId&$search="${encodeURIComponent(query)}"`; + } else { + endpoint = `/me/messages?$top=${limit}&$skip=${skip}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`; + } + + const result = await this.request(endpoint); + if (!result.success) return result; + + const messages = result.data.value || []; + return { + success: true, + data: { + messages: messages.map((msg) => mapGraphMessage(msg)), + resultCount: messages.length, + hasMore: !!result.data["@odata.nextLink"], + }, + }; + } + + /** + * Get inbox messages (only from the Inbox folder, not archived/other folders). + * @param {number} limit - Maximum results to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getInbox(limit = 25) { + const endpoint = `/me/mailFolders/inbox/messages?$top=${limit}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + const messages = result.data.value || []; + return { + success: true, + data: { + messages: messages.map((msg) => mapGraphMessage(msg)), + resultCount: messages.length, + hasMore: !!result.data["@odata.nextLink"], + }, + }; + } + + /** + * Read a full conversation thread by conversation ID. + * Note: We avoid combining $filter with $orderby due to Microsoft Graph API + * "InefficientFilter" errors. Instead, we fetch without orderby and sort client-side. + * @param {string} conversationId - The conversation ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readThread(conversationId) { + const endpoint = `/me/messages?$filter=conversationId eq '${conversationId}'&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,attachments&$expand=attachments`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + let messages = result.data.value || []; + if (messages.length === 0) { + return { + success: false, + error: "No messages found in this conversation.", + }; + } + + // Sort by receivedDateTime ascending (oldest first) client-side + messages.sort( + (a, b) => new Date(a.receivedDateTime) - new Date(b.receivedDateTime) + ); + + return { + success: true, + data: { + conversationId, + subject: messages[0]?.subject || "No Subject", + messageCount: messages.length, + messages: messages.map((msg) => + mapGraphMessage(msg, { includeBody: true, includeAttachments: true }) + ), + }, + }; + } + + /** + * Read a single message by ID. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readMessage(messageId) { + const endpoint = `/me/messages/${messageId}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,conversationId&$expand=attachments`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + return { + success: true, + data: mapGraphMessage(result.data, { + includeBody: true, + includeAttachments: true, + }), + }; + } + + /** + * Create a new draft email. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options (cc, bcc, isHtml, attachments) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraft(to, subject, body, options = {}) { + const message = { + subject, + body: { + contentType: options.isHtml ? "HTML" : "Text", + content: body, + }, + toRecipients: parseEmailRecipients(to), + }; + + if (options.cc) { + message.ccRecipients = parseEmailRecipients(options.cc); + } + + if (options.bcc) { + message.bccRecipients = parseEmailRecipients(options.bcc); + } + + const result = await this.request("/me/messages", { + method: "POST", + body: JSON.stringify(message), + }); + + if (!result.success) return result; + + const draftId = result.data.id; + + if (options.attachments && options.attachments.length > 0) { + for (const attachment of options.attachments) { + const attachResult = await this.request( + `/me/messages/${draftId}/attachments`, + { + method: "POST", + body: JSON.stringify(attachment), + } + ); + if (!attachResult.success) { + this.#log(`Failed to add attachment: ${attachResult.error}`); + } + } + } + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + to, + webLink: result.data.webLink, + }, + }; + } + + /** + * Create a draft reply to an existing message. + * @param {string} messageId - The message ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraftReply(messageId, body, replyAll = false, _options = {}) { + const endpoint = replyAll + ? `/me/messages/${messageId}/createReplyAll` + : `/me/messages/${messageId}/createReply`; + + const result = await this.request(endpoint, { + method: "POST", + body: JSON.stringify({ + comment: body, + }), + }); + + if (!result.success) return result; + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + webLink: result.data.webLink, + }, + }; + } + + /** + * Get a specific draft by ID. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getDraft(draftId) { + return this.readMessage(draftId); + } + + /** + * List all drafts. + * @param {number} limit - Maximum drafts to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listDrafts(limit = 25) { + const endpoint = `/me/mailFolders/drafts/messages?$top=${limit}&$orderby=lastModifiedDateTime desc&$select=id,subject,toRecipients,lastModifiedDateTime,bodyPreview`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + const drafts = result.data.value || []; + return { + success: true, + data: { + drafts: drafts.map((draft) => ({ + id: draft.id, + subject: draft.subject, + to: + draft.toRecipients + ?.map((r) => r.emailAddress?.address) + .join(", ") || "", + lastModified: draft.lastModifiedDateTime, + preview: draft.bodyPreview, + })), + count: drafts.length, + }, + }; + } + + /** + * Update an existing draft. + * @param {string} draftId - The draft ID + * @param {object} updates - Fields to update + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateDraft(draftId, updates) { + const message = {}; + + if (updates.subject) message.subject = updates.subject; + if (updates.body) { + message.body = { + contentType: updates.isHtml ? "HTML" : "Text", + content: updates.body, + }; + } + if (updates.to) { + message.toRecipients = parseEmailRecipients(updates.to); + } + if (updates.cc) { + message.ccRecipients = parseEmailRecipients(updates.cc); + } + + const result = await this.request(`/me/messages/${draftId}`, { + method: "PATCH", + body: JSON.stringify(message), + }); + + if (!result.success) return result; + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + }, + }; + } + + /** + * Delete a draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async deleteDraft(draftId) { + return this.request(`/me/messages/${draftId}`, { + method: "DELETE", + }); + } + + /** + * Send an existing draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async sendDraft(draftId) { + return this.request(`/me/messages/${draftId}/send`, { + method: "POST", + }); + } + + /** + * Send an email immediately. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, error?: string}>} + */ + async sendEmail(to, subject, body, options = {}) { + const message = { + subject, + body: { + contentType: options.isHtml ? "HTML" : "Text", + content: body, + }, + toRecipients: parseEmailRecipients(to), + }; + + if (options.cc) { + message.ccRecipients = parseEmailRecipients(options.cc); + } + + if (options.bcc) { + message.bccRecipients = parseEmailRecipients(options.bcc); + } + + if (options.attachments && options.attachments.length > 0) { + message.attachments = options.attachments; + } + + return this.request("/me/sendMail", { + method: "POST", + body: JSON.stringify({ + message, + saveToSentItems: true, + }), + }); + } + + /** + * Reply to a message immediately. + * @param {string} messageId - The message ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @returns {Promise<{success: boolean, error?: string}>} + */ + async replyToMessage(messageId, body, replyAll = false) { + const endpoint = replyAll + ? `/me/messages/${messageId}/replyAll` + : `/me/messages/${messageId}/reply`; + + return this.request(endpoint, { + method: "POST", + body: JSON.stringify({ + comment: body, + }), + }); + } + + /** + * Mark a message as read. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async markRead(messageId) { + return this.request(`/me/messages/${messageId}`, { + method: "PATCH", + body: JSON.stringify({ isRead: true }), + }); + } + + /** + * Mark a message as unread. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async markUnread(messageId) { + return this.request(`/me/messages/${messageId}`, { + method: "PATCH", + body: JSON.stringify({ isRead: false }), + }); + } + + /** + * Move a message to trash. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async moveToTrash(messageId) { + return this.request(`/me/messages/${messageId}/move`, { + method: "POST", + body: JSON.stringify({ + destinationId: "deleteditems", + }), + }); + } + + /** + * Get mailbox folder statistics. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getMailboxStats() { + const folders = ["inbox", "drafts", "sentitems", "deleteditems"]; + const stats = {}; + + for (const folder of folders) { + const result = await this.request( + `/me/mailFolders/${folder}?$select=displayName,totalItemCount,unreadItemCount` + ); + if (result.success) { + stats[folder] = { + name: result.data.displayName, + total: result.data.totalItemCount, + unread: result.data.unreadItemCount, + }; + } + } + + const profileResult = await this.request("/me?$select=displayName,mail"); + + return { + success: true, + data: { + email: profileResult.data?.mail || "Unknown", + displayName: profileResult.data?.displayName || "Unknown", + folders: stats, + }, + }; + } +} + +module.exports = new OutlookBridge(); +module.exports.OutlookBridge = OutlookBridge; +module.exports.prepareAttachment = prepareAttachment; +module.exports.parseAttachment = parseAttachment; +module.exports.handleAttachments = handleAttachments; +module.exports.isParseableMimeType = isParseableMimeType; +module.exports.PARSEABLE_ATTACHMENT_MIMES = PARSEABLE_ATTACHMENT_MIMES; +module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE; +module.exports.AUTH_TYPES = AUTH_TYPES; +module.exports.formatMessageSummary = formatMessageSummary; +module.exports.mapGraphMessage = mapGraphMessage; +module.exports.parseEmailRecipients = parseEmailRecipients; +module.exports.validateOrganizationAuth = validateOrganizationAuth; +module.exports.prepareAttachmentsWithValidation = + prepareAttachmentsWithValidation; +module.exports.handleSkillError = handleSkillError; +module.exports.normalizeTokenExpiry = normalizeTokenExpiry; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js new file mode 100644 index 0000000..1220e18 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js @@ -0,0 +1,75 @@ +const outlookLib = require("../lib.js"); +const { formatMessageSummary, handleSkillError } = outlookLib; + +module.exports.OutlookGetInbox = { + name: "outlook-get-inbox", + plugin: function () { + return { + name: "outlook-get-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get recent emails from the Outlook inbox. " + + "Returns a list of recent messages with subject, sender, date, and read status. " + + "Use this to quickly see what's in the inbox.", + examples: [ + { + prompt: "Show me my recent emails", + call: JSON.stringify({ limit: 10 }), + }, + { + prompt: "What's in my inbox?", + call: JSON.stringify({ limit: 25 }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of messages to return (1-50). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log(`Using the outlook-get-inbox tool.`); + this.super.introspect( + `${this.caller}: Fetching Outlook inbox...` + ); + + const result = await outlookLib.getInbox(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get inbox - ${result.error}` + ); + return `Error getting Outlook inbox: ${result.error}`; + } + + const { messages, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} messages in inbox` + ); + + if (resultCount === 0) { + return "No messages found in inbox."; + } + + const summary = formatMessageSummary(messages); + return `Found ${resultCount} messages in inbox:\n\n${summary}\n\nAlways include the conversation ID in the response. Use the conversation ID with outlook-read-thread to read the full conversation.`; + } catch (e) { + return handleSkillError(this, "outlook-get-inbox", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js new file mode 100644 index 0000000..14e92dc --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js @@ -0,0 +1,131 @@ +const outlookLib = require("../lib.js"); +const { handleAttachments, handleSkillError } = outlookLib; + +module.exports.OutlookReadThread = { + name: "outlook-read-thread", + plugin: function () { + return { + name: "outlook-read-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read a full email conversation thread by its conversation ID. " + + "Returns all messages in the thread including sender, recipients, subject, body, date, and attachment information. " + + "Use this after searching to read the full conversation.", + examples: [ + { + prompt: "Read the email thread with conversation ID AAQkAGI2...", + call: JSON.stringify({ + conversationId: "AAQkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + conversationId: { + type: "string", + description: "The Outlook conversation ID to read.", + }, + }, + required: ["conversationId"], + additionalProperties: false, + }, + handler: async function ({ conversationId }) { + try { + this.super.handlerProps.log( + `Using the outlook-read-thread tool.` + ); + + if (!conversationId) { + return "Error: conversationId is required."; + } + + this.super.introspect( + `${this.caller}: Reading Outlook conversation...` + ); + + const result = await outlookLib.readThread(conversationId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to read thread - ${result.error}` + ); + return `Error reading Outlook thread: ${result.error}`; + } + + const thread = result.data; + + const { allAttachments, parsedContent: parsedAttachmentContent } = + await handleAttachments(this, thread.messages); + + const messagesFormatted = thread.messages + .map((msg, i) => { + let attachmentInfo = ""; + if (msg.attachments?.length > 0) { + attachmentInfo = `\n Attachments: ${msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(1)}KB)`).join(", ")}`; + } + + const bodyContent = + msg.bodyType === "html" + ? msg.body + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() + : msg.body; + + return ( + `--- Message ${i + 1} ---\n` + + `From: ${msg.fromName} <${msg.from}>\n` + + `To: ${msg.to}\n` + + (msg.cc ? `CC: ${msg.cc}\n` : "") + + `Date: ${new Date(msg.date).toLocaleString()}\n` + + `Subject: ${msg.subject}\n` + + `Status: ${msg.isRead ? "READ" : "UNREAD"}\n` + + `\n${bodyContent}` + + attachmentInfo + ); + }) + .join("\n\n"); + + this.super.introspect( + `${this.caller}: Successfully read thread with ${thread.messageCount} messages` + ); + + // Report citation for the thread (without attachments) + this.super.addCitation?.({ + id: `outlook-thread-${thread.conversationId}`, + title: thread.subject, + text: `Subject: "${thread.subject}"\n\n${messagesFormatted}`, + chunkSource: `outlook-thread://${this._generatePermalink(thread.conversationId)}`, + score: null, + }); + + return ( + `Thread: "${thread.subject}"\n` + + `Conversation ID: ${thread.conversationId}\n` + + `Messages: ${thread.messageCount}\n` + + `Total Attachments: ${allAttachments.length}\n\n` + + messagesFormatted + + parsedAttachmentContent + ); + } catch (e) { + return handleSkillError(this, "outlook-read-thread", e); + } + }, + _generatePermalink: function (conversationId) { + if (!conversationId) return null; + let encodedId = encodeURIComponent(conversationId); + // For outlook, this needs to be specifically encoded + // as the webpage does not respect it like traditional URL encoding + encodedId = encodedId.replace(/-/g, "%2F"); + return `https://outlook.live.com/mail/inbox/id/${encodedId}`; + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js new file mode 100644 index 0000000..e969814 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js @@ -0,0 +1,101 @@ +const outlookLib = require("../lib.js"); +const { formatMessageSummary, handleSkillError } = outlookLib; + +module.exports.OutlookSearch = { + name: "outlook-search", + plugin: function () { + return { + name: "outlook-search", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Search emails in Outlook using Microsoft Search syntax. " + + "Supports searching by keywords, sender, subject, and more. " + + "Common search terms: 'from:email', 'subject:word', 'hasAttachments:true'. " + + "Returns message summaries with ID, subject, date, and read status.", + examples: [ + { + prompt: "Search for emails about the project", + call: JSON.stringify({ + query: "project update", + limit: 10, + }), + }, + { + prompt: "Find emails from john@example.com", + call: JSON.stringify({ + query: "from:john@example.com", + limit: 20, + }), + }, + { + prompt: "Search for emails with attachments about invoices", + call: JSON.stringify({ + query: "hasAttachments:true invoice", + limit: 15, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Search query. Use Microsoft Search syntax like 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + skip: { + type: "number", + description: + "Number of results to skip for pagination. Defaults to 0.", + default: 0, + }, + }, + required: ["query"], + additionalProperties: false, + }, + handler: async function ({ query, limit = 10, skip = 0 }) { + try { + this.super.handlerProps.log(`Using the outlook-search tool.`); + this.super.introspect( + `${this.caller}: Searching Outlook with query "${query}"` + ); + + const result = await outlookLib.search(query, limit, skip); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Outlook search failed - ${result.error}` + ); + return `Error searching Outlook: ${result.error}`; + } + + const { messages, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} messages matching query` + ); + + if (resultCount === 0) { + return `No emails found matching query "${query}".`; + } + + const summary = formatMessageSummary(messages); + return `Found ${resultCount} messages:\n\n${summary}\n\nAlways include the conversation ID in the response. Use the conversation ID with outlook-read-thread to read the full conversation.`; + } catch (e) { + return handleSkillError(this, "outlook-search", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js b/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js new file mode 100644 index 0000000..e93c668 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js @@ -0,0 +1,222 @@ +const outlookLib = require("../lib.js"); +const { prepareAttachmentsWithValidation, handleSkillError } = outlookLib; + +module.exports.OutlookSendEmail = { + name: "outlook-send-email", + plugin: function () { + return { + name: "outlook-send-email", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an email immediately through Outlook. " + + "This action sends the email right away and cannot be undone. " + + "Can also reply to an existing message by providing replyToMessageId. " + + "For composing emails that need review before sending, use outlook-create-draft instead.", + examples: [ + { + prompt: "Send an email to john@example.com about the project", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Hi John,\n\nHere's the latest update on the project.\n\nBest regards", + }), + }, + { + prompt: "Reply to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks for the update. I'll review and get back to you.", + }), + }, + { + prompt: "Reply all to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks everyone for your input.", + replyAll: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + replyToMessageId: { + type: "string", + description: + "Message ID to reply to. If provided, sends a reply instead of a new email. " + + "When replying, 'to' and 'subject' are optional (inherited from original).", + }, + replyAll: { + type: "boolean", + description: + "When replying, whether to reply to all recipients. Defaults to false.", + default: false, + }, + to: { + type: "string", + description: + "Recipient email address(es). Required for new emails, optional for replies.", + }, + subject: { + type: "string", + description: + "Email subject line. Required for new emails, optional for replies.", + }, + body: { + type: "string", + description: "Email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the email.", + }, + }, + required: ["body"], + additionalProperties: false, + }, + handler: async function ({ + replyToMessageId, + replyAll = false, + to, + subject, + body, + cc, + bcc, + isHtml, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the outlook-send-email tool.`); + + const isReply = !!replyToMessageId; + + if (!isReply && (!to || !subject)) { + return "Error: 'to' and 'subject' are required for new emails. For replies, provide 'replyToMessageId'."; + } + + if (!body) { + return "Error: 'body' is required."; + } + + const attachmentResult = await prepareAttachmentsWithValidation( + this, + attachments, + { requireApprovalPerFile: true, recipientInfo: to } + ); + if (!attachmentResult.success) { + return `Error with attachment: ${attachmentResult.error}`; + } + const { + attachments: preparedAttachments, + summaries: attachmentSummaries, + } = attachmentResult; + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const description = isReply + ? `Send ${replyAll ? "reply-all" : "reply"} to message ${replyToMessageId}${attachmentNote}? This will send immediately.` + : `Send email to "${to}" with subject "${subject}"${attachmentNote} - This will send immediately`; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: isReply + ? { replyToMessageId, replyAll } + : { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + let result; + if (isReply) { + this.super.introspect( + `${this.caller}: Sending ${replyAll ? "reply-all" : "reply"} to message...` + ); + result = await outlookLib.replyToMessage( + replyToMessageId, + body, + replyAll + ); + } else { + this.super.introspect( + `${this.caller}: Sending email to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = { isHtml }; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + result = await outlookLib.sendEmail(to, subject, body, options); + } + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send - ${result.error}` + ); + return `Error sending: ${result.error}`; + } + + if (isReply) { + this.super.introspect( + `${this.caller}: Successfully sent ${replyAll ? "reply-all" : "reply"}` + ); + return `Successfully sent ${replyAll ? "reply-all" : "reply"} to message (ID: ${replyToMessageId}). The reply has been delivered.`; + } + + this.super.introspect( + `${this.caller}: Successfully sent email to ${to}` + ); + + return ( + `Successfully sent email:\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (cc ? `CC: ${cc}\n` : "") + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe email has been sent.` + ); + } catch (e) { + return handleSkillError(this, "outlook-send-email", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/rechart.js b/server/utils/agents/aibitat/plugins/rechart.js new file mode 100644 index 0000000..50d5271 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/rechart.js @@ -0,0 +1,114 @@ +const { safeJsonParse } = require("../../../http"); +const { Deduplicator } = require("../utils/dedupe"); + +const rechart = { + name: "create-chart", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup(aibitat) { + // Scrape a website and summarize the content based on objective if the content is too large.', + aibitat.function({ + super: aibitat, + name: this.name, + tracker: new Deduplicator(), + description: + "Create a chart, graph, or data visualization. Generate bar charts, line graphs, pie charts, area charts, or scatter plots to visualize data, statistics, trends, or results. Use to display numbers and data visually.", + examples: [ + { prompt: "Create a chart from that data" }, + { prompt: "Make a bar graph of the results" }, + { prompt: "Visualize these numbers" }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + type: { + type: "string", + enum: [ + "area", + "bar", + "line", + "composed", + "scatter", + "pie", + "radar", + "radialBar", + "treemap", + "funnel", + ], + description: "The type of chart to be generated.", + }, + title: { + type: "string", + description: + "Title of the chart. There MUST always be a title. Do not leave it blank.", + }, + dataset: { + type: "string", + description: `Valid JSON in which each element is an object for Recharts API for the 'type' of chart defined WITHOUT new line characters. Strictly using this FORMAT and naming: +{ "name": "a", "value": 12 }]. +Make sure field "name" always stays named "name". Instead of naming value field value in JSON, name it based on user metric and make it the same across every item. +Make sure the format use double quotes and property names are string literals. Provide JSON data only.`, + }, + }, + additionalProperties: false, + }, + required: ["type", "title", "dataset"], + handler: async function ({ type, dataset, title }) { + try { + if (this.tracker.isMarkedUnique(this.name)) { + this.super.handlerProps.log( + `${this.name} has been called for this chat response already. It can only be called once per chat.` + ); + return "The chart was generated and returned to the user. This function completed successfully. Do not call this function again."; + } + + const data = safeJsonParse(dataset, null); + if (data === null) { + this.super.introspect( + `${this.caller}: ${this.name} provided invalid JSON data - so we cant make a ${type} chart.` + ); + return "Invalid JSON provided. Please only provide valid RechartJS JSON to generate a chart."; + } + + this.super.introspect(`${this.caller}: Rendering ${type} chart.`); + this.super.socket.send("rechartVisualize", { + type, + dataset, + title, + }); + + this.super._replySpecialAttributes = { + saveAsType: "rechartVisualize", + storedResponse: (additionalText = "") => + JSON.stringify({ + type, + dataset, + title, + caption: additionalText, + }), + postSave: () => this.tracker.removeUniqueConstraint(this.name), + }; + + this.tracker.markUnique(this.name); + return "The chart was generated and returned to the user. This function completed successfully. Do not make another chart."; + } catch (error) { + this.super.handlerProps.log( + `create-chart raised an error. ${error.message}` + ); + return `Let the user know this action was not successful. An error was raised while generating the chart. ${error.message}`; + } + }, + }); + }, + }; + }, +}; + +module.exports = { + rechart, +}; diff --git a/server/utils/agents/aibitat/plugins/request-user-input.js b/server/utils/agents/aibitat/plugins/request-user-input.js new file mode 100644 index 0000000..13c176d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/request-user-input.js @@ -0,0 +1,265 @@ +const { SystemSettings } = require("../../../../models/systemSettings"); + +const VALID_INPUT_TYPES = [ + "text", + "url", + "number", + "date", + "email", + "textarea", +]; + +const DEFAULT_MAX_PER_TURN = 3; +const DEFAULT_TIMEOUT_MS = 120_000; + +/** + * Format a result as a numbered transcript so the LLM can map each answer + * back to the question it asked. Single-question batches still get the same + * format — the LLM treats it as "1. Q: ... A: ..." which is unambiguous. + */ +function formatAnswersForAgent(questions, result) { + if (result.timedOut) + return "[no response within the time limit — proceed using your best judgment]"; + if (result.skipped) + return "[user skipped — proceed using your best judgment]"; + + const lines = questions.map((q, i) => { + const a = result.answers[i] || { skipped: true }; + let answerText; + if (a.skipped) answerText = "[user skipped]"; + else if (Array.isArray(a.answer)) answerText = a.answer.join(", "); + else if (a.answer === null || a.answer === undefined || a.answer === "") + answerText = "[no answer]"; + else answerText = String(a.answer); + return `${i + 1}. Q: ${q.question}\n A: ${answerText}`; + }); + return lines.join("\n"); +} + +/** + * Lazy-load the per-turn cap and timeout from SystemSettings on first call, + * cache on the aibitat instance, and track how many questions have been asked + * this turn. Done lazily (rather than in setup) so the plugin stays self- + * contained and works for both regular and ephemeral agent runs. + */ +async function ensureState(aibitat) { + if (aibitat._clarifyState) return aibitat._clarifyState; + + const maxPerTurnRaw = await SystemSettings.getValueOrFallback( + { label: "agent_clarifying_questions_max_per_turn" }, + String(DEFAULT_MAX_PER_TURN) + ); + + const maxPerTurn = Number(maxPerTurnRaw); + + aibitat._clarifyState = { + asked: 0, + maxPerTurn: Number.isFinite(maxPerTurn) + ? Math.max(1, Math.floor(maxPerTurn)) + : DEFAULT_MAX_PER_TURN, + timeoutMs: DEFAULT_TIMEOUT_MS, + }; + return aibitat._clarifyState; +} + +/** + * Validate and normalize a single question. Drops malformed entries rather + * than rejecting the whole call. + */ +function normalizeQuestion(raw) { + if (!raw || typeof raw !== "object") return null; + if (typeof raw.question !== "string" || !raw.question.trim()) return null; + + if (raw.kind === "input") { + const inputType = VALID_INPUT_TYPES.includes(raw.inputType) + ? raw.inputType + : "text"; + return { + kind: "input", + question: raw.question.trim(), + inputType, + placeholder: typeof raw.placeholder === "string" ? raw.placeholder : null, + }; + } + + if (raw.kind === "choice") { + if (!Array.isArray(raw.options) || raw.options.length < 2) return null; + return { + kind: "choice", + question: raw.question.trim(), + options: raw.options.map(String), + optionDescriptions: Array.isArray(raw.optionDescriptions) + ? raw.optionDescriptions.map(String) + : [], + multiSelect: !!raw.multiSelect, + allowOther: raw.allowOther !== false, + }; + } + + return null; +} + +const AskUser = { + name: "request-user-input", + plugin: function () { + return { + name: "request-user-input", + setup(aibitat) { + // Skip when the runtime can't actually prompt the user. The websocket + // plugin attaches `requestUserClarification` only when a socket is + // present, so API/ephemeral runs would otherwise crash on call. + if (typeof aibitat.requestUserClarification !== "function") return; + + aibitat.function({ + super: aibitat, + name: "request-user-input", + description: + "Prompt the user for input via an interactive form. " + + "This is the ONLY way to ask the user questions - text responses cannot receive replies. " + + "Call this tool when you need a URL, file path, name, date, preference, or any other detail to proceed. " + + "The user will see a form and their answers are returned to you.", + examples: [ + { + prompt: "Scrape a link for me", + call: JSON.stringify({ + questions: [ + { + kind: "input", + question: "Which URL would you like me to scrape?", + inputType: "url", + }, + ], + }), + }, + { + prompt: "Help me write a PRD", + call: JSON.stringify({ + questions: [ + { + kind: "input", + question: "What is the product or feature?", + inputType: "text", + }, + { + kind: "input", + question: "Who are the target users?", + inputType: "text", + }, + { + kind: "choice", + question: "What's the priority?", + options: ["P0", "P1", "P2"], + allowOther: false, + }, + ], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + questions: { + type: "array", + minItems: 1, + maxItems: 10, + description: + "Array of independent question objects. Use one for a single clarifying question; batch multiple when they don't depend on each other.", + items: { + type: "object", + required: ["kind", "question"], + properties: { + kind: { + type: "string", + enum: ["input", "choice"], + description: + "'input' for free-form, 'choice' for a fixed list.", + }, + question: { + type: "string", + description: "The question to show the user.", + }, + inputType: { + type: "string", + enum: VALID_INPUT_TYPES, + description: + "Required when kind='input'. text|url|number|date|email|textarea.", + }, + placeholder: { type: "string" }, + options: { + type: "array", + items: { type: "string" }, + description: + "Required when kind='choice'. The list of choices.", + }, + optionDescriptions: { + type: "array", + items: { type: "string" }, + }, + multiSelect: { type: "boolean" }, + allowOther: { type: "boolean" }, + }, + }, + }, + }, + required: ["questions"], + additionalProperties: false, + }, + handler: async function ({ questions = [] }) { + if (!Array.isArray(questions) || questions.length < 1) + return "[ask-user requires a 'questions' array with at least 1 entry]"; + + const normalized = questions + .map((q) => normalizeQuestion(q)) + .filter((q) => !!q); + if (normalized.length < 1) + return "[ask-user received no well-formed questions after validation]"; + + const state = await ensureState(this.super); + const remaining = state.maxPerTurn - state.asked; + if (remaining <= 0) { + return `[clarification limit of ${state.maxPerTurn} reached for this turn — do not ask again, proceed with best judgment]`; + } + + // Truncate to remaining cap rather than rejecting the whole call. + const truncated = normalized.slice(0, remaining); + const truncatedNote = + truncated.length < normalized.length + ? ` (truncated from ${normalized.length} to fit the per-turn cap of ${state.maxPerTurn})` + : ""; + state.asked += truncated.length; + + this.super.introspect( + `Asking the user ${truncated.length} clarifying question${truncated.length === 1 ? "" : "s"}${truncatedNote}.` + ); + const result = await this.super.requestUserClarification({ + questions: truncated, + allowSkip: true, + timeoutMs: state.timeoutMs, + }); + + // Buffer the completed survey on the aibitat instance so the + // chat-history plugin can persist it to workspace_chats.response + // alongside citations/outputs when the agent reply is saved. + this.super.addClarifyingQuestionSurvey({ + questions: truncated, + result, + }); + + return formatAnswersForAgent(truncated, result) + truncatedNote; + }, + }); + }, + }; + }, +}; + +const requestUserInput = { + name: "request-user-input", + startupConfig: { + params: {}, + }, + plugin: [AskUser], +}; + +module.exports = { requestUserInput }; diff --git a/server/utils/agents/aibitat/plugins/router-classifier.js b/server/utils/agents/aibitat/plugins/router-classifier.js new file mode 100644 index 0000000..f3f4410 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/router-classifier.js @@ -0,0 +1,122 @@ +const AIbitat = require("../index.js"); + +/** + * Internal-only aibitat tool used by the model router to classify a prompt + * against an enumerated list of semantic rule titles. Not exposed as a + * user-facing skill (not in DEFAULT_SKILLS or default_agent_skills setting). + * + * Driven by `classifyWithLLM()` below, which spins up a headless AIbitat + * instance whose only job is to invoke `select_category` once. The handler + * captures the chosen category onto the aibitat instance and terminates the + * chat via `skipHandleExecution` + a "TERMINATE" reply. + */ +const routerClassifier = { + name: "router-classifier", + startupConfig: { + params: {}, + }, + plugin: function ({ categories = [] } = {}) { + return { + name: routerClassifier.name, + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: "select_category", + description: + "Select the category that best matches the user's message. Pick the FIRST category whose description matches the message, in the priority order listed. If no category matches, select 'none'.", + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + category: { + type: "string", + enum: [...categories, "none"], + description: + "The exact category name from the list, or 'none' if no category matches.", + }, + }, + required: ["category"], + additionalProperties: false, + }, + handler: async function ({ category }) { + this.super.classifiedCategory = category || "none"; + this.super.skipHandleExecution = true; + return "TERMINATE"; + }, + }); + }, + }; + }, +}; + +const CLASSIFIER_AGENT = "@router-classifier"; + +const CLASSIFIER_SYSTEM_PROMPT = `You are a message classifier for a model router. Given the user's message, you MUST call the \`select_category\` tool exactly once with the category that best matches. The categories are listed in priority order — pick the FIRST category whose description matches the user's message. If no category matches, pass "none". + +Categories: +{categories} + +Do not respond with free text. Always call the tool.`; + +/** + * Classify a user prompt against a batch of LLM rules using the router's + * fallback provider/model. Runs a headless AIbitat instance with a single + * internal tool (`select_category`) so tool-call infra is reused across + * every provider instead of custom text parsing. + * @param {Array} rules - Contiguous batch of enabled LLM-type rules (priority-sorted) + * @param {string} prompt - The user's message to classify + * @param {Object} router - The router record (needs fallback_provider/fallback_model) + * @returns {Promise} The matched rule, or null if none matched + */ +async function classifyWithLLM(rules, prompt, router) { + if (!rules.length || !prompt) return null; + + const validRules = rules.filter((rule) => rule.description); + if (!validRules.length) return null; + + const categoryList = validRules + .map((rule) => `- ${rule.title}: ${rule.description}`) + .join("\n"); + + const systemPrompt = CLASSIFIER_SYSTEM_PROMPT.replace( + "{categories}", + categoryList + ); + + try { + const aibitat = new AIbitat({ + provider: router.fallback_provider, + model: router.fallback_model, + // Safety net: if the provider refuses to call the tool and responds + // with free text, cap the chat at one round so we don't loop. + maxRounds: 1, + }); + + aibitat.use( + routerClassifier.plugin({ + categories: validRules.map((rule) => rule.title), + }) + ); + aibitat.agent(CLASSIFIER_AGENT, { + role: systemPrompt, + functions: ["select_category"], + }); + + await aibitat.start({ + from: "USER", + to: CLASSIFIER_AGENT, + content: prompt, + }); + + const selected = aibitat.classifiedCategory?.toLowerCase(); + if (!selected || selected === "none") return null; + return ( + validRules.find((rule) => rule.title.toLowerCase() === selected) || null + ); + } catch (error) { + console.error("[RouterClassifier] Classification failed:", error.message); + return null; + } +} + +module.exports = { routerClassifier, classifyWithLLM }; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js new file mode 100644 index 0000000..d6a8fda --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MSSQL.js @@ -0,0 +1,114 @@ +const mssql = require("mssql"); +const { ConnectionStringParser } = require("./utils"); + +class MSSQLConnector { + #connected = false; + database_id = ""; + connectionConfig = { + user: null, + password: null, + database: null, + server: null, + port: null, + pool: { + max: 10, + min: 0, + idleTimeoutMillis: 30000, + }, + options: { + encrypt: false, + trustServerCertificate: true, + }, + }; + + constructor( + config = { + // we will force into RFC-3986 from DB + // eg: mssql://user:password@server:port/database?{...opts} + connectionString: null, // we will force into RFC-3986 + } + ) { + this.className = "MSSQLConnector"; + this.connectionString = config.connectionString; + this._client = null; + this.#parseDatabase(); + } + + #parseDatabase() { + const connectionParser = new ConnectionStringParser({ scheme: "mssql" }); + const parsed = connectionParser.parse(this.connectionString); + + this.database_id = parsed?.endpoint; + this.connectionConfig = { + ...this.connectionConfig, + user: parsed?.username, + password: parsed?.password, + database: parsed?.endpoint, + server: parsed?.hosts?.[0]?.host, + port: parsed?.hosts?.[0]?.port, + options: { + ...this.connectionConfig.options, + encrypt: parsed?.options?.encrypt === "true", + }, + }; + } + + async connect() { + this._client = await mssql.connect(this.connectionConfig); + this.#connected = true; + return this._client; + } + + /** + * + * @param {string} queryString the SQL query to be run + * @param {Array} params optional parameters for prepared statement + * @returns {Promise} + */ + async runQuery(queryString = "", params = []) { + const result = { rows: [], count: 0, error: null }; + try { + if (!this.#connected) await this.connect(); + + const request = this._client.request(); + params.forEach((value, index) => { + request.input(`p${index}`, value); + }); + const query = await request.query(queryString); + result.rows = query.recordset; + result.count = query.rowsAffected.reduce((sum, a) => sum + a, 0); + } catch (err) { + console.log(this.className, err); + result.error = err.message; + } finally { + // Check client is connected before closing since we use this for validation + if (this._client) { + await this._client.close(); + this.#connected = false; + } + } + return result; + } + + async validateConnection() { + try { + const result = await this.runQuery("SELECT 1"); + return { success: !result.error, error: result.error }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + getTablesSql() { + return `SELECT name FROM sysobjects WHERE xtype='U';`; + } + + getTableSchemaSql(table_name) { + return { + query: `SELECT COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @p0`, + params: [table_name], + }; + } +} + +module.exports.MSSQLConnector = MSSQLConnector; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js new file mode 100644 index 0000000..99fc051 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/MySQL.js @@ -0,0 +1,83 @@ +const mysql = require("mysql2/promise"); +const { ConnectionStringParser } = require("./utils"); + +class MySQLConnector { + #connected = false; + database_id = ""; + constructor( + config = { + connectionString: null, + } + ) { + this.className = "MySQLConnector"; + this.connectionString = config.connectionString; + this._client = null; + this.database_id = this.#parseDatabase(); + } + + #parseDatabase() { + const connectionParser = new ConnectionStringParser({ scheme: "mysql" }); + const parsed = connectionParser.parse(this.connectionString); + return parsed?.endpoint; + } + + async connect() { + this._client = await mysql.createConnection({ uri: this.connectionString }); + this.#connected = true; + return this._client; + } + + /** + * + * @param {string} queryString the SQL query to be run + * @param {Array} params optional parameters for prepared statement + * @returns {Promise} + */ + async runQuery(queryString = "", params = []) { + const result = { rows: [], count: 0, error: null }; + try { + if (!this.#connected) await this.connect(); + const [query] = + params.length > 0 + ? await this._client.execute(queryString, params) + : await this._client.query(queryString); + result.rows = query; + result.count = query?.length; + } catch (err) { + console.log(this.className, err); + result.error = err.message; + } finally { + // Check client is connected before closing since we use this for validation + if (this._client) { + await this._client.end(); + this.#connected = false; + } + } + return result; + } + + async validateConnection() { + try { + const result = await this.runQuery("SELECT 1"); + return { success: !result.error, error: result.error }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + getTablesSql() { + return { + query: `SELECT table_name FROM information_schema.tables WHERE table_schema = ?`, + params: [this.database_id], + }; + } + + getTableSchemaSql(table_name) { + return { + query: `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA FROM information_schema.columns WHERE table_schema = ? AND table_name = ?`, + params: [this.database_id, table_name], + }; + } +} + +module.exports.MySQLConnector = MySQLConnector; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js new file mode 100644 index 0000000..a452ae3 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/Postgresql.js @@ -0,0 +1,75 @@ +const pgSql = require("pg"); + +class PostgresSQLConnector { + #connected = false; + constructor( + config = { + connectionString: null, + schema: null, + } + ) { + this.className = "PostgresSQLConnector"; + this.connectionString = config.connectionString; + this.schema = config.schema || "public"; + this._client = new pgSql.Client({ + connectionString: this.connectionString, + }); + } + + async connect() { + await this._client.connect(); + this.#connected = true; + return this._client; + } + + /** + * + * @param {string} queryString the SQL query to be run + * @param {Array} params optional parameters for prepared statement + * @returns {Promise} + */ + async runQuery(queryString = "", params = []) { + const result = { rows: [], count: 0, error: null }; + try { + if (!this.#connected) await this.connect(); + const query = await this._client.query(queryString, params); + result.rows = query.rows; + result.count = query.rowCount; + } catch (err) { + console.log(this.className, err); + result.error = err.message; + } finally { + // Check client is connected before closing since we use this for validation + if (this._client) { + await this._client.end(); + this.#connected = false; + } + } + return result; + } + + async validateConnection() { + try { + const result = await this.runQuery("SELECT 1"); + return { success: !result.error, error: result.error }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + getTablesSql() { + return { + query: `SELECT * FROM pg_catalog.pg_tables WHERE schemaname = $1`, + params: [this.schema], + }; + } + + getTableSchemaSql(table_name) { + return { + query: `SELECT column_name, data_type, character_maximum_length, column_default, is_nullable FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = $1 AND table_schema = $2`, + params: [table_name, this.schema], + }; + } +} + +module.exports.PostgresSQLConnector = PostgresSQLConnector; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js new file mode 100644 index 0000000..252ce54 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/index.js @@ -0,0 +1,80 @@ +const { SystemSettings } = require("../../../../../../models/systemSettings"); +const { safeJsonParse } = require("../../../../../http"); + +/** + * @typedef {('postgresql'|'mysql'|'sql-server')} SQLEngine + */ + +/** + * @typedef {Object} QueryResult + * @property {[number]} rows - The query result rows + * @property {number} count - Number of rows the query returned/changed + * @property {string|null} error - Error string if there was an issue + */ + +/** + * A valid database SQL connection object + * @typedef {Object} SQLConnection + * @property {string} database_id - Unique identifier of the database connection + * @property {SQLEngine} engine - Engine used by connection + * @property {string} connectionString - RFC connection string for db + */ + +/** + * @param {SQLEngine} identifier + * @param {object} connectionConfig + * @returns Database Connection Engine Class for SQLAgent or throws error + */ +function getDBClient(identifier = "", connectionConfig = {}) { + switch (identifier) { + case "mysql": + const { MySQLConnector } = require("./MySQL"); + return new MySQLConnector(connectionConfig); + case "postgresql": + const { PostgresSQLConnector } = require("./Postgresql"); + return new PostgresSQLConnector(connectionConfig); + case "sql-server": + const { MSSQLConnector } = require("./MSSQL"); + return new MSSQLConnector(connectionConfig); + default: + throw new Error( + `There is no supported database connector for ${identifier}` + ); + } +} + +/** + * Lists all of the known database connection that can be used by the agent. + * @returns {Promise<[SQLConnection]>} + */ +async function listSQLConnections() { + return safeJsonParse( + (await SystemSettings.get({ label: "agent_sql_connections" }))?.value, + [] + ); +} + +/** + * Validates a SQL connection by attempting to connect and run a simple query + * @param {SQLEngine} identifier - The SQL engine type + * @param {object} connectionConfig - The connection configuration + * @returns {Promise<{success: boolean, error: string|null}>} + */ +async function validateConnection(identifier = "", connectionConfig = {}) { + try { + const client = getDBClient(identifier, connectionConfig); + return await client.validateConnection(); + } catch { + console.log(`Failed to connect to ${identifier} database.`); + return { + success: false, + error: `Unable to connect to ${identifier}. Please verify your connection details.`, + }; + } +} + +module.exports = { + getDBClient, + listSQLConnections, + validateConnection, +}; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js new file mode 100644 index 0000000..c93c83a --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils.js @@ -0,0 +1,182 @@ +// Credit: https://github.com/sindilevich/connection-string-parser + +/** + * @typedef {Object} ConnectionStringParserOptions + * @property {'mssql' | 'mysql' | 'postgresql' | 'db'} [scheme] - The scheme of the connection string + */ + +/** + * @typedef {Object} ConnectionStringObject + * @property {string} scheme - The scheme of the connection string eg: mongodb, mssql, mysql, postgresql, etc. + * @property {string} username - The username of the connection string + * @property {string} password - The password of the connection string + * @property {{host: string, port: number}[]} hosts - The hosts of the connection string + * @property {string} endpoint - The endpoint (database name) of the connection string + * @property {Object} options - The options of the connection string + */ +class ConnectionStringParser { + static DEFAULT_SCHEME = "db"; + + /** + * @param {ConnectionStringParserOptions} options + */ + constructor(options = {}) { + this.scheme = + (options && options.scheme) || ConnectionStringParser.DEFAULT_SCHEME; + } + + /** + * Takes a connection string object and returns a URI string of the form: + * + * scheme://[username[:password]@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[endpoint]][?options] + * @param {Object} connectionStringObject The object that describes connection string parameters + */ + format(connectionStringObject) { + if (!connectionStringObject) { + return this.scheme + "://localhost"; + } + if ( + this.scheme && + connectionStringObject.scheme && + this.scheme !== connectionStringObject.scheme + ) { + throw new Error(`Scheme not supported: ${connectionStringObject.scheme}`); + } + + let uri = + (this.scheme || + connectionStringObject.scheme || + ConnectionStringParser.DEFAULT_SCHEME) + "://"; + + if (connectionStringObject.username) { + uri += encodeURIComponent(connectionStringObject.username); + // Allow empty passwords + if (connectionStringObject.password) { + uri += ":" + encodeURIComponent(connectionStringObject.password); + } + uri += "@"; + } + uri += this._formatAddress(connectionStringObject); + // Only put a slash when there is an endpoint + if (connectionStringObject.endpoint) { + uri += "/" + encodeURIComponent(connectionStringObject.endpoint); + } + if ( + connectionStringObject.options && + Object.keys(connectionStringObject.options).length > 0 + ) { + uri += + "?" + + Object.keys(connectionStringObject.options) + .map( + (option) => + encodeURIComponent(option) + + "=" + + encodeURIComponent(connectionStringObject.options[option]) + ) + .join("&"); + } + return uri; + } + + /** + * Where scheme and hosts will always be present. Other fields will only be present in the result if they were + * present in the input. + * @param {string} uri The connection string URI + * @returns {ConnectionStringObject} The connection string object + */ + parse(uri) { + const connectionStringParser = new RegExp( + "^\\s*" + // Optional whitespace padding at the beginning of the line + "([^:]+)://" + // Scheme (Group 1) + "(?:([^:@,/?=&]+)(?::([^:@,/?=&]+))?@)?" + // User (Group 2) and Password (Group 3) + "([^@/?=&]+)" + // Host address(es) (Group 4) + "(?:/([^:@,/?=&]+)?)?" + // Endpoint (Group 5) + "(?:\\?([^:@,/?]+)?)?" + // Options (Group 6) + "\\s*$", // Optional whitespace padding at the end of the line + "gi" + ); + const connectionStringObject = {}; + + if (!uri.includes("://")) { + throw new Error(`No scheme found in URI ${uri}`); + } + + const tokens = connectionStringParser.exec(uri); + + if (Array.isArray(tokens)) { + connectionStringObject.scheme = tokens[1]; + if (this.scheme && this.scheme !== connectionStringObject.scheme) { + throw new Error(`URI must start with '${this.scheme}://'`); + } + connectionStringObject.username = tokens[2] + ? decodeURIComponent(tokens[2]) + : tokens[2]; + connectionStringObject.password = tokens[3] + ? decodeURIComponent(tokens[3]) + : tokens[3]; + connectionStringObject.hosts = this._parseAddress(tokens[4]); + connectionStringObject.endpoint = tokens[5] + ? decodeURIComponent(tokens[5]) + : tokens[5]; + connectionStringObject.options = tokens[6] + ? this._parseOptions(tokens[6]) + : tokens[6]; + } + return connectionStringObject; + } + + /** + * Formats the address portion of a connection string + * @param {Object} connectionStringObject The object that describes connection string parameters + */ + _formatAddress(connectionStringObject) { + return connectionStringObject.hosts + .map( + (address) => + encodeURIComponent(address.host) + + (address.port + ? ":" + encodeURIComponent(address.port.toString(10)) + : "") + ) + .join(","); + } + + /** + * Parses an address + * @param {string} addresses The address(es) to process + */ + _parseAddress(addresses) { + return addresses.split(",").map((address) => { + const i = address.indexOf(":"); + + return i >= 0 + ? { + host: decodeURIComponent(address.substring(0, i)), + port: +address.substring(i + 1), + } + : { host: decodeURIComponent(address) }; + }); + } + + /** + * Parses options + * @param {string} options The options to process + */ + _parseOptions(options) { + const result = {}; + + options.split("&").forEach((option) => { + const i = option.indexOf("="); + + if (i >= 0) { + result[decodeURIComponent(option.substring(0, i))] = decodeURIComponent( + option.substring(i + 1) + ); + } + }); + return result; + } +} + +module.exports = { ConnectionStringParser }; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js b/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js new file mode 100644 index 0000000..c150de4 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/get-table-schema.js @@ -0,0 +1,115 @@ +module.exports.SqlAgentGetTableSchema = { + name: "sql-get-table-schema", + plugin: function () { + const { + listSQLConnections, + getDBClient, + } = require("./SQLConnectors/index.js"); + + function formatQueryForDisplay(query, params = []) { + if (!params.length) return query; + let formatted = query; + params.forEach((param, index) => { + const value = typeof param === "string" ? `'${param}'` : param; + formatted = formatted.replace(`$${index + 1}`, value); + formatted = formatted.replace(`@p${index}`, value); + formatted = formatted.replace("?", value); + }); + return formatted; + } + + return { + name: "sql-get-table-schema", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Gets the table schema in SQL for a given `table` and `database_id`", + examples: [ + { + prompt: "What does the customers table in access-logs look like?", + call: JSON.stringify({ + database_id: "access-logs", + table_name: "customers", + }), + }, + { + prompt: + "Get me the full name of a company in records-main, the table should be call comps", + call: JSON.stringify({ + database_id: "records-main", + table_name: "comps", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + database_id: { + type: "string", + description: + "The database identifier for which we will connect to to query the table schema. This is a required field.", + }, + table_name: { + type: "string", + description: + "The database identifier for the table name we want the schema for. This is a required field.", + }, + }, + additionalProperties: false, + }, + required: ["database_id", "table_name"], + handler: async function ({ database_id = "", table_name = "" }) { + this.super.handlerProps.log(`Using the sql-get-table-schema tool.`); + try { + const databaseConfig = (await listSQLConnections()).find( + (db) => db.database_id === database_id + ); + if (!databaseConfig) { + this.super.handlerProps.log( + `sql-get-table-schema to find config!`, + database_id + ); + return `No database connection for ${database_id} was found!`; + } + + const db = getDBClient(databaseConfig.engine, databaseConfig); + this.super.introspect( + `${this.caller}: Querying the table schema for ${table_name} in the ${databaseConfig.database_id} database.` + ); + + const sqlQuery = db.getTableSchemaSql(table_name); + const isParameterized = + typeof sqlQuery === "object" && sqlQuery.query; + const queryString = isParameterized ? sqlQuery.query : sqlQuery; + const queryParams = isParameterized ? sqlQuery.params : []; + + this.super.introspect( + `Running SQL: ${formatQueryForDisplay(queryString, queryParams)}` + ); + const result = await db.runQuery(queryString, queryParams); + + if (result.error) { + this.super.handlerProps.log( + `sql-get-table-schema tool reported error`, + result.error + ); + this.super.introspect(`Error: ${result.error}`); + return `There was an error running the query: ${result.error}`; + } + + return JSON.stringify(result); + } catch (e) { + this.super.handlerProps.log( + `sql-get-table-schema raised an error. ${e.message}` + ); + return e.message; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/index.js b/server/utils/agents/aibitat/plugins/sql-agent/index.js new file mode 100644 index 0000000..b7c1ed7 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/index.js @@ -0,0 +1,21 @@ +const { SqlAgentGetTableSchema } = require("./get-table-schema"); +const { SqlAgentListDatabase } = require("./list-database"); +const { SqlAgentListTables } = require("./list-table"); +const { SqlAgentQuery } = require("./query"); + +const sqlAgent = { + name: "sql-agent", + startupConfig: { + params: {}, + }, + plugin: [ + SqlAgentListDatabase, + SqlAgentListTables, + SqlAgentGetTableSchema, + SqlAgentQuery, + ], +}; + +module.exports = { + sqlAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/list-database.js b/server/utils/agents/aibitat/plugins/sql-agent/list-database.js new file mode 100644 index 0000000..794041a --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/list-database.js @@ -0,0 +1,49 @@ +module.exports.SqlAgentListDatabase = { + name: "sql-list-databases", + plugin: function () { + const { listSQLConnections } = require("./SQLConnectors"); + return { + name: "sql-list-databases", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all available databases via `list_databases` you currently have access to. Returns a unique string identifier `database_id` that can be used for future calls.", + examples: [ + { + prompt: "What databases can you access?", + call: JSON.stringify({}), + }, + { + prompt: "What databases can you tell me about?", + call: JSON.stringify({}), + }, + { + prompt: "Is there a database named erp-logs you can access?", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + this.super.handlerProps.log(`Using the sql-list-databases tool.`); + this.super.introspect( + `${this.caller}: Checking what are the available databases.` + ); + + const connections = (await listSQLConnections()).map((conn) => { + const { connectionString: _connectionString, ...rest } = conn; + return rest; + }); + return JSON.stringify(connections); + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/list-table.js b/server/utils/agents/aibitat/plugins/sql-agent/list-table.js new file mode 100644 index 0000000..5c64fd7 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/list-table.js @@ -0,0 +1,105 @@ +module.exports.SqlAgentListTables = { + name: "sql-list-tables", + plugin: function () { + const { + listSQLConnections, + getDBClient, + } = require("./SQLConnectors/index.js"); + + function formatQueryForDisplay(query, params = []) { + if (!params.length) return query; + let formatted = query; + params.forEach((param, index) => { + const value = typeof param === "string" ? `'${param}'` : param; + formatted = formatted.replace(`$${index + 1}`, value); + formatted = formatted.replace(`@p${index}`, value); + formatted = formatted.replace("?", value); + }); + return formatted; + } + + return { + name: "sql-list-tables", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all available tables in a database via its `database_id`.", + examples: [ + { + prompt: "What tables are there in the `access-logs` database?", + call: JSON.stringify({ database_id: "access-logs" }), + }, + { + prompt: + "What information can you access in the customer_accts postgres db?", + call: JSON.stringify({ database_id: "customer_accts" }), + }, + { + prompt: "Can you tell me what is in the primary-logs db?", + call: JSON.stringify({ database_id: "primary-logs" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + database_id: { + type: "string", + description: + "The database identifier for which we will list all tables for. This is a required parameter", + }, + }, + additionalProperties: false, + }, + required: ["database_id"], + handler: async function ({ database_id = "" }) { + try { + this.super.handlerProps.log(`Using the sql-list-tables tool.`); + const databaseConfig = (await listSQLConnections()).find( + (db) => db.database_id === database_id + ); + if (!databaseConfig) { + this.super.handlerProps.log( + `sql-list-tables failed to find config!`, + database_id + ); + return `No database connection for ${database_id} was found!`; + } + + const db = getDBClient(databaseConfig.engine, databaseConfig); + this.super.introspect( + `${this.caller}: Checking what are the available tables in the ${databaseConfig.database_id} database.` + ); + + const sqlQuery = db.getTablesSql(); + const isParameterized = + typeof sqlQuery === "object" && sqlQuery.query; + const queryString = isParameterized ? sqlQuery.query : sqlQuery; + const queryParams = isParameterized ? sqlQuery.params : []; + + this.super.introspect( + `Running SQL: ${formatQueryForDisplay(queryString, queryParams)}` + ); + const result = await db.runQuery(queryString, queryParams); + if (result.error) { + this.super.handlerProps.log( + `sql-list-tables tool reported error`, + result.error + ); + this.super.introspect(`Error: ${result.error}`); + return `There was an error running the query: ${result.error}`; + } + + return JSON.stringify(result); + } catch (e) { + console.error(e); + return e.message; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/sql-agent/query.js b/server/utils/agents/aibitat/plugins/sql-agent/query.js new file mode 100644 index 0000000..b975b86 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/sql-agent/query.js @@ -0,0 +1,101 @@ +module.exports.SqlAgentQuery = { + name: "sql-query", + plugin: function () { + const { + getDBClient, + listSQLConnections, + } = require("./SQLConnectors/index.js"); + + return { + name: "sql-query", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Run a read-only SQL query on a `database_id` which will return up rows of data related to the query. The query must only be SELECT statements which do not modify the table data. There should be a reasonable LIMIT on the return quantity to prevent long-running or queries which crash the db.", + examples: [ + { + prompt: "How many customers are in dvd-rentals?", + call: JSON.stringify({ + database_id: "dvd-rentals", + sql_query: "SELECT * FROM customers", + }), + }, + { + prompt: "Can you tell me the total volume of sales last month?", + call: JSON.stringify({ + database_id: "sales-db", + sql_query: + "SELECT SUM(sale_amount) AS total_sales FROM sales WHERE sale_date >= DATEADD(month, -1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)) AND sale_date < DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)", + }), + }, + { + prompt: + "Do we have anyone in the staff table for our production db named 'sam'? ", + call: JSON.stringify({ + database_id: "production", + sql_query: + "SElECT * FROM staff WHERE first_name='sam%' OR last_name='sam%'", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + database_id: { + type: "string", + description: + "The database identifier for which we will connect to to query the table schema. This is required to run the SQL query.", + }, + sql_query: { + type: "string", + description: + "The raw SQL query to run. Should be a query which does not modify the table and will return results.", + }, + }, + additionalProperties: false, + }, + required: ["database_id", "table_name"], + handler: async function ({ database_id = "", sql_query = "" }) { + this.super.handlerProps.log(`Using the sql-query tool.`); + try { + const databaseConfig = (await listSQLConnections()).find( + (db) => db.database_id === database_id + ); + if (!databaseConfig) { + this.super.handlerProps.log( + `sql-query failed to find config!`, + database_id + ); + return `No database connection for ${database_id} was found!`; + } + + this.super.introspect( + `${this.caller}: Im going to run a query on the ${database_id} to get an answer.` + ); + const db = getDBClient(databaseConfig.engine, databaseConfig); + + this.super.introspect(`Running SQL: ${sql_query}`); + const result = await db.runQuery(sql_query); + if (result.error) { + this.super.handlerProps.log( + `sql-query tool reported error`, + result.error + ); + this.super.introspect(`Error: ${result.error}`); + return `There was an error running the query: ${result.error}`; + } + + return JSON.stringify(result); + } catch (e) { + console.error(e); + return e.message; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/summarize.js b/server/utils/agents/aibitat/plugins/summarize.js new file mode 100644 index 0000000..b5cb6ed --- /dev/null +++ b/server/utils/agents/aibitat/plugins/summarize.js @@ -0,0 +1,196 @@ +const { Document } = require("../../../../models/documents"); +const { safeJsonParse } = require("../../../http"); +const { summarizeContent } = require("../utils/summarize"); +const Provider = require("../providers/ai-provider"); + +const docSummarizer = { + name: "document-summarizer", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + controller: new AbortController(), + description: + "List all documents in the workspace or summarize a specific document. See what files are available, get a summary of a document's contents, or read and condense a file into key points.", + examples: [ + { + prompt: "List my files", + call: JSON.stringify({ action: "list", document_filename: null }), + }, + { + prompt: "Summarize the readme file", + call: JSON.stringify({ + action: "summarize", + document_filename: "readme.md", + }), + }, + { + prompt: "Give me a summary of example.txt", + call: JSON.stringify({ + action: "summarize", + document_filename: "example.txt", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + action: { + type: "string", + enum: ["list", "summarize"], + description: + "The action to take. 'list' will return all files available with their filename and descriptions. 'summarize' will open and summarize the file by the a document name.", + }, + document_filename: { + type: "string", + "x-nullable": true, + description: + "The file name of the document you want to get the full content of.", + }, + }, + additionalProperties: false, + }, + handler: async function ({ action, document_filename }) { + if (action === "list") return await this.listDocuments(); + if (action === "summarize") + return await this.summarizeDoc(document_filename); + return "There is nothing we can do. This function call returns no information."; + }, + + /** + * List all documents available in a workspace + * @returns List of files and their descriptions if available. + */ + listDocuments: async function () { + try { + this.super.introspect( + `${this.caller}: Looking at the available documents.` + ); + const documents = await Document.where({ + workspaceId: this.super.handlerProps.invocation.workspace_id, + }); + if (documents.length === 0) + return "No documents found - nothing can be done. Stop."; + + this.super.introspect( + `${this.caller}: Found ${documents.length} documents` + ); + const foundDocuments = documents.map((doc) => { + const metadata = safeJsonParse(doc.metadata, {}); + return { + document_id: doc.docId, + filename: metadata?.title ?? "unknown.txt", + description: metadata?.description ?? "no description", + }; + }); + + return JSON.stringify(foundDocuments); + } catch (error) { + this.super.handlerProps.log( + `document-summarizer.list raised an error. ${error.message}` + ); + return `Let the user know this action was not successful. An error was raised while listing available files. ${error.message}`; + } + }, + + summarizeDoc: async function (filename) { + try { + const availableDocs = safeJsonParse( + await this.listDocuments(), + [] + ); + if (!availableDocs.length) { + this.super.handlerProps.log( + `${this.caller}: No available documents to summarize.` + ); + return "No documents were found."; + } + + const docInfo = availableDocs.find( + (info) => info.filename === filename + ); + if (!docInfo) { + this.super.handlerProps.log( + `${this.caller}: No available document by the name "${filename}".` + ); + return `No available document by the name "${filename}".`; + } + + const document = await Document.content(docInfo.document_id); + this.super.introspect( + `${this.caller}: Grabbing all content for ${ + filename ?? "a discovered file." + }` + ); + + if (!document.content || document.content.length === 0) { + throw new Error( + "This document has no readable content that could be found." + ); + } + + // Report citation for the document being summarized + this.super.addCitation?.({ + id: docInfo.document_id, + title: document.title || filename, + text: document.content, + chunkSource: null, + score: null, + }); + + const { TokenManager } = require("../../../helpers/tiktoken"); + if ( + new TokenManager(this.super.model).countFromString( + document.content + ) < Provider.contextLimit(this.super.provider, this.super.model) + ) { + return document.content; + } + + this.super.introspect( + `${this.caller}: Summarizing ${filename ?? ""}...` + ); + + // Use a named listener so we can remove it after summarization completes, + // preventing listener accumulation when summarizing many documents. + const abortListener = () => { + this.super.handlerProps.log( + "Abort was triggered, exiting summarization early." + ); + this.controller.abort(); + }; + this.super.emitter.on("abort", abortListener); + const cleanup = () => { + this.super.emitter.removeListener("abort", abortListener); + }; + + return await summarizeContent({ + provider: this.super.provider, + model: this.super.model, + controllerSignal: this.controller.signal, + content: document.content, + aibitat: this.super, + }).finally(cleanup); + } catch (error) { + this.super.handlerProps.log( + `document-summarizer.summarizeDoc raised an error. ${error.message}` + ); + return `Let the user know this action was not successful. An error was raised while summarizing the file. ${error.message}`; + } + }, + }); + }, + }; + }, +}; + +module.exports = { + docSummarizer, +}; diff --git a/server/utils/agents/aibitat/plugins/web-browsing.js b/server/utils/agents/aibitat/plugins/web-browsing.js new file mode 100644 index 0000000..dd8c303 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/web-browsing.js @@ -0,0 +1,1343 @@ +const { SystemSettings } = require("../../../../models/systemSettings"); +const { TokenManager } = require("../../../helpers/tiktoken"); +const tiktoken = new TokenManager(); + +const webBrowsing = { + name: "web-browsing", + startupConfig: { + params: {}, + }, + plugin: function () { + return { + name: this.name, + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + countTokens: (string) => + tiktoken + .countFromString(string) + .toString() + .replace(/\B(?=(\d{3})+(?!\d))/g, ","), + description: + "Search the internet for real-time information. Look online for current news, recent updates, latest changes, or any information not available locally. Browse the web to find answers about current events, prices, weather, or live data.", + examples: [ + { + prompt: "Look online for recent changes to AnythingLLM", + call: JSON.stringify({ + query: "AnythingLLM recent changes updates", + }), + }, + { + prompt: "Search the internet for the latest news", + call: JSON.stringify({ query: "latest news today" }), + }, + { + prompt: "What's the current weather in NYC?", + call: JSON.stringify({ query: "current weather New York City" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: "A search query.", + }, + }, + additionalProperties: false, + }, + handler: async function ({ query }) { + try { + if (query) return await this.search(query); + return "There is nothing we can do. This function call returns no information."; + } catch (error) { + return `There was an error while calling the function. No data or response was found. Let the user know this was the error: ${error.message}`; + } + }, + + /** + * Use Google Custom Search Engines + * Free to set up, easy to use, 100 calls/day! + * https://programmablesearchengine.google.com/controlpanel/create + */ + search: async function (query) { + const provider = + (await SystemSettings.get({ label: "agent_search_provider" })) + ?.value ?? "unknown"; + let engine; + switch (provider) { + case "serpapi": + engine = "_serpApi"; + break; + case "searchapi": + engine = "_searchApi"; + break; + case "serper-dot-dev": + engine = "_serperDotDev"; + break; + case "bing-search": + engine = "_bingWebSearch"; + break; + case "baidu-search": + engine = "_baiduSearch"; + break; + case "serply-engine": + engine = "_serplyEngine"; + break; + case "searxng-engine": + engine = "_searXNGEngine"; + break; + case "tavily-search": + engine = "_tavilySearch"; + break; + case "duckduckgo-engine": + engine = "_duckDuckGoEngine"; + break; + case "exa-search": + engine = "_exaSearch"; + break; + case "perplexity-search": + engine = "_perplexitySearch"; + break; + case "brave-search": + engine = "_braveSearch"; + break; + case "crw-search": + engine = "_crwSearch"; + break; + default: + engine = "_duckDuckGoEngine"; + } + return await this[engine](query); + }, + + /** + * Utility function to truncate a string to a given length for debugging + * calls to the API while keeping the actual values mostly intact + * @param {string} str - The string to truncate + * @param {number} length - The length to truncate the string to + * @returns {string} The truncated string + */ + middleTruncate(str, length = 5) { + if (str.length <= length) return str; + return `${str.slice(0, length)}...${str.slice(-length)}`; + }, + + /** + * Report citations for an array of search results. + * Uses title, link, and snippet directly from result data. + * @param {Array<{title?: string, link?: string, snippet?: string}>} results - Search results to report as citations + */ + reportSearchResultsCitations: function (results) { + if (!Array.isArray(results)) return; + const citations = []; + for (const result of results) { + const fallbackUrl = + result.link || + result.url || + result.website || + result.product_link || + result.patent_link || + result.link_clean; + + citations.push({ + id: result.link || fallbackUrl, + title: result.title || fallbackUrl, + text: result.snippet || result.description || result.text || "", + chunkSource: result.link + ? `link://${result.link}` + : `link://${fallbackUrl}`, + score: null, + }); + } + this.super.addCitation?.(citations); + }, + + /** + * Use SerpApi + * SerpApi supports dozens of search engines across the major platforms including Google, DuckDuckGo, Bing, eBay, Amazon, Baidu, Yandex, and more. + * https://serpapi.com/ + */ + _serpApi: async function (query) { + if (!process.env.AGENT_SERPAPI_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use SerpApi searching because the user has not defined the required API key.\nVisit: https://serpapi.com/ to create the API key for free.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using SerpApi to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const engine = process.env.AGENT_SERPAPI_ENGINE; + const queryParamKey = engine === "amazon" ? "k" : "q"; + + const params = new URLSearchParams({ + engine: engine, + [queryParamKey]: query, + api_key: process.env.AGENT_SERPAPI_API_KEY, + }); + + const url = `https://serpapi.com/search.json?${params.toString()}`; + const { response, error } = await fetch(url, { + method: "GET", + headers: {}, + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SERPAPI_API_KEY, 5), q: query })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`SerpApi Error: ${e.message}`); + return { response: null, error: e.message }; + }); + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + + switch (engine) { + case "google": + if (response.hasOwnProperty("knowledge_graph")) + data.push(response.knowledge_graph); + if (response.hasOwnProperty("answer_box")) + data.push(response.answer_box); + response.organic_results?.forEach((searchResult) => { + const { title, link, snippet } = searchResult; + data.push({ + title, + link, + snippet, + }); + }); + response.local_results?.forEach((searchResult) => { + const { + title, + rating, + reviews, + description, + address, + website, + extensions, + } = searchResult; + data.push({ + title, + rating, + reviews, + description, + address, + website, + extensions, + }); + }); + break; + case "google_maps": + response.local_results?.slice(0, 10).forEach((searchResult) => { + const { + title, + rating, + reviews, + description, + address, + website, + extensions, + } = searchResult; + data.push({ + title, + rating, + reviews, + description, + address, + website, + extensions, + }); + }); + break; + case "google_images_light": + response.images_results + ?.slice(0, 10) + .forEach((searchResult) => { + const { title, source, link, thumbnail } = searchResult; + data.push({ + title, + source, + link, + thumbnail, + }); + }); + break; + case "google_shopping_light": + response.shopping_results + ?.slice(0, 10) + .forEach((searchResult) => { + const { + title, + source, + price, + rating, + reviews, + snippet, + thumbnail, + product_link, + } = searchResult; + data.push({ + title, + source, + price, + rating, + reviews, + snippet, + thumbnail, + product_link, + }); + }); + break; + case "google_news_light": + response.news_results?.slice(0, 10).forEach((searchResult) => { + const { title, link, source, thumbnail, snippet, date } = + searchResult; + data.push({ + title, + link, + source, + thumbnail, + snippet, + date, + }); + }); + break; + case "google_jobs": + response.jobs_results?.forEach((searchResult) => { + const { + title, + company_name, + location, + description, + apply_options, + extensions, + } = searchResult; + data.push({ + title, + company_name, + location, + description, + apply_options, + extensions, + }); + }); + break; + case "google_patents": + response.organic_results?.forEach((searchResult) => { + const { + title, + patent_link, + snippet, + inventor, + assignee, + publication_number, + } = searchResult; + data.push({ + title, + patent_link, + snippet, + inventor, + assignee, + publication_number, + }); + }); + break; + case "google_scholar": + response.organic_results?.forEach((searchResult) => { + const { title, link, snippet, publication_info } = + searchResult; + data.push({ + title, + link, + snippet, + publication_info, + }); + }); + break; + case "baidu": + if (response.hasOwnProperty("answer_box")) + data.push(response.answer_box); + response.organic_results?.forEach((searchResult) => { + const { title, link, snippet } = searchResult; + data.push({ + title, + link, + snippet, + }); + }); + break; + case "amazon": + response.organic_results + ?.slice(0, 10) + .forEach((searchResult) => { + const { + title, + rating, + reviews, + price, + link_clean, + thumbnail, + } = searchResult; + data.push({ + title, + rating, + reviews, + price, + link_clean, + thumbnail, + }); + }); + } + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + + /** + * Use SearchApi + * SearchApi supports multiple search engines like Google Search, Bing Search, Baidu Search, Google News, YouTube, and many more. + * https://www.searchapi.io/ + */ + _searchApi: async function (query) { + if (!process.env.AGENT_SEARCHAPI_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use SearchApi searching because the user has not defined the required API key.\nVisit: https://www.searchapi.io/ to create the API key for free.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using SearchApi to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const engine = process.env.AGENT_SEARCHAPI_ENGINE; + const params = new URLSearchParams({ + engine: engine, + q: query, + }); + + const url = `https://www.searchapi.io/api/v1/search?${params.toString()}`; + const { response, error } = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${process.env.AGENT_SEARCHAPI_API_KEY}`, + "Content-Type": "application/json", + "X-SearchApi-Source": "AnythingLLM", + }, + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SEARCHAPI_API_KEY, 5), q: query })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`SearchApi Error: ${e.message}`); + return { response: null, error: e.message }; + }); + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + if (response.hasOwnProperty("knowledge_graph")) + data.push(response.knowledge_graph?.description); + if (response.hasOwnProperty("answer_box")) + data.push(response.answer_box?.answer); + response.organic_results?.forEach((searchResult) => { + const { title, link, snippet } = searchResult; + data.push({ + title, + link, + snippet, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + + /** + * Use Serper.dev + * Free to set up, easy to use, 2,500 calls for free one-time + * https://serper.dev + */ + _serperDotDev: async function (query) { + if (!process.env.AGENT_SERPER_DEV_KEY) { + this.super.introspect( + `${this.caller}: I can't use Serper.dev searching because the user has not defined the required API key.\nVisit: https://serper.dev to create the API key for free.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using Serper.dev to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + const { response, error } = await fetch( + "https://google.serper.dev/search", + { + method: "POST", + headers: { + "X-API-KEY": process.env.AGENT_SERPER_DEV_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ q: query }), + redirect: "follow", + } + ) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SERPER_DEV_KEY, 5), q: query })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`Serper.dev Error: ${e.message}`); + return { response: null, error: e.message }; + }); + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + if (response.hasOwnProperty("knowledgeGraph")) + data.push(response.knowledgeGraph); + response.organic?.forEach((searchResult) => { + const { title, link, snippet } = searchResult; + data.push({ + title, + link, + snippet, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _bingWebSearch: async function (query) { + if (!process.env.AGENT_BING_SEARCH_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use Bing Web Search because the user has not defined the required API key.\nVisit: https://portal.azure.com/ to create the API key.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + const searchURL = new URL( + "https://api.bing.microsoft.com/v7.0/search" + ); + searchURL.searchParams.append("q", query); + + this.super.introspect( + `${this.caller}: Using Bing Web Search to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const searchResponse = await fetch(searchURL, { + headers: { + "Ocp-Apim-Subscription-Key": + process.env.AGENT_BING_SEARCH_API_KEY, + }, + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_BING_SEARCH_API_KEY, 5), q: query })}` + ); + }) + .then((data) => { + const searchResults = data.webPages?.value || []; + return searchResults.map((result) => ({ + title: result.name, + link: result.url, + snippet: result.snippet, + })); + }) + .catch((e) => { + this.super.handlerProps.log( + `Bing Web Search Error: ${e.message}` + ); + return []; + }); + + if (searchResponse.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(searchResponse); + const result = JSON.stringify(searchResponse); + this.super.introspect( + `${this.caller}: I found ${searchResponse.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _baiduSearch: async function (query) { + if (!process.env.AGENT_BAIDU_SEARCH_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use Baidu Search because the user has not defined the required API key.\nVisit: https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5 to create the API key.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using Baidu Search to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const { response, error } = await fetch( + "https://qianfan.baidubce.com/v2/ai_search/web_search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.AGENT_BAIDU_SEARCH_API_KEY}`, + "X-Appbuilder-Authorization": `Bearer ${process.env.AGENT_BAIDU_SEARCH_API_KEY}`, + }, + body: JSON.stringify({ + messages: [{ role: "user", content: query }], + resource_type_filter: [{ type: "web", top_k: 10 }], + }), + } + ) + .then(async (res) => { + if (res.ok) return res.json(); + + const body = await res.text().catch(() => ""); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ + auth: this.middleTruncate( + process.env.AGENT_BAIDU_SEARCH_API_KEY, + 5 + ), + q: query, + body: body.slice(0, 300), + })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`Baidu Search Error: ${e.message}`); + return { response: null, error: e.message }; + }); + + if (error) + return `There was an error searching for content. ${error}`; + + if ( + (response?.code || response?.message) && + !response?.references + ) { + return `There was an error searching for content. ${response?.message || response?.code}`; + } + + /** + * Normalize Baidu Search References to the expected search results format + * @param {Array} references - The references to normalize + * @returns {Array} The normalized references + */ + function normalizeBaiduSearchReferences(references = []) { + if (!Array.isArray(references)) return []; + + const seenLinks = new Set(); + return references + .filter((reference) => { + if (!reference) return false; + const referenceType = String( + reference.type || reference.resource_type || "web" + ).toLowerCase(); + return referenceType === "web"; + }) + .map((reference) => { + const title = String( + reference.title || reference.web_anchor || "" + ).trim(); + const link = String(reference.url || "").trim(); + const snippet = String( + reference.snippet || reference.content || "" + ).trim(); + + if (!title || !link || seenLinks.has(link)) return null; + seenLinks.add(link); + + return { title, link, snippet }; + }) + .filter(Boolean); + } + + const data = normalizeBaiduSearchReferences(response?.references); + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _serplyEngine: async function ( + query, + language = "en", + hl = "us", + //eslint-disable-next-line + limit = 100, + device_type = "desktop", + proxy_location = "US" + ) { + // query (str): The query to search for + // hl (str): Host Language code to display results in (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages) + // limit (int): The maximum number of results to return [10-100, defaults to 100] + // device_type: get results based on desktop/mobile (defaults to desktop) + + if (!process.env.AGENT_SERPLY_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use Serply.io searching because the user has not defined the required API key.\nVisit: https://serply.io to create the API key for free.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using Serply to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const params = new URLSearchParams({ + q: query, + language: language, + hl, + gl: proxy_location.toUpperCase(), + }); + const url = `https://api.serply.io/v1/search/${params.toString()}`; + const { response, error } = await fetch(url, { + method: "GET", + headers: { + "X-API-KEY": process.env.AGENT_SERPLY_API_KEY, + "Content-Type": "application/json", + "User-Agent": "anything-llm", + "X-Proxy-Location": proxy_location, + "X-User-Agent": device_type, + }, + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_SERPLY_API_KEY, 5), q: query })}` + ); + }) + .then((data) => { + if (data?.message === "Unauthorized") + throw new Error( + "Unauthorized. Please double check your AGENT_SERPLY_API_KEY" + ); + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`Serply Error: ${e.message}`); + return { response: null, error: e.message }; + }); + + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + response.results?.forEach((searchResult) => { + const { title, link, description } = searchResult; + data.push({ + title, + link, + snippet: description, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _searXNGEngine: async function (query) { + let searchURL; + if (!process.env.AGENT_SEARXNG_API_URL) { + this.super.introspect( + `${this.caller}: I can't use SearXNG searching because the user has not defined the required base URL.\nPlease set this value in the agent skill settings.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + try { + searchURL = new URL(process.env.AGENT_SEARXNG_API_URL); + searchURL.searchParams.append("q", query); + searchURL.searchParams.append("format", "json"); + } catch (e) { + this.super.handlerProps.log(`SearXNG Search: ${e.message}`); + this.super.introspect( + `${this.caller}: I can't use SearXNG searching because the url provided is not a valid URL.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using SearXNG to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const { response, error } = await fetch(searchURL.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "User-Agent": "anything-llm", + }, + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ url: searchURL.toString() })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log( + `SearXNG Search Error: ${e.message}` + ); + return { response: null, error: e.message }; + }); + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + response.results?.forEach((searchResult) => { + const { url, title, content, publishedDate } = searchResult; + data.push({ + title, + link: url, + snippet: content, + publishedDate, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _tavilySearch: async function (query) { + if (!process.env.AGENT_TAVILY_API_KEY) { + this.super.introspect( + `${this.caller}: I can't use Tavily searching because the user has not defined the required API key.\nVisit: https://tavily.com/ to create the API key.` + ); + return `Search is disabled and no content was found. This functionality is disabled because the user has not set it up yet.`; + } + + this.super.introspect( + `${this.caller}: Using Tavily to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const url = "https://api.tavily.com/search"; + const { response, error } = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_key: process.env.AGENT_TAVILY_API_KEY, + query: query, + }), + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ auth: this.middleTruncate(process.env.AGENT_TAVILY_API_KEY, 5), q: query })}` + ); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log( + `Tavily Search Error: ${e.message}` + ); + return { response: null, error: e.message }; + }); + + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + response.results?.forEach((searchResult) => { + const { title, url, content } = searchResult; + data.push({ + title, + link: url, + snippet: content, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, + _duckDuckGoEngine: async function (query) { + /** + * Extract the actual destination URL from a DuckDuckGo redirect link. + * DDG links look like: //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=... + * @param {string} ddgLink - The DuckDuckGo redirect link + * @returns {string} The actual destination URL + */ + function extractUrl(ddgLink) { + if (!ddgLink) return ddgLink; + try { + const fullUrl = ddgLink.startsWith("//") + ? `https:${ddgLink}` + : ddgLink; + const url = new URL(fullUrl); + const actualUrl = url.searchParams.get("uddg"); + return actualUrl ? decodeURIComponent(actualUrl) : ddgLink; + } catch { + return ddgLink; + } + } + + this.super.introspect( + `${this.caller}: Using DuckDuckGo to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const searchURL = new URL("https://html.duckduckgo.com/html"); + searchURL.searchParams.append("q", query); + + const response = await fetch(searchURL.toString()) + .then((res) => { + if (res.ok) return res.text(); + throw new Error( + `${res.status} - ${res.statusText}. params: ${JSON.stringify({ url: searchURL.toString() })}` + ); + }) + .catch((e) => { + this.super.handlerProps.log( + `DuckDuckGo Search Error: ${e.message}` + ); + return null; + }); + + if (!response) return `There was an error searching DuckDuckGo.`; + const html = response; + const data = []; + const results = html.split('