[
  {
    "path": ".devcontainer/Dockerfile",
    "content": "FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm\n\n# Install MongoDB tools (mongosh, mongorestore, mongodump) directly from MongoDB repository\nRUN curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb-server-8.0.gpg && \\\n    echo \"deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main\" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list && \\\n    apt-get update && \\\n    apt-get install -y mongodb-mongosh mongodb-database-tools vim && \\\n    apt-get autoremove -y && \\\n    rm -rf /var/lib/apt/lists/*\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node\n{\n\t\"name\": \"Node.js & TypeScript\",\n\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n\t\"build\": {\n\t\t\"dockerfile\": \"Dockerfile\"\n\t},\n\n\t\"customizations\": {\n\t\t\"vscode\": {\n\t\t\t\"extensions\": [\"esbenp.prettier-vscode\", \"dbaeumer.vscode-eslint\", \"svelte.svelte-vscode\"]\n\t\t}\n\t},\n\n\t\"features\": {\n\t\t// Install docker in container\n\t\t\"ghcr.io/devcontainers/features/docker-in-docker:2\": {\n\t\t\t// Use proprietary docker engine. I get a timeout error when using the default moby engine and loading\n\t\t\t// microsoft's PGP keys\n\t\t\t\"moby\": false\n\t\t}\n\t}\n\n\t// Use 'forwardPorts' to make a list of ports inside the container available locally.\n\t// \"forwardPorts\": [],\n\n\t// Use 'postCreateCommand' to run commands after the container is created.\n\t// \"postCreateCommand\": \"yarn install\",\n\n\t// Configure tool-specific properties.\n\t// \"customizations\": {},\n\n\t// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.\n\t// \"remoteUser\": \"root\"\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "Dockerfile\n.vscode/\n.idea\n.gitignore\nLICENSE\nREADME.md\nnode_modules/\n.svelte-kit/\n.env*\n!.env\n.env.local\ndb\nmodels/**"
  },
  {
    "path": ".env",
    "content": "# Use .env.local to change these variables\n# DO NOT EDIT THIS FILE WITH SENSITIVE DATA\n\n### Models ###\n# Models are sourced exclusively from an OpenAI-compatible base URL.\n# Example: https://router.huggingface.co/v1\nOPENAI_BASE_URL=https://router.huggingface.co/v1\n\n# Canonical auth token for any OpenAI-compatible provider\nOPENAI_API_KEY=#your provider API key (works for HF router, OpenAI, LM Studio, etc.). \n# When set to true, user token will be used for inference calls\nUSE_USER_TOKEN=false\n# Automatically redirect to oauth login page if user is not logged in, when set to \"true\"\nAUTOMATIC_LOGIN=false\n\n### MongoDB ###\nMONGODB_URL=#your mongodb URL here, use chat-ui-db image if you don't want to set this\nMONGODB_DB_NAME=chat-ui\nMONGODB_DIRECT_CONNECTION=false\n\n\n## Public app configuration ##\nPUBLIC_APP_NAME=ChatUI # name used as title throughout the app\nPUBLIC_APP_ASSETS=chatui # used to find logos & favicons in static/$PUBLIC_APP_ASSETS\nPUBLIC_APP_DESCRIPTION=\"Making the community's best AI chat models available to everyone.\"# description used throughout the app\nPUBLIC_ORIGIN=\nPUBLIC_SHARE_PREFIX=\nPUBLIC_GOOGLE_ANALYTICS_ID=\nPUBLIC_PLAUSIBLE_SCRIPT_URL=\nPUBLIC_APPLE_APP_ID=\n\nCOUPLE_SESSION_WITH_COOKIE_NAME=\n# when OPEN_ID is configured, users are required to login after the welcome modal\nOPENID_CLIENT_ID=\"\" # You can set to \"__CIMD__\" for automatic oauth app creation when deployed, see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/\nOPENID_CLIENT_SECRET=\nOPENID_SCOPES=\"openid profile inference-api read-mcp read-billing\"\nUSE_USER_TOKEN=\nAUTOMATIC_LOGIN=# if true authentication is required on all routes\n\n### Local Storage ###\nMONGO_STORAGE_PATH= # where is the db folder stored\n\n## Models overrides\nMODELS=\n\n## Task model\n# Optional: set to the model id/name from the `${OPENAI_BASE_URL}/models` list\n# to use for internal tasks (title summarization, etc). If not set, the current model will be used\nTASK_MODEL=\n\n# Arch router (OpenAI-compatible) endpoint base URL used for route selection\n# Example: https://api.openai.com/v1 or your hosted Arch endpoint\nLLM_ROUTER_ARCH_BASE_URL=\n\n## LLM Router Configuration\n# Path to routes policy (JSON array). Required when the router is enabled; must point to a valid JSON file.\nLLM_ROUTER_ROUTES_PATH=\n\n# Model used at the Arch router endpoint for selection\nLLM_ROUTER_ARCH_MODEL=\n\n# Fallback behavior\n# Route to map \"other\" to (must exist in routes file)\nLLM_ROUTER_OTHER_ROUTE=casual_conversation\n# Model to call if the Arch selection fails entirely\nLLM_ROUTER_FALLBACK_MODEL=\n# Arch selection timeout in milliseconds (default 10000)\nLLM_ROUTER_ARCH_TIMEOUT_MS=10000\n# Maximum length (in characters) for assistant messages sent to router for route selection (default 500)\nLLM_ROUTER_MAX_ASSISTANT_LENGTH=500\n# Maximum length (in characters) for previous user messages sent to router (latest user message not trimmed, default 400)\nLLM_ROUTER_MAX_PREV_USER_LENGTH=400\n\n# Enable router multimodal handling (set to true to allow image inputs via router)\nLLM_ROUTER_ENABLE_MULTIMODAL=\n# Required when LLM_ROUTER_ENABLE_MULTIMODAL=true: id or name of the multimodal model to use for image requests\nLLM_ROUTER_MULTIMODAL_MODEL=\n\n# Enable router tool support (set to true to allow tool calling via router)\nLLM_ROUTER_ENABLE_TOOLS=\n# Required when tools are active: id or name of the model to use for MCP tool calls.\nLLM_ROUTER_TOOLS_MODEL=\n\n# Router UI overrides (client-visible)\n# Public display name for the router entry in the model list. Defaults to \"Omni\".\nPUBLIC_LLM_ROUTER_DISPLAY_NAME=Omni\n# Optional: public logo URL for the router entry. If unset, the UI shows a Carbon icon.\nPUBLIC_LLM_ROUTER_LOGO_URL=\n# Public alias id used for the virtual router model (Omni). Defaults to \"omni\".\nPUBLIC_LLM_ROUTER_ALIAS_ID=omni\n\n### Transcription ###\n# Voice-to-text transcription using Whisper models\n# If set, enables the microphone button in the chat input\n# Example: openai/whisper-large-v3-turbo\nTRANSCRIPTION_MODEL=\n# Optional: Base URL for transcription API (defaults to HF inference)\n# Default: https://router.huggingface.co/hf-inference/models\nTRANSCRIPTION_BASE_URL=\n\n### Authentication ###\n# Parameters to enable open id login\nOPENID_CONFIG=\n# if it's defined, only these emails will be allowed to use login\nALLOWED_USER_EMAILS=[]\n# If it's defined, users with emails matching these domains will also be allowed to use login\nALLOWED_USER_DOMAINS=[]\n# valid alternative redirect URLs for OAuth, used for HuggingChat apps\nALTERNATIVE_REDIRECT_URLS=[] \n### Cookies\n# name of the cookie used to store the session\nCOOKIE_NAME=hf-chat\n# If the value of this cookie changes, the session is destroyed. Useful if chat-ui is deployed on a subpath\n# of your domain, and you want chat ui sessions to reset if the user's auth changes\nCOUPLE_SESSION_WITH_COOKIE_NAME=\n# specify secure behaviour for cookies \nCOOKIE_SAMESITE=# can be \"lax\", \"strict\", \"none\" or left empty\nCOOKIE_SECURE=# set to true to only allow cookies over https\nTRUSTED_EMAIL_HEADER=# header to use to get the user email, only use if you know what you are doing\n\n### Admin stuff ###\nADMIN_CLI_LOGIN=true # set to false to disable the CLI login\nADMIN_TOKEN=#We recommend leaving this empty, you can get the token from the terminal.\n\n### Feature Flags ###\nLLM_SUMMARIZATION=true # generate conversation titles with LLMs\n \nALLOW_IFRAME=true # Allow the app to be embedded in an iframe\n\n# Base servers list (JSON array). Example: MCP_SERVERS=[{\"name\": \"Web Search (Exa)\", \"url\": \"https://mcp.exa.ai/mcp\"}, {\"name\": \"Hugging Face\", \"url\": \"https://hf.co/mcp\"}]\nMCP_SERVERS=\n# When true, forward the logged-in user's Hugging Face access token\nMCP_FORWARD_HF_USER_TOKEN=\n# Exa API key (injected at runtime into mcp.exa.ai URLs as ?exaApiKey=)\nEXA_API_KEY=\n# Timeout in milliseconds for MCP tool calls (default: 120000 = 2 minutes)\nMCP_TOOL_TIMEOUT_MS=\nENABLE_DATA_EXPORT=true\n\n### Rate limits ### \n# See `src/lib/server/usageLimits.ts`\n# {\n#   conversations: number, # how many conversations\n#   messages: number, # how many messages in a conversation\n#   assistants: number, # how many assistants\n#   messageLength: number, # how long can a message be before we cut it off\n#   messagesPerMinute: number, # how many messages per minute\n#   tools: number # how many tools\n# }\nUSAGE_LIMITS={}\n\n### HuggingFace specific ###\n## Feature flag & admin settings\n# Used for setting early access & admin flags to users\nHF_ORG_ADMIN=\nHF_ORG_EARLY_ACCESS=\nWEBHOOK_URL_REPORT_ASSISTANT=#provide slack webhook url to get notified for reports/feature requests\n\n\n### Metrics ###\nMETRICS_ENABLED=false\nMETRICS_PORT=5565\nLOG_LEVEL=info\n\n\n### Parquet export ###\n# Not in use anymore but useful to export conversations to a parquet file as a HuggingFace dataset\nPARQUET_EXPORT_DATASET=\nPARQUET_EXPORT_HF_TOKEN=\nADMIN_API_SECRET=# secret to admin API calls, like computing usage stats or exporting parquet data\n\n### Config ###\nENABLE_CONFIG_MANAGER=true\n\n### Docker build variables ### \n# These values cannot be updated at runtime\n# They need to be passed when building the docker image\n# See https://github.com/huggingface/chat-ui/main/.github/workflows/deploy-prod.yml#L44-L47\nAPP_BASE=\"\" # base path of the app, e.g. /chat, left blank as default\n### Body size limit for SvelteKit https://svelte.dev/docs/kit/adapter-node#Environment-variables-BODY_SIZE_LIMIT\nBODY_SIZE_LIMIT=15728640\nPUBLIC_COMMIT_SHA=\n\n### LEGACY parameters\nALLOW_INSECURE_COOKIES=false # LEGACY! Use COOKIE_SECURE and COOKIE_SAMESITE instead\nPARQUET_EXPORT_SECRET=#DEPRECATED, use ADMIN_API_SECRET instead\nRATE_LIMIT= # /!\\ DEPRECATED definition of messages per minute. Use USAGE_LIMITS.messagesPerMinute instead\nOPENID_NAME_CLAIM=\"name\" # Change to \"username\" for some providers that do not provide name\nOPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.google.com\nOPENID_TOLERANCE=\nOPENID_RESOURCE=\nEXPOSE_API=# deprecated, API is now always exposed\n"
  },
  {
    "path": ".env.ci",
    "content": "MONGODB_URL=mongodb://localhost:27017/"
  },
  {
    "path": ".eslintignore",
    "content": ".DS_Store\nnode_modules\n/build\n/.svelte-kit\n/package\n.env\n.env.*\n!.env.example\n\n# Ignore files for PNPM, NPM and YARN\npnpm-lock.yaml\npackage-lock.json\nyarn.lock\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n\troot: true,\n\tparser: \"@typescript-eslint/parser\",\n\textends: [\n\t\t\"eslint:recommended\",\n\t\t\"plugin:@typescript-eslint/recommended\",\n\t\t\"plugin:svelte/recommended\",\n\t\t\"prettier\",\n\t],\n\tplugins: [\"@typescript-eslint\"],\n\tignorePatterns: [\"*.cjs\"],\n\toverrides: [\n\t\t{\n\t\t\tfiles: [\"*.svelte\"],\n\t\t\tparser: \"svelte-eslint-parser\",\n\t\t\tparserOptions: {\n\t\t\t\tparser: \"@typescript-eslint/parser\",\n\t\t\t},\n\t\t},\n\t],\n\tparserOptions: {\n\t\tsourceType: \"module\",\n\t\tecmaVersion: 2020,\n\t\textraFileExtensions: [\".svelte\"],\n\t},\n\trules: {\n\t\t\"no-empty\": \"off\",\n\t\t\"require-yield\": \"off\",\n\t\t\"@typescript-eslint/no-explicit-any\": \"error\",\n\t\t\"@typescript-eslint/no-non-null-assertion\": \"error\",\n\t\t\"@typescript-eslint/no-unused-vars\": [\n\t\t\t// prevent variables with a _ prefix from being marked as unused\n\t\t\t\"error\",\n\t\t\t{\n\t\t\t\targsIgnorePattern: \"^_\",\n\t\t\t},\n\t\t],\n\t\t\"object-shorthand\": [\"error\", \"always\"],\n\t},\n\tenv: {\n\t\tbrowser: true,\n\t\tes2017: true,\n\t\tnode: true,\n\t},\n};\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report--chat-ui-.md",
    "content": "---\nname: Bug Report (chat-ui)\nabout: Use this for confirmed issues with chat-ui\ntitle: \"\"\nlabels: bug\nassignees: \"\"\n---\n\n## Bug description\n\n<!-- A clear and concise description of what the bug is. -->\n\n## Steps to reproduce\n\n<!-- Steps to reproduce the issue -->\n\n## Screenshots\n\n<!-- If applicable, add screenshots to help explain your problem. -->\n\n## Context\n\n### Logs\n\n<!-- Add any logs that are relevant to your issue. Could be browser or server logs. Wrap in code blocks. -->\n\n```\n// logs here if relevant\n```\n\n### Specs\n\n- **OS**:\n- **Browser**:\n- **chat-ui commit**:\n\n### Config\n\n<!-- Add the environment variables you've used to setup chat-ui, making sure to redact any secrets. -->\n\n## Notes\n\n<!-- Anything else relevant to help the issue get solved -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config-support.md",
    "content": "---\nname: Config Support\nabout: Help with setting up chat-ui locally\ntitle: \"\"\nlabels: support\nassignees: \"\"\n---\n\n**Please use the discussions on GitHub** for getting help with setting things up instead of opening an issue: https://github.com/huggingface/chat-ui/discussions\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request--chat-ui-.md",
    "content": "---\nname: Feature Request (chat-ui)\nabout: Suggest new features to be added to chat-ui\ntitle: \"\"\nlabels: enhancement\nassignees: \"\"\n---\n\n## Describe your feature request\n\n<!-- Short description of what this is about -->\n\n## Screenshots (if relevant)\n\n## Implementation idea\n\n<!-- If you know how this should be implemented in the codebase, share your thoughts. Let us know if you feel like implementing it yourself as well! -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/huggingchat.md",
    "content": "---\nname: HuggingChat\nabout: Requests & reporting outages on HuggingChat, the hosted version of chat-ui.\ntitle: \"\"\nlabels: huggingchat\nassignees: \"\"\n---\n\n**Do not use GitHub issues** for requesting models on HuggingChat or reporting issues with HuggingChat being down/overloaded.\n\n**Use the discussions page on the hub instead:** https://huggingface.co/spaces/huggingchat/chat-ui/discussions\n"
  },
  {
    "path": ".github/release.yml",
    "content": "changelog:\n  exclude:\n    labels:\n      - huggingchat\n      - CI/CD\n      - documentation\n  categories:\n    - title: Features\n      labels:\n        - enhancement\n    - title: Bugfixes\n      labels:\n        - bug\n    - title: Other changes\n      labels:\n        - \"*\"\n"
  },
  {
    "path": ".github/workflows/build-docs.yml",
    "content": "name: Build documentation\n\non:\n  push:\n    branches:\n      - main\n      - v*-release\n\njobs:\n  build:\n    uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@main\n    with:\n      commit_sha: ${{ github.sha }}\n      package: chat-ui\n      additional_args: --not_python_module\n    secrets:\n      token: ${{ secrets.HUGGINGFACE_PUSH }}\n      hf_token: ${{ secrets.HF_DOC_BUILD_PUSH }}\n"
  },
  {
    "path": ".github/workflows/build-image.yml",
    "content": "name: Build and Publish Image\n\npermissions:\n  packages: write\n\non:\n  push:\n    branches:\n      - \"main\"\n  pull_request:\n    branches:\n      - \"*\"\n    paths:\n      - \"Dockerfile\"\n      - \"entrypoint.sh\"\n  workflow_dispatch:\n  release:\n    types: [published, edited]\n\njobs:\n  build-and-publish-image-with-db:\n    runs-on:\n      group: aws-general-8-plus\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Extract package version\n        id: package-version\n        run: |\n          VERSION=$(jq -r .version package.json)\n          echo \"VERSION=$VERSION\" >> $GITHUB_OUTPUT\n          MAJOR=$(echo $VERSION | cut -d '.' -f1)\n          echo \"MAJOR=$MAJOR\" >> $GITHUB_OUTPUT\n          MINOR=$(echo $VERSION | cut -d '.' -f1).$(echo $VERSION | cut -d '.' -f2)\n          echo \"MINOR=$MINOR\" >> $GITHUB_OUTPUT\n\n      - name: Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ghcr.io/huggingface/chat-ui-db\n          tags: |\n            type=raw,value=${{ steps.package-version.outputs.VERSION }},enable=${{github.event_name == 'release'}}\n            type=raw,value=${{ steps.package-version.outputs.MAJOR }},enable=${{github.event_name == 'release'}}\n            type=raw,value=${{ steps.package-version.outputs.MINOR }},enable=${{github.event_name == 'release'}}\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=sha,enable={{is_default_branch}}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Inject slug/short variables\n        uses: rlespinasse/github-slug-action@v4.5.0\n\n      - name: Build and Publish Docker Image with DB\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: Dockerfile\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-args: |\n            INCLUDE_DB=true\n            PUBLIC_COMMIT_SHA=${{ env.GITHUB_SHA_SHORT }}\n  build-and-publish-image-nodb:\n    runs-on:\n      group: aws-general-8-plus\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Extract package version\n        id: package-version\n        run: |\n          VERSION=$(jq -r .version package.json)\n          echo \"VERSION=$VERSION\" >> $GITHUB_OUTPUT\n          MAJOR=$(echo $VERSION | cut -d '.' -f1)\n          echo \"MAJOR=$MAJOR\" >> $GITHUB_OUTPUT\n          MINOR=$(echo $VERSION | cut -d '.' -f1).$(echo $VERSION | cut -d '.' -f2)\n          echo \"MINOR=$MINOR\" >> $GITHUB_OUTPUT\n\n      - name: Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ghcr.io/huggingface/chat-ui\n          tags: |\n            type=raw,value=${{ steps.package-version.outputs.VERSION }},enable=${{github.event_name == 'release'}}\n            type=raw,value=${{ steps.package-version.outputs.MAJOR }},enable=${{github.event_name == 'release'}}\n            type=raw,value=${{ steps.package-version.outputs.MINOR }},enable=${{github.event_name == 'release'}}\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=sha,enable={{is_default_branch}}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Inject slug/short variables\n        uses: rlespinasse/github-slug-action@v4.5.0\n\n      - name: Build and Publish Docker Image without DB\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: Dockerfile\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-args: |\n            INCLUDE_DB=false\n            PUBLIC_COMMIT_SHA=${{ env.GITHUB_SHA_SHORT }}\n"
  },
  {
    "path": ".github/workflows/build-pr-docs.yml",
    "content": "name: Build PR Documentation\n\non:\n  pull_request:\n    paths:\n      - \"docs/source/**\"\n      - \".github/workflows/build-pr-docs.yml\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}\n  cancel-in-progress: true\n\njobs:\n  build:\n    uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@main\n    with:\n      commit_sha: ${{ github.event.pull_request.head.sha }}\n      pr_number: ${{ github.event.number }}\n      package: chat-ui\n      additional_args: --not_python_module\n"
  },
  {
    "path": ".github/workflows/deploy-dev.yml",
    "content": "name: Deploy to ephemeral\non:\n  pull_request:\n    types: [opened, reopened, synchronize, labeled, unlabeled]\n\njobs:\n  branch-slug:\n    uses: ./.github/workflows/slugify.yaml\n    with:\n      value: ${{ github.head_ref }}\n\n  deploy-dev:\n    if: contains(github.event.pull_request.labels.*.name, 'preview')\n    runs-on: ubuntu-latest\n    needs: branch-slug\n    environment:\n      name: dev\n      url: https://${{ needs.branch-slug.outputs.slug }}.chat-dev.huggingface.tech/chat/\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Login to Registry\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Inject slug/short variables\n        uses: rlespinasse/github-slug-action@v4.5.0\n\n      - name: Set GITHUB_SHA_SHORT from PR\n        if: env.GITHUB_EVENT_PULL_REQUEST_HEAD_SHA_SHORT != null\n        run: echo \"GITHUB_SHA_SHORT=${{ env.GITHUB_EVENT_PULL_REQUEST_HEAD_SHA_SHORT }}\" >> $GITHUB_ENV\n\n      - name: Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            huggingface/chat-ui\n          tags: |\n            type=raw,value=dev-${{ env.GITHUB_SHA_SHORT }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and Publish HuggingChat image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: Dockerfile\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64\n          cache-to: type=gha,mode=max,scope=amd64\n          cache-from: type=gha,scope=amd64\n          provenance: false\n          build-args: |\n            INCLUDE_DB=false\n            APP_BASE=/chat\n            PUBLIC_COMMIT_SHA=${{ env.GITHUB_SHA_SHORT }}\n"
  },
  {
    "path": ".github/workflows/deploy-prod.yml",
    "content": "name: Deploy to k8s\non:\n  # run this workflow manually from the Actions tab\n  workflow_dispatch:\n\njobs:\n  build-and-publish-huggingchat-image:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Login to Registry\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            huggingface/chat-ui\n          tags: |\n            type=raw,value=latest,enable={{is_default_branch}}\n            type=sha,enable=true,prefix=sha-,format=short,sha-len=8\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Inject slug/short variables\n        uses: rlespinasse/github-slug-action@v4.5.0\n\n      - name: Build and Publish HuggingChat image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: Dockerfile\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64\n          cache-to: type=gha,mode=max,scope=amd64\n          cache-from: type=gha,scope=amd64\n          provenance: false\n          build-args: |\n            INCLUDE_DB=false\n            APP_BASE=/chat\n            PUBLIC_COMMIT_SHA=${{ env.GITHUB_SHA_SHORT }}\n  deploy:\n    name: Deploy on prod\n    runs-on: ubuntu-latest\n    needs: [\"build-and-publish-huggingchat-image\"]\n    steps:\n      - name: Inject slug/short variables\n        uses: rlespinasse/github-slug-action@v4.5.0\n\n      - name: Gen values\n        run: |\n          VALUES=$(cat <<-END\n          image:\n            tag: \"sha-${{ env.GITHUB_SHA_SHORT }}\"\n          END\n          )\n          echo \"VALUES=$(echo \"$VALUES\" | yq -o=json | jq tostring)\" >> $GITHUB_ENV\n\n      - name: Deploy on infra-deployments\n        uses: aurelien-baudet/workflow-dispatch@v2\n        with:\n          workflow: Update application single value\n          repo: huggingface/infra-deployments\n          wait-for-completion: true\n          wait-for-completion-interval: 10s\n          display-workflow-run-url-interval: 10s\n          ref: refs/heads/main\n          token: ${{ secrets.GIT_TOKEN_INFRA_DEPLOYMENT }}\n          inputs: '{\"path\": \"hub/chat-ui/chat-ui.yaml\", \"value\": ${{ env.VALUES }}, \"url\": \"${{ github.event.head_commit.url }}\"}'\n"
  },
  {
    "path": ".github/workflows/lint-and-test.yml",
    "content": "name: Lint and test\n\non:\n  pull_request:\n  push:\n    branches:\n      - main\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - uses: actions/setup-node@v3\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n      - run: |\n          npm install ci\n      - name: \"Checking lint/format errors\"\n        run: |\n          npm run lint\n      - name: \"Checking type errors\"\n        run: |\n          npm run check\n\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n      - run: |\n          npm ci\n          npx playwright install\n      - name: \"Tests\"\n        run: |\n          npm run test\n\n  build-check:\n    runs-on:\n      group: aws-general-8-plus\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@v3\n      - name: Build Docker image\n        run: |\n          docker build \\\n            --build-arg INCLUDE_DB=true \\\n            -t chat-ui-test:latest .\n\n      - name: Run Docker container\n        run: |\n          export DOTENV_LOCAL=$(<.env.ci)\n          docker run -d --rm --network=host \\\n            --name chat-ui-test \\\n            -e DOTENV_LOCAL=\"$DOTENV_LOCAL\" \\\n            chat-ui-test:latest\n\n      - name: Wait for server to start\n        run: |\n          for i in {1..10}; do\n            if curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/ | grep -q \"200\"; then\n              echo \"Server is up\"\n              exit 0\n            fi\n            echo \"Waiting for server...\"\n            sleep 2\n          done\n          echo \"Server did not start in time\"\n          docker logs chat-ui-test\n          exit 1\n\n      - name: Stop Docker container\n        if: always()\n        run: |\n          docker stop chat-ui-test || true\n"
  },
  {
    "path": ".github/workflows/slugify.yaml",
    "content": "name: Generate Branch Slug\n\non:\n  workflow_call:\n    inputs:\n      value:\n        description: \"Value to slugify\"\n        required: true\n        type: string\n    outputs:\n      slug:\n        description: \"Slugified value\"\n        value: ${{ jobs.generate-slug.outputs.slug }}\n\njobs:\n  generate-slug:\n    runs-on: ubuntu-latest\n    outputs:\n      slug: ${{ steps.slugify.outputs.slug }}\n\n    steps:\n      - name: Setup Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.21\"\n\n      - name: Generate slug\n        id: slugify\n        run: |\n          # Create working directory\n          mkdir -p $HOME/slugify\n          cd $HOME/slugify\n\n          # Create Go script\n          cat > main.go << 'EOF'\n          package main\n\n          import (\n              \"fmt\"\n              \"os\"\n              \"github.com/gosimple/slug\"\n          )\n\n          func main() {\n              if len(os.Args) < 2 {\n                  fmt.Println(\"Usage: slugify <text>\")\n                  os.Exit(1)\n              }\n\n              text := os.Args[1]\n              slugged := slug.Make(text)\n              fmt.Println(slugged)\n          }\n          EOF\n\n          # Initialize module and install dependency\n          go mod init slugify\n          go mod tidy\n          go get github.com/gosimple/slug\n\n          # Build\n          go build -o slugify main.go\n\n          # Generate slug\n          VALUE=\"${{ inputs.value }}\"\n          echo \"Input value: $VALUE\"\n\n          SLUG=$(./slugify \"$VALUE\")\n          echo \"Generated slug: $SLUG\"\n\n          # Export\n          echo \"slug=$SLUG\" >> $GITHUB_OUTPUT\n"
  },
  {
    "path": ".github/workflows/trufflehog.yml",
    "content": "on:\n  push:\n\nname: Secret Leaks\n\njobs:\n  trufflehog:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Secret Scanning\n        uses: trufflesecurity/trufflehog@main\n        with:\n          extra_args: --results=verified,unknown\n"
  },
  {
    "path": ".github/workflows/upload-pr-documentation.yml",
    "content": "name: Upload PR Documentation\n\non:\n  workflow_run:\n    workflows: [\"Build PR Documentation\"]\n    types:\n      - completed\n\njobs:\n  build:\n    uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@main\n    with:\n      package_name: chat-ui\n    secrets:\n      hf_token: ${{ secrets.HF_DOC_BUILD_PUSH }}\n      comment_bot_token: ${{ secrets.COMMENT_BOT_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules\n/build\n/.svelte-kit\n/package\n.env\n.env.*\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\nSECRET_CONFIG\n.idea\n!.env.ci\n!.env\ngcp-*.json\ndb\nmodels/*\n!models/add-your-models-here.txt\n.claude/*\n!.claude/skills/"
  },
  {
    "path": ".husky/lint-stage-config.js",
    "content": "export default {\n\t\"*.{js,jsx,ts,tsx}\": [\"prettier --write\", \"eslint --fix\", \"eslint\"],\n\t\"*.json\": [\"prettier --write\"],\n};\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "set -e\nnpx lint-staged --config ./.husky/lint-stage-config.js\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": ".prettierignore",
    "content": ".DS_Store\nnode_modules\n/build\n/.svelte-kit\n/package\n/chart\n.env\n.env.*\n!.env.example\n\n# Ignore files for PNPM, NPM and YARN\npnpm-lock.yaml\npackage-lock.json\nyarn.lock\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n\t\"useTabs\": true,\n\t\"trailingComma\": \"es5\",\n\t\"printWidth\": 100,\n\t\"plugins\": [\"prettier-plugin-svelte\", \"prettier-plugin-tailwindcss\"],\n\t\"overrides\": [{ \"files\": \"*.svelte\", \"options\": { \"parser\": \"svelte\" } }]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n\t\"version\": \"0.2.0\",\n\t\"configurations\": [\n\t\t{\n\t\t\t\"command\": \"npm run dev\",\n\t\t\t\"name\": \"Run development server\",\n\t\t\t\"request\": \"launch\",\n\t\t\t\"type\": \"node-terminal\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n\t\"editor.formatOnSave\": true,\n\t\"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n\t\"editor.codeActionsOnSave\": {\n\t\t\"source.fixAll\": \"explicit\"\n\t},\n\t\"eslint.validate\": [\"javascript\", \"svelte\"],\n\t\"[svelte]\": {\n\t\t\"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n\t},\n\t\"[typescript]\": {\n\t\t\"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n\t}\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Overview\n\nChat UI is a SvelteKit application that provides a chat interface for LLMs. It powers HuggingChat (hf.co/chat). The app speaks exclusively to OpenAI-compatible APIs via `OPENAI_BASE_URL`.\n\n## Commands\n\n```bash\nnpm run dev          # Start dev server on localhost:5173\nnpm run build        # Production build\nnpm run preview      # Preview production build\nnpm run check        # TypeScript validation (svelte-kit sync + svelte-check)\nnpm run lint         # Check formatting (Prettier) and linting (ESLint)\nnpm run format       # Auto-format with Prettier\nnpm run test         # Run all tests (Vitest)\n```\n\n### Running a Single Test\n\n```bash\nnpx vitest run path/to/file.spec.ts        # Run specific test file\nnpx vitest run -t \"test name\"              # Run test by name\nnpx vitest --watch path/to/file.spec.ts    # Watch mode for single file\n```\n\n### Test Environments\n\nTests are split into three workspaces (configured in vite.config.ts):\n\n- **Client tests** (`*.svelte.test.ts`): Browser environment with Playwright\n- **SSR tests** (`*.ssr.test.ts`): Node environment for server-side rendering\n- **Server tests** (`*.test.ts`, `*.spec.ts`): Node environment for utilities\n\n## Architecture\n\n### Stack\n\n- **SvelteKit 2** with Svelte 5 (uses runes: `$state`, `$effect`, `$bindable`)\n- **MongoDB** for persistence (auto-fallback to in-memory with MongoMemoryServer when `MONGODB_URL` not set)\n- **TailwindCSS** for styling\n\n### Key Directories\n\n```\nsrc/\n├── lib/\n│   ├── components/       # Svelte components (chat/, mcp/, voice/, icons/)\n│   ├── server/\n│   │   ├── api/utils/       # Shared API helpers (auth, superjson, model/conversation resolvers)\n│   │   ├── textGeneration/  # LLM streaming pipeline\n│   │   ├── mcp/          # Model Context Protocol integration\n│   │   ├── router/       # Smart model routing (Omni)\n│   │   ├── database.ts   # MongoDB collections\n│   │   ├── models.ts     # Model registry from OPENAI_BASE_URL/models\n│   │   └── auth.ts       # OpenID Connect authentication\n│   ├── types/            # TypeScript interfaces (Conversation, Message, User, Model, etc.)\n│   ├── stores/           # Svelte stores for reactive state\n│   └── utils/            # Helpers (tree/, marked.ts, auth.ts, etc.)\n├── routes/               # SvelteKit file-based routing\n│   ├── conversation/[id]/  # Chat page + streaming endpoint\n│   ├── settings/         # User settings pages\n│   ├── api/              # Legacy v1 API endpoints (mcp, transcribe, fetch-url)\n│   ├── api/v2/           # REST API endpoints (+server.ts)\n│   └── r/[id]/           # Shared conversation view\n```\n\n### Text Generation Flow\n\n1. User sends message via `POST /conversation/[id]`\n2. Server validates user, fetches conversation history\n3. Builds message tree structure (see `src/lib/utils/tree/`)\n4. Calls LLM endpoint via OpenAI client\n5. Streams response back, stores in MongoDB\n\n### Model Context Protocol (MCP)\n\nMCP servers are configured via `MCP_SERVERS` env var. When enabled, tools are exposed as OpenAI function calls. The router can auto-select tools-capable models when `LLM_ROUTER_ENABLE_TOOLS=true`.\n\n### LLM Router (Omni)\n\nSmart routing via Arch-Router model. Configured with:\n\n- `LLM_ROUTER_ROUTES_PATH`: JSON file defining routes\n- `LLM_ROUTER_ARCH_BASE_URL`: Router endpoint\n- Shortcuts: multimodal routes bypass router if `LLM_ROUTER_ENABLE_MULTIMODAL=true`\n\n### Database Collections\n\n- `conversations` - Chat sessions with nested messages\n- `users` - User accounts (OIDC-backed)\n- `sessions` - Session data\n- `sharedConversations` - Public share links\n- `settings` - User preferences\n\n## Environment Setup\n\nCopy `.env` to `.env.local` and configure:\n\n```env\nOPENAI_BASE_URL=https://router.huggingface.co/v1\nOPENAI_API_KEY=hf_***\n# MONGODB_URL is optional; omit for in-memory DB persisted to ./db\n```\n\nSee `.env` for full list of variables including router config, MCP servers, auth, and feature flags.\n\n## Code Conventions\n\n- TypeScript strict mode enabled\n- ESLint: no `any`, no non-null assertions\n- Prettier: tabs, 100 char width, Tailwind class sorting\n- Server vs client separation via SvelteKit conventions (`+page.server.ts` vs `+page.ts`)\n\n## Feature Development Checklist\n\nWhen building new features, consider:\n\n1. **HuggingChat vs self-hosted**: Wrap HuggingChat-specific features with `publicConfig.isHuggingChat`\n2. **Settings persistence**: Add new fields to `src/lib/types/Settings.ts`, update API endpoint at `src/routes/api/v2/user/settings/+server.ts`\n3. **Rich dropdowns**: Use `bits-ui` (Select, DropdownMenu) instead of native elements when you need icons/images in options\n4. **Scrollbars**: Use `scrollbar-custom` class for styled scrollbars\n5. **Icons**: Custom icons in `$lib/components/icons/`, use Carbon (`~icons/carbon/*`) or Lucide (`~icons/lucide/*`) for standard icons\n6. **Provider avatars**: Use `PROVIDERS_HUB_ORGS` from `@huggingface/inference` for HF provider avatar URLs\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nARG INCLUDE_DB=false\n\nFROM node:24-slim AS base\n\n# install dotenv-cli\nRUN npm install -g dotenv-cli\n\n# switch to a user that works for spaces\nRUN userdel -r node\nRUN useradd -m -u 1000 user\nUSER user\n\nENV HOME=/home/user \\\n    PATH=/home/user/.local/bin:$PATH\n\nWORKDIR /app\n\n# add a .env.local if the user doesn't bind a volume to it\nRUN touch /app/.env.local\n\nUSER root\nRUN apt-get update\nRUN apt-get install -y libgomp1 libcurl4 curl dnsutils nano\n\n# ensure npm cache dir exists before adjusting ownership\nRUN mkdir -p /home/user/.npm && chown -R 1000:1000 /home/user/.npm\n\nUSER user\n\n\nCOPY --chown=1000 .env /app/.env\nCOPY --chown=1000 entrypoint.sh /app/entrypoint.sh\nCOPY --chown=1000 package.json /app/package.json\nCOPY --chown=1000 package-lock.json /app/package-lock.json\n\nRUN chmod +x /app/entrypoint.sh\n\nFROM node:24 AS builder\n\nWORKDIR /app\n\nCOPY --link --chown=1000 package-lock.json package.json ./\n\nARG APP_BASE=\nARG PUBLIC_APP_COLOR=\nENV BODY_SIZE_LIMIT=15728640\n\nRUN --mount=type=cache,target=/app/.npm \\\n    npm set cache /app/.npm && \\\n    npm ci\n\nCOPY --link --chown=1000 . .\n\nRUN git config --global --add safe.directory /app && \\\n    npm run build\n\n# mongo image\nFROM mongo:7 AS mongo\n\n# image to be used if INCLUDE_DB is false\nFROM base AS local_db_false\n\n# image to be used if INCLUDE_DB is true\nFROM base AS local_db_true\n\n# copy mongo from the other stage\nCOPY --from=mongo /usr/bin/mongo* /usr/bin/\n\nENV MONGODB_URL=mongodb://localhost:27017\nUSER root\nRUN mkdir -p /data/db\nRUN chown -R 1000:1000 /data/db\nUSER user\n# final image\nFROM local_db_${INCLUDE_DB} AS final\n\n# build arg to determine if the database should be included\nARG INCLUDE_DB=false\nENV INCLUDE_DB=${INCLUDE_DB}\n\n# svelte requires APP_BASE at build time so it must be passed as a build arg\nARG APP_BASE=\nARG PUBLIC_APP_COLOR=\nARG PUBLIC_COMMIT_SHA=\nENV PUBLIC_COMMIT_SHA=${PUBLIC_COMMIT_SHA}\nENV BODY_SIZE_LIMIT=15728640\n\n#import the build & dependencies\nCOPY --from=builder --chown=1000 /app/build /app/build\nCOPY --from=builder --chown=1000 /app/node_modules /app/node_modules\n\nCMD [\"/bin/bash\", \"-c\", \"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2018- The Hugging Face team. All rights reserved.\n\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "PRIVACY.md",
    "content": "## Privacy\n\n> Last updated: Sep 15, 2025\n\nBasics:\n\n- Sign-in: You authenticate with your Hugging Face account.\n- Conversation history: Stored so you can access past chats; you can delete any conversation at any time from the UI.\n\n🗓 Please also consult huggingface.co's main privacy policy at <https://huggingface.co/privacy>. To exercise any of your legal privacy rights, please send an email to <privacy@huggingface.co>.\n\n## Data handling and processing\n\nHuggingChat uses Hugging Face’s Inference Providers to access models from multiple partners via a single API. Depending on the model and availability, inference runs with the corresponding provider.\n\n- Inference Providers documentation: <https://huggingface.co/docs/inference-providers>\n- Security & Compliance: <https://huggingface.co/docs/inference-providers/security>\n\nSecurity and routing facts\n\n- Hugging Face does not store any user data for training purposes.\n- Hugging Face does not store the request body or the response when routing requests through Hugging Face.\n- Logs are kept for debugging purposes for up to 30 days, but no user data or tokens are stored in those logs.\n- Inference Provider routing uses TLS/SSL to encrypt data in transit.\n- The Hugging Face Hub (which Inference Providers is a feature of) is SOC 2 Type 2 certified. See <https://huggingface.co/docs/hub/security>.\n\nExternal providers are responsible for their own security and data handling. Please consult each provider’s respective security and privacy policies via the Inference Providers documentation linked above.\n\n## Technical details\n\n[![chat-ui](https://img.shields.io/github/stars/huggingface/chat-ui)](https://github.com/huggingface/chat-ui)\n\nThe app is completely open source, and further development takes place on the [huggingface/chat-ui](https://github.com/huggingface/chat-ui) GitHub repo. We're always open to contributions!\n\nYou can find the production configuration for HuggingChat [here](https://github.com/huggingface/chat-ui/blob/main/chart/env/prod.yaml).\n\nHuggingChat connects to the OpenAI‑compatible Inference Providers router at `https://router.huggingface.co/v1` to access models across multiple providers. Provider selection may be automatic or fixed depending on the model configuration.\n\nWe welcome any feedback on this app: please participate in the public discussion at <https://huggingface.co/spaces/huggingchat/chat-ui/discussions>\n\n<a target=\"_blank\" href=\"https://huggingface.co/spaces/huggingchat/chat-ui/discussions\"><img src=\"https://huggingface.co/datasets/huggingface/badges/raw/main/open-a-discussion-xl.svg\" title=\"open a discussion\"></a>\n"
  },
  {
    "path": "README.md",
    "content": "# Chat UI\n\n![Chat UI repository thumbnail](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/chat-ui/chat-ui-2026.png)\n\nA chat interface for LLMs. It is a SvelteKit app and it powers the [HuggingChat app on hf.co/chat](https://huggingface.co/chat).\n\n0. [Quickstart](#quickstart)\n1. [Database Options](#database-options)\n2. [Launch](#launch)\n3. [Optional Docker Image](#optional-docker-image)\n4. [Extra parameters](#extra-parameters)\n5. [Building](#building)\n\n> [!NOTE]\n> Chat UI only supports OpenAI-compatible APIs via `OPENAI_BASE_URL` and the `/models` endpoint. Provider-specific integrations (legacy `MODELS` env var, GGUF discovery, embeddings, web-search helpers, etc.) are removed, but any service that speaks the OpenAI protocol (llama.cpp server, Ollama, OpenRouter, etc. will work by default).\n\n> [!NOTE]\n> The old version is still available on the [legacy branch](https://github.com/huggingface/chat-ui/tree/legacy)\n\n## Quickstart\n\nChat UI speaks to OpenAI-compatible APIs only. The fastest way to get running is with the Hugging Face Inference Providers router plus your personal Hugging Face access token.\n\n**Step 1 – Create `.env.local`:**\n\n```env\nOPENAI_BASE_URL=https://router.huggingface.co/v1\nOPENAI_API_KEY=hf_************************\n```\n\n`OPENAI_API_KEY` can come from any OpenAI-compatible endpoint you plan to call. Pick the combo that matches your setup and drop the values into `.env.local`:\n\n| Provider                                      | Example `OPENAI_BASE_URL`          | Example key env                                                         |\n| --------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |\n| Hugging Face Inference Providers router       | `https://router.huggingface.co/v1` | `OPENAI_API_KEY=hf_xxx` (or `HF_TOKEN` legacy alias)                    |\n| llama.cpp server (`llama.cpp --server --api`) | `http://127.0.0.1:8080/v1`         | `OPENAI_API_KEY=sk-local-demo` (any string works; llama.cpp ignores it) |\n| Ollama (with OpenAI-compatible bridge)        | `http://127.0.0.1:11434/v1`        | `OPENAI_API_KEY=ollama`                                                 |\n| OpenRouter                                    | `https://openrouter.ai/api/v1`     | `OPENAI_API_KEY=sk-or-v1-...`                                           |\n| Poe                                           | `https://api.poe.com/v1`           | `OPENAI_API_KEY=pk_...`                                                 |\n\nCheck the root [`.env` template](./.env) for the full list of optional variables you can override.\n\n**Step 2 – Install and launch the dev server:**\n\n```bash\ngit clone https://github.com/huggingface/chat-ui\ncd chat-ui\nnpm install\nnpm run dev -- --open\n```\n\nYou now have Chat UI running locally. Open the browser and start chatting.\n\n## Database Options\n\nChat history, users, settings, files, and stats all live in MongoDB. You can point Chat UI at any MongoDB 6/7 deployment.\n\n> [!TIP]\n> For quick local development, you can skip this section. When `MONGODB_URL` is not set, Chat UI falls back to an embedded MongoDB that persists to `./db`.\n\n### MongoDB Atlas (managed)\n\n1. Create a free cluster at [mongodb.com](https://www.mongodb.com/pricing).\n2. Add your IP (or `0.0.0.0/0` for development) to the network access list.\n3. Create a database user and copy the connection string.\n4. Paste that string into `MONGODB_URL` in `.env.local`. Keep the default `MONGODB_DB_NAME=chat-ui` or change it per environment.\n\nAtlas keeps MongoDB off your laptop, which is ideal for teams or cloud deployments.\n\n### Local MongoDB (container)\n\nIf you prefer to run MongoDB in a container:\n\n```bash\ndocker run -d -p 27017:27017 --name mongo-chatui mongo:latest\n```\n\nThen set `MONGODB_URL=mongodb://localhost:27017` in `.env.local`.\n\n## Launch\n\nAfter configuring your environment variables, start Chat UI with:\n\n```bash\nnpm install\nnpm run dev\n```\n\nThe dev server listens on `http://localhost:5173` by default. Use `npm run build` / `npm run preview` for production builds.\n\n## Optional Docker Image\n\nThe `chat-ui-db` image bundles MongoDB inside the container:\n\n```bash\ndocker run \\\n  -p 3000:3000 \\\n  -e OPENAI_BASE_URL=https://router.huggingface.co/v1 \\\n  -e OPENAI_API_KEY=hf_*** \\\n  -v chat-ui-data:/data \\\n  ghcr.io/huggingface/chat-ui-db:latest\n```\n\nAll environment variables accepted in `.env.local` can be provided as `-e` flags.\n\n## Extra parameters\n\n### Theming\n\nYou can use a few environment variables to customize the look and feel of chat-ui. These are by default:\n\n```env\nPUBLIC_APP_NAME=ChatUI\nPUBLIC_APP_ASSETS=chatui\nPUBLIC_APP_DESCRIPTION=\"Making the community's best AI chat models available to everyone.\"\nPUBLIC_APP_DATA_SHARING=\n```\n\n- `PUBLIC_APP_NAME` The name used as a title throughout the app.\n- `PUBLIC_APP_ASSETS` Is used to find logos & favicons in `static/$PUBLIC_APP_ASSETS`, current options are `chatui` and `huggingchat`.\n- `PUBLIC_APP_DATA_SHARING` Can be set to 1 to add a toggle in the user settings that lets your users opt-in to data sharing with models creator.\n\n### Models\n\nModels are discovered from `${OPENAI_BASE_URL}/models`, and you can optionally override their metadata via the `MODELS` env var (JSON5). Legacy provider‑specific integrations and GGUF discovery are removed. Authorization uses `OPENAI_API_KEY` (preferred). `HF_TOKEN` remains a legacy alias.\n\n### LLM Router (Optional)\n\nChat UI can perform server-side smart routing using [katanemo/Arch-Router-1.5B](https://huggingface.co/katanemo/Arch-Router-1.5B) as the routing model without running a separate router service. The UI exposes a virtual model alias called \"Omni\" (configurable) that, when selected, chooses the best route/model for each message.\n\n- Provide a routes policy JSON via `LLM_ROUTER_ROUTES_PATH`. No sample file ships with this branch, so you must point the variable to a JSON array you create yourself (for example, commit one in your project like `config/routes.chat.json`). Each route entry needs `name`, `description`, `primary_model`, and optional `fallback_models`.\n- Configure the Arch router selection endpoint with `LLM_ROUTER_ARCH_BASE_URL` (OpenAI-compatible `/chat/completions`) and `LLM_ROUTER_ARCH_MODEL` (e.g. `router/omni`). The Arch call reuses `OPENAI_API_KEY` for auth.\n- Map `other` to a concrete route via `LLM_ROUTER_OTHER_ROUTE` (default: `casual_conversation`). If Arch selection fails, calls fall back to `LLM_ROUTER_FALLBACK_MODEL`.\n- Selection timeout can be tuned via `LLM_ROUTER_ARCH_TIMEOUT_MS` (default 10000).\n- Omni alias configuration: `PUBLIC_LLM_ROUTER_ALIAS_ID` (default `omni`), `PUBLIC_LLM_ROUTER_DISPLAY_NAME` (default `Omni`), and optional `PUBLIC_LLM_ROUTER_LOGO_URL`.\n\nWhen you select Omni in the UI, Chat UI will:\n\n- Call the Arch endpoint once (non-streaming) to pick the best route for the last turns.\n- Emit RouterMetadata immediately (route and actual model used) so the UI can display it.\n- Stream from the selected model via your configured `OPENAI_BASE_URL`. On errors, it tries route fallbacks.\n\nTool and multimodal shortcuts:\n\n- Multimodal: If `LLM_ROUTER_ENABLE_MULTIMODAL=true` and the user sends an image, the router bypasses Arch and uses the model specified in `LLM_ROUTER_MULTIMODAL_MODEL`. Route name: `multimodal`.\n- Tools: If `LLM_ROUTER_ENABLE_TOOLS=true` and the user has at least one MCP server enabled, the router bypasses Arch and uses `LLM_ROUTER_TOOLS_MODEL`. If that model is missing or misconfigured, it falls back to Arch routing. Route name: `agentic`.\n\n### MCP Tools (Optional)\n\nChat UI can call tools exposed by Model Context Protocol (MCP) servers and feed results back to the model using OpenAI function calling. You can preconfigure trusted servers via env, let users add their own, and optionally have the Omni router auto‑select a tools‑capable model.\n\nConfigure servers (base list for all users):\n\n```env\n# JSON array of servers: name, url, optional headers\nMCP_SERVERS=[\n  {\"name\": \"Web Search (Exa)\", \"url\": \"https://mcp.exa.ai/mcp\"},\n  {\"name\": \"Hugging Face MCP Login\", \"url\": \"https://hf.co/mcp?login\"}\n]\n\n# Forward the signed-in user's Hugging Face token to the official HF MCP login endpoint\n# when no Authorization header is set on that server entry.\nMCP_FORWARD_HF_USER_TOKEN=true\n```\n\nEnable router tool path (Omni):\n\n- Set `LLM_ROUTER_ENABLE_TOOLS=true` and choose a tools‑capable target with `LLM_ROUTER_TOOLS_MODEL=<model id or name>`.\n- The target must support OpenAI tools/function calling. Chat UI surfaces a “tools” badge on models that advertise this; you can also force‑enable it per‑model in settings (see below).\n\nUse tools in the UI:\n\n- Open “MCP Servers” from the top‑right menu or from the `+` menu in the chat input to add servers, toggle them on, and run Health Check. The server card lists available tools.\n- When a model calls a tool, the message shows a compact “tool” block with parameters, a progress bar while running, and the result (or error). Results are also provided back to the model for follow‑up.\n\nPer‑model overrides:\n\n- In Settings → Model, you can toggle “Tool calling (functions)” and “Multimodal input” per model. These overrides apply even if the provider metadata doesn’t advertise the capability.\n\n## Building\n\nTo create a production version of your app:\n\n```bash\nnpm run build\n```\n\nYou can preview the production build with `npm run preview`.\n\n> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.\n"
  },
  {
    "path": "chart/Chart.yaml",
    "content": "apiVersion: v2\nname: chat-ui\nversion: 0.0.1-latest\ntype: application\nicon: https://huggingface.co/front/assets/huggingface_logo-noborder.svg\n"
  },
  {
    "path": "chart/env/dev.yaml",
    "content": "image:\n  repository: huggingface\n  name: chat-ui\n\n#nodeSelector:\n#  role-huggingchat: \"true\"\n#\n#tolerations:\n#  - key: \"huggingface.co/huggingchat\"\n#    operator: \"Equal\"\n#    value: \"true\"\n#    effect: \"NoSchedule\"\n\nserviceAccount:\n  enabled: true\n  create: true\n  name: huggingchat-ephemeral\n\ningress:\n  enabled: false\n\ningressInternal:\n  enabled: true\n  path: \"/chat\"\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: \"*.chat-dev.huggingface.tech\"\n    alb.ingress.kubernetes.io/healthcheck-path: \"/chat/healthcheck\"\n    alb.ingress.kubernetes.io/listen-ports: \"[{\\\"HTTP\\\": 80}, {\\\"HTTPS\\\": 443}]\"\n    alb.ingress.kubernetes.io/group.name: \"chat-dev-internal-public\"\n    alb.ingress.kubernetes.io/load-balancer-name: \"chat-dev-internal-public\"\n    alb.ingress.kubernetes.io/ssl-redirect: \"443\"\n    alb.ingress.kubernetes.io/tags: \"Env=prod,Project=hub,Terraform=true\"\n    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30\n    alb.ingress.kubernetes.io/target-type: \"ip\"\n    alb.ingress.kubernetes.io/certificate-arn: \"arn:aws:acm:us-east-1:707930574880:certificate/bc3eb446-1c04-432c-ac6b-946a88d725da\"\n    kubernetes.io/ingress.class: \"alb\"\n\nenvVars:\n  TEST: \"test\"\n  COUPLE_SESSION_WITH_COOKIE_NAME: \"token\"\n  OPENID_SCOPES: \"openid profile inference-api read-mcp read-billing\"\n  USE_USER_TOKEN: \"true\"\n  MCP_FORWARD_HF_USER_TOKEN: \"true\"\n  AUTOMATIC_LOGIN: \"false\"\n\n  ADDRESS_HEADER: \"X-Forwarded-For\"\n  APP_BASE: \"/chat\"\n  ALLOW_IFRAME: \"false\"\n  COOKIE_SAMESITE: \"lax\"\n  COOKIE_SECURE: \"true\"\n  EXPOSE_API: \"true\"\n  METRICS_ENABLED: \"true\"\n  LOG_LEVEL: \"debug\"\n  NODE_LOG_STRUCTURED_DATA: \"true\"\n\n  OPENAI_BASE_URL: \"https://router.huggingface.co/v1\"\n  PUBLIC_APP_ASSETS: \"huggingchat\"\n  PUBLIC_APP_NAME: \"HuggingChat\"\n  PUBLIC_APP_DESCRIPTION: \"Making the community's best AI chat models available to everyone\"\n  PUBLIC_ORIGIN: \"\"\n  PUBLIC_PLAUSIBLE_SCRIPT_URL: \"https://plausible.io/js/pa-Io_oigECawqdlgpf5qvHb.js\"\n\n  TASK_MODEL: \"Qwen/Qwen3-4B-Instruct-2507\"\n  LLM_ROUTER_ARCH_BASE_URL: \"https://router.huggingface.co/v1\"\n  LLM_ROUTER_ROUTES_PATH: \"build/client/chat/huggingchat/routes.chat.json\"\n  LLM_ROUTER_ARCH_MODEL: \"katanemo/Arch-Router-1.5B\"\n  LLM_ROUTER_OTHER_ROUTE: \"casual_conversation\"\n  LLM_ROUTER_ARCH_TIMEOUT_MS: \"10000\"\n  LLM_ROUTER_ENABLE_MULTIMODAL: \"true\"\n  LLM_ROUTER_MULTIMODAL_MODEL: \"Qwen/Qwen3.5-397B-A17B\"\n  LLM_ROUTER_ENABLE_TOOLS: \"true\"\n  LLM_ROUTER_TOOLS_MODEL: \"moonshotai/Kimi-K2-Instruct-0905\"\n  TRANSCRIPTION_MODEL: \"openai/whisper-large-v3-turbo\"\n  MCP_SERVERS: >\n    [{\"name\": \"Web Search (Exa)\", \"url\": \"https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa\"}, {\"name\": \"Hugging Face\", \"url\": \"https://hf.co/mcp?login\"}]\n  MCP_TOOL_TIMEOUT_MS: \"120000\"\n  PUBLIC_LLM_ROUTER_DISPLAY_NAME: \"Omni\"\n  PUBLIC_LLM_ROUTER_LOGO_URL: \"https://cdn-uploads.huggingface.co/production/uploads/5f17f0a0925b9863e28ad517/C5V0v1xZXv6M7FXsdJH9b.png\"\n  PUBLIC_LLM_ROUTER_ALIAS_ID: \"omni\"\n  MODELS: >\n    [\n      { \"id\": \"Qwen/Qwen3.5-9B\", \"description\": \"Dense multimodal hybrid with 262K context excelling at reasoning on-device.\" },\n      { \"id\": \"CohereLabs/tiny-aya-global\", \"description\": \"Tiny multilingual assistant covering 70+ languages for on-device deployment.\" },\n      { \"id\": \"CohereLabs/tiny-aya-earth\", \"description\": \"Regional Aya for African languages with culturally tuned on-device inference.\" },\n      { \"id\": \"CohereLabs/tiny-aya-fire\", \"description\": \"Regional Aya for South Asian languages with culturally tuned on-device inference.\" },\n      { \"id\": \"CohereLabs/tiny-aya-water\", \"description\": \"Regional Aya for Asia-Pacific and European multilingual on-device tasks.\" },\n      { \"id\": \"Qwen/Qwen3.5-122B-A10B\", \"description\": \"Multimodal MoE excelling at agentic tool use with 1M context and 201 languages.\" },\n      { \"id\": \"Qwen/Qwen3.5-35B-A3B\", \"description\": \"Compact multimodal MoE with hybrid DeltaNet, 1M context, and 201 languages.\" },\n      { \"id\": \"Qwen/Qwen3.5-27B\", \"description\": \"Dense multimodal hybrid with top-tier reasoning density and 1M context.\" },\n      { \"id\": \"Qwen/Qwen3.5-397B-A17B\", \"description\": \"Native multimodal MoE with hybrid attention, 1M context, and 201 languages.\", \"parameters\": { \"max_tokens\": 32768 } },\n      { \"id\": \"allenai/Olmo-3.1-32B-Think\", \"description\": \"Updated Olmo Think with extended RL for stronger math, code, and instruction following.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2.5\", \"description\": \"Frontier 230B MoE agent for top-tier coding, tool calling, and fast inference.\" },\n      { \"id\": \"zai-org/GLM-5\", \"description\": \"Flagship 745B MoE for agentic reasoning, coding, and creative writing.\" },\n      { \"id\": \"Qwen/Qwen3-VL-235B-A22B-Instruct\", \"description\": \"Flagship Qwen3 vision-language MoE for visual agents, documents, and GUI automation.\" },\n      { \"id\": \"google/gemma-3n-E4B-it\", \"description\": \"Mobile-first multimodal Gemma handling text, images, video, and audio on-device.\" },\n      { \"id\": \"nvidia/NVIDIA-Nemotron-Nano-9B-v2\", \"description\": \"Hybrid Mamba-Transformer with 128K context and controllable reasoning budget.\" },\n      { \"id\": \"mistralai/Mistral-7B-Instruct-v0.2\", \"description\": \"Efficient 7B instruction model with 32K context for dialogue and coding.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-Next-FP8\", \"description\": \"FP8 Qwen3-Coder-Next for efficient inference with repository-scale coding agents.\" },\n      { \"id\": \"arcee-ai/Trinity-Mini\", \"description\": \"Compact US-built MoE for multi-turn agents, tool use, and structured outputs.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-Next\", \"description\": \"Ultra-sparse coding MoE for repository-scale agents with 256K context.\" },\n      { \"id\": \"moonshotai/Kimi-K2.5\", \"description\": \"Native multimodal agent with agent swarms for parallel tool orchestration.\" },\n      { \"id\": \"allenai/Molmo2-8B\", \"description\": \"Open vision-language model excelling at video understanding, pointing, and object tracking.\" },\n      { \"id\": \"zai-org/GLM-4.7-Flash\", \"description\": \"Fast GLM-4.7 variant optimized for lower latency coding and agents.\" },\n      { \"id\": \"zai-org/GLM-4.7\", \"description\": \"Flagship GLM MoE for coding, reasoning, and agentic tool use.\" },\n      { \"id\": \"zai-org/GLM-4.7-FP8\", \"description\": \"FP8 GLM-4.7 for efficient inference with strong coding.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2.1\", \"description\": \"MoE agent model with multilingual coding and fast outputs.\" },\n      { \"id\": \"XiaomiMiMo/MiMo-V2-Flash\", \"description\": \"Fast MoE reasoning model with speculative decoding for agents.\" },\n      { \"id\": \"Qwen/Qwen3-VL-32B-Instruct\", \"description\": \"Vision-language Qwen for documents, GUI agents, and visual reasoning.\" },\n      { \"id\": \"allenai/Olmo-3.1-32B-Instruct\", \"description\": \"Fully open chat model strong at tool use and dialogue.\" },\n      { \"id\": \"zai-org/AutoGLM-Phone-9B-Multilingual\", \"description\": \"Mobile agent for multilingual Android device automation.\" },\n      { \"id\": \"utter-project/EuroLLM-22B-Instruct-2512\", \"description\": \"European multilingual model for all EU languages and translation.\" },\n      { \"id\": \"dicta-il/DictaLM-3.0-24B-Thinking\", \"description\": \"Hebrew-English reasoning model with explicit thinking traces for bilingual QA and logic.\" },\n      { \"id\": \"EssentialAI/rnj-1-instruct\", \"description\": \"8B code and STEM model rivaling larger models on agentic coding, math, and tool use.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2\", \"description\": \"Compact MoE model tuned for fast coding, agentic workflows, and long-context chat.\" },\n      { \"id\": \"PrimeIntellect/INTELLECT-3-FP8\", \"description\": \"FP8 INTELLECT-3 variant for cheaper frontier-level math, code, and general reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-VL-30B-A3B-Instruct\", \"description\": \"Flagship Qwen3 vision-language model for high-accuracy image, text, and video reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-VL-30B-A3B-Thinking\", \"description\": \"Thinking-mode Qwen3-VL that emits detailed multimodal reasoning traces for difficult problems.\" },\n      { \"id\": \"Qwen/Qwen3-VL-8B-Instruct\", \"description\": \"Smaller Qwen3 vision-language assistant for everyday multimodal chat, captioning, and analysis.\" },\n      { \"id\": \"aisingapore/Qwen-SEA-LION-v4-32B-IT\", \"description\": \"SEA-LION v4 Qwen optimized for Southeast Asian languages and regional enterprise workloads.\" },\n      { \"id\": \"allenai/Olmo-3-32B-Think\", \"description\": \"Fully open 32B thinking model excelling at stepwise math, coding, and research reasoning.\" },\n      { \"id\": \"allenai/Olmo-3-7B-Instruct\", \"description\": \"Lightweight Olmo assistant for instruction following, Q&A, and everyday open-source workflows.\" },\n      { \"id\": \"allenai/Olmo-3-7B-Think\", \"description\": \"7B Olmo reasoning model delivering transparent multi-step thinking on modest hardware.\" },\n      { \"id\": \"deepcogito/cogito-671b-v2.1\", \"description\": \"Frontier-scale 671B MoE focused on deep reasoning, math proofs, and complex coding.\" },\n      { \"id\": \"deepcogito/cogito-671b-v2.1-FP8\", \"description\": \"FP8 Cogito v2.1 making 671B-scale reasoning more affordable to serve and experiment with.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.2\", \"description\": \"Latest DeepSeek agent model combining strong reasoning, tool-use, and efficient long-context inference.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Thinking\", \"description\": \"Reasoning-focused Kimi K2 variant for deep chain-of-thought and large agentic tool flows.\" },\n      { \"id\": \"nvidia/NVIDIA-Nemotron-Nano-12B-v2\", \"description\": \"NVIDIA Nano 12B general assistant for coding, chat, and agents with efficient deployment.\" },\n      { \"id\": \"ServiceNow-AI/Apriel-1.6-15b-Thinker\", \"description\": \"15B multimodal reasoning model with efficient thinking for enterprise and coding tasks.\" },\n      { \"id\": \"openai/gpt-oss-safeguard-20b\", \"description\": \"Safety-focused gpt-oss variant for content classification, policy enforcement, and LLM output filtering.\" },\n      { \"id\": \"zai-org/GLM-4.5\", \"description\": \"Flagship GLM agent model unifying advanced reasoning, coding, and tool-using capabilities.\" },\n      { \"id\": \"zai-org/GLM-4.5V-FP8\", \"description\": \"FP8 vision-language GLM-4.5V for efficient multilingual visual QA, understanding, and hybrid reasoning.\" },    \n      { \"id\": \"deepseek-ai/DeepSeek-V3.2-Exp\", \"description\": \"Experimental V3.2 release focused on faster, lower-cost inference with strong general reasoning and tool use.\" },\n      { \"id\": \"zai-org/GLM-4.6\", \"description\": \"Next-gen GLM with very long context and solid multilingual reasoning; good for agents and tools.\" },\n      { \"id\": \"Kwaipilot/KAT-Dev\", \"description\": \"Developer-oriented assistant tuned for coding, debugging, and lightweight agent workflows.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-72B-Instruct\", \"description\": \"Flagship multimodal Qwen (text+image) instruction model for high-accuracy visual reasoning and detailed explanations.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.1-Terminus\", \"description\": \"Refined V3.1 variant optimized for reliability on long contexts, structured outputs, and tool use.\" },\n      { \"id\": \"Qwen/Qwen3-VL-235B-A22B-Thinking\", \"description\": \"Deliberative multimodal Qwen that can produce step-wise visual+text reasoning traces for complex tasks.\" },\n      { \"id\": \"zai-org/GLM-4.6-FP8\", \"description\": \"FP8-optimized GLM-4.6 for faster/cheaper deployment with near-parity quality on most tasks.\" },\n      { \"id\": \"zai-org/GLM-4.6V\", \"description\": \"106B vision-language model with 128K context and native tool calling for multimodal agents.\", \"parameters\": { \"max_tokens\": 8192 } },\n      { \"id\": \"zai-org/GLM-4.6V-Flash\", \"description\": \"9B lightweight vision model for fast local inference with tool calling and UI understanding.\" },\n      { \"id\": \"zai-org/GLM-4.6V-FP8\", \"description\": \"FP8-quantized GLM-4.6V for efficient multimodal deployment with native tool use.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-Thinking-2507\", \"description\": \"Deliberative text-only 235B Qwen variant for transparent, step-by-step reasoning on hard problems.\" },\n      { \"id\": \"Qwen/Qwen3-Next-80B-A3B-Instruct\", \"description\": \"Instruction tuned Qwen for multilingual reasoning, coding, long contexts.\" },\n      { \"id\": \"Qwen/Qwen3-Next-80B-A3B-Thinking\", \"description\": \"Thinking mode Qwen that outputs explicit step by step reasoning.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Instruct-0905\", \"description\": \"Instruction MoE strong coding and multi step reasoning, long context.\" },\n      { \"id\": \"openai/gpt-oss-20b\", \"description\": \"Efficient open model for reasoning and tool use, runs locally.\" },\n      { \"id\": \"swiss-ai/Apertus-8B-Instruct-2509\", \"description\": \"Open, multilingual, trained on compliant data transparent global assistant.\" },\n      { \"id\": \"openai/gpt-oss-120b\", \"description\": \"High performing open model suitable for large scale applications.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-30B-A3B-Instruct\", \"description\": \"Code specialized Qwen long context strong generation and function calling.\" },\n      { \"id\": \"meta-llama/Llama-3.1-8B-Instruct\", \"description\": \"Instruction tuned Llama efficient conversational assistant with improved alignment.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-7B-Instruct\", \"description\": \"Vision language Qwen handles images and text for basic multimodal tasks.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B-Instruct-2507\", \"description\": \"Instruction tuned Qwen reliable general tasks with long context support.\" },\n      { \"id\": \"baidu/ERNIE-4.5-VL-28B-A3B-PT\", \"description\": \"Baidu multimodal MoE strong at complex vision language reasoning.\" },\n      { \"id\": \"baidu/ERNIE-4.5-0.3B-PT\", \"description\": \"Tiny efficient Baidu model surprisingly long context for lightweight chat.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1\", \"description\": \"MoE reasoning model excels at math, logic, coding with steps.\" },\n      { \"id\": \"baidu/ERNIE-4.5-21B-A3B-PT\", \"description\": \"Efficient Baidu MoE competitive generation with fewer active parameters.\" },\n      { \"id\": \"swiss-ai/Apertus-70B-Instruct-2509\", \"description\": \"Open multilingual model trained on open data transparent and capable.\" },\n      { \"id\": \"Qwen/Qwen3-4B-Instruct-2507\", \"description\": \"Compact instruction Qwen great for lightweight assistants and apps.\" },\n      { \"id\": \"meta-llama/Llama-3.2-3B-Instruct\", \"description\": \"Small efficient Llama for basic conversations and instructions.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct\", \"description\": \"Huge Qwen coder repository scale understanding and advanced generation.\" },\n      { \"id\": \"meta-llama/Meta-Llama-3-8B-Instruct\", \"description\": \"Aligned, efficient Llama dependable open source assistant tasks.\" },\n      { \"id\": \"Qwen/Qwen3-4B-Thinking-2507\", \"description\": \"Small Qwen that emits transparent step by step reasoning.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Instruct\", \"description\": \"MoE assistant strong coding, reasoning, agentic tasks, long context.\" },\n      { \"id\": \"zai-org/GLM-4.5V\", \"description\": \"Vision language MoE state of the art multimodal reasoning.\" },\n      { \"id\": \"zai-org/GLM-4.6\", \"description\": \"Hybrid reasoning model top choice for intelligent agent applications.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.1\", \"description\": \"Supports direct and thinking style reasoning within one model.\" },\n      { \"id\": \"Qwen/Qwen3-8B\", \"description\": \"Efficient Qwen assistant strong multilingual skills and formatting.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B-Thinking-2507\", \"description\": \"Thinking mode Qwen explicit reasoning for complex interpretable tasks.\" },\n      { \"id\": \"google/gemma-3-27b-it\", \"description\": \"Multimodal Gemma long context strong text and image understanding.\" },\n      { \"id\": \"zai-org/GLM-4.5-Air\", \"description\": \"Efficient GLM strong reasoning and tool use at lower cost.\" },\n      { \"id\": \"HuggingFaceTB/SmolLM3-3B\", \"description\": \"Small multilingual long context model surprisingly strong reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B\", \"description\": \"Qwen base model for general use or further fine tuning.\" },\n      { \"id\": \"Qwen/Qwen2.5-7B-Instruct\", \"description\": \"Compact instruction model solid for basic conversation and tasks.\" },\n      { \"id\": \"Qwen/Qwen3-32B\", \"description\": \"General purpose Qwen strong for complex queries and dialogues.\" },\n      { \"id\": \"Qwen/QwQ-32B\", \"description\": \"Preview Qwen showcasing next generation features and alignment.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\", \"description\": \"Flagship instruction Qwen near state of the art across domains.\" },\n      { \"id\": \"meta-llama/Llama-3.3-70B-Instruct\", \"description\": \"Improved Llama alignment and structure powerful complex conversations.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-32B-Instruct\", \"description\": \"Multimodal Qwen advanced visual reasoning for complex image plus text.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B\", \"description\": \"Tiny distilled Qwen stepwise math and logic reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B\", \"description\": \"Qwen base at flagship scale ideal for custom fine tuning.\" },\n      { \"id\": \"meta-llama/Llama-4-Scout-17B-16E-Instruct\", \"description\": \"Processes text and images excels at summarization and cross modal reasoning.\" },\n      { \"id\": \"NousResearch/Hermes-4-70B\", \"description\": \"Steerable assistant strong reasoning and creativity highly helpful.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-32B-Instruct\", \"description\": \"Code model strong generation and tool use bridges sizes.\" },\n      { \"id\": \"katanemo/Arch-Router-1.5B\", \"description\": \"Lightweight router model directs queries to specialized backends.\" },\n      { \"id\": \"meta-llama/Llama-3.2-1B-Instruct\", \"description\": \"Ultra small Llama handles basic Q and A and instructions.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B\", \"description\": \"Distilled Qwen excels at stepwise logic in compact footprint.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3\", \"description\": \"General language model direct answers strong creative and knowledge tasks.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3-0324\", \"description\": \"Updated V3 better reasoning and coding strong tool use.\" },\n      { \"id\": \"CohereLabs/command-a-translate-08-2025\", \"description\": \"Translation focused Command model high quality multilingual translation.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B\", \"description\": \"Distilled from R1 strong reasoning standout dense model.\" },\n      { \"id\": \"baidu/ERNIE-4.5-VL-424B-A47B-Base-PT\", \"description\": \"Multimodal base text image pretraining for cross modal understanding.\" },\n      { \"id\": \"meta-llama/Llama-4-Maverick-17B-128E-Instruct\", \"description\": \"MoE multimodal Llama rivals top vision language models.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8\", \"description\": \"Quantized giant coder faster lighter retains advanced code generation.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-0528-Qwen3-8B\", \"description\": \"Qwen3 variant with R1 reasoning improvements compact and capable.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-0528\", \"description\": \"R1 update improved reasoning, fewer hallucinations, adds function calling.\", \"parameters\": { \"max_tokens\": 32000 } },\n      { \"id\": \"Qwen/Qwen3-14B\", \"description\": \"Balanced Qwen good performance and efficiency for assistants.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M1-80k\", \"description\": \"Long context MoE very fast excels at long range reasoning and code.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-7B-Instruct\", \"description\": \"Efficient coding assistant for lightweight programming tasks.\" },\n      { \"id\": \"aisingapore/Gemma-SEA-LION-v4-27B-IT\", \"description\": \"Gemma SEA LION optimized for Southeast Asian languages or enterprise.\" },\n      { \"id\": \"CohereLabs/aya-expanse-8b\", \"description\": \"Small Aya Expanse broad knowledge and efficient general reasoning.\" },\n      { \"id\": \"baichuan-inc/Baichuan-M2-32B\", \"description\": \"Medical reasoning specialist fine tuned for clinical QA bilingual.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-72B-Instruct\", \"description\": \"Vision language Qwen detailed image interpretation and instructions.\" },\n      { \"id\": \"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8\", \"description\": \"FP8 Maverick efficient deployment retains top multimodal capability.\" },\n      { \"id\": \"zai-org/GLM-4.1V-9B-Thinking\", \"description\": \"Vision language with explicit reasoning strong for its size.\" },\n      { \"id\": \"zai-org/GLM-4.5-Air-FP8\", \"description\": \"FP8 efficient GLM Air hybrid reasoning with minimal compute.\" },\n      { \"id\": \"google/gemma-2-2b-it\", \"description\": \"Small Gemma instruction tuned safe responsible outputs easy deployment.\" },\n      { \"id\": \"arcee-ai/AFM-4.5B\", \"description\": \"Enterprise focused model strong CPU performance compliant and practical.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Llama-8B\", \"description\": \"Llama distilled from R1 strong reasoning and structured outputs.\" },\n      { \"id\": \"CohereLabs/aya-vision-8b\", \"description\": \"Vision capable Aya handles images and text for basic multimodal.\" },\n      { \"id\": \"NousResearch/Hermes-3-Llama-3.1-405B\", \"description\": \"Highly aligned assistant excels at math, code, QA.\" },\n      { \"id\": \"Qwen/Qwen2.5-72B-Instruct\", \"description\": \"Accurate detailed instruction model supports tools and long contexts.\" },\n      { \"id\": \"meta-llama/Llama-Guard-4-12B\", \"description\": \"Safety guardrail model filters and enforces content policies.\" },\n      { \"id\": \"CohereLabs/command-a-vision-07-2025\", \"description\": \"Command model with image input captioning and visual QA.\" },\n      { \"id\": \"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1\", \"description\": \"NVIDIA tuned Llama optimized throughput for research and production.\" },\n      { \"id\": \"meta-llama/Meta-Llama-3-70B-Instruct\", \"description\": \"Instruction tuned Llama improved reasoning and reliability over predecessors.\" },\n      { \"id\": \"NousResearch/Hermes-4-405B\", \"description\": \"Frontier Hermes hybrid reasoning excels at math, code, creativity.\" },\n      { \"id\": \"NousResearch/Hermes-2-Pro-Llama-3-8B\", \"description\": \"Small Hermes highly steerable maximized helpfulness for basics.\" },\n      { \"id\": \"google/gemma-2-9b-it\", \"description\": \"Gemma with improved accuracy and context safe, easy to deploy.\" },\n      { \"id\": \"Sao10K/L3-8B-Stheno-v3.2\", \"description\": \"Community Llama variant themed tuning and unique conversational style.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-109B-MoE\", \"description\": \"MoE preview advanced reasoning tests DeepCogito v2 fine tuning.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r-08-2024\", \"description\": \"Cohere Command variant instruction following with specialized tuning.\" },\n      { \"id\": \"baidu/ERNIE-4.5-300B-A47B-Base-PT\", \"description\": \"Large base model foundation for specialized language systems.\" },\n      { \"id\": \"CohereLabs/aya-expanse-32b\", \"description\": \"Aya Expanse large comprehensive knowledge and reasoning capabilities.\" },\n      { \"id\": \"CohereLabs/c4ai-command-a-03-2025\", \"description\": \"Updated Command assistant improved accuracy and general usefulness.\" },\n      { \"id\": \"CohereLabs/command-a-reasoning-08-2025\", \"description\": \"Command variant optimized for complex multi step logical reasoning.\" },\n      { \"id\": \"alpindale/WizardLM-2-8x22B\", \"description\": \"Multi expert WizardLM MoE approach for efficient high quality generation.\" },\n      { \"id\": \"tokyotech-llm/Llama-3.3-Swallow-70B-Instruct-v0.4\", \"description\": \"Academic fine tune potential multilingual and domain improvements.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Llama-70B\", \"description\": \"Llama distilled from R1 improved reasoning enterprise friendly.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r7b-12-2024\", \"description\": \"Small Command variant research or regional adaptation focus.\" },\n      { \"id\": \"Sao10K/L3-70B-Euryale-v2.1\", \"description\": \"Creative community instruct model with distinctive persona.\" },\n      { \"id\": \"CohereLabs/aya-vision-32b\", \"description\": \"Larger Aya Vision advanced vision language with detailed reasoning.\" },\n      { \"id\": \"meta-llama/Llama-3.1-405B-Instruct\", \"description\": \"Massive instruction model very long context excels at complex tasks.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r7b-arabic-02-2025\", \"description\": \"Command tuned for Arabic fluent and culturally appropriate outputs.\" },\n      { \"id\": \"Sao10K/L3-8B-Lunaris-v1\", \"description\": \"Community Llama creative role play oriented themed persona.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-7B\", \"description\": \"Small Qwen coder basic programming assistance for low resource environments.\" },\n      { \"id\": \"Qwen/QwQ-32B-Preview\", \"description\": \"Preview Qwen experimental features and architecture refinements.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B\", \"description\": \"Distilled Qwen mid size strong reasoning and clear steps.\" },\n      { \"id\": \"meta-llama/Llama-3.1-70B-Instruct\", \"description\": \"Instruction tuned Llama improved reasoning and factual reliability.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-FP8\", \"description\": \"FP8 quantized Qwen flagship efficient access to ultra large capabilities.\" },\n      { \"id\": \"zai-org/GLM-4-32B-0414\", \"description\": \"Open licensed GLM matches larger proprietary models on benchmarks.\" },\n      { \"id\": \"SentientAGI/Dobby-Unhinged-Llama-3.3-70B\", \"description\": \"Unfiltered candid creative outputs intentionally less restricted behavior.\" },\n      { \"id\": \"marin-community/marin-8b-instruct\", \"description\": \"Community tuned assistant helpful conversational everyday tasks.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-Prover-V2-671B\", \"description\": \"Specialist for mathematical proofs and formal reasoning workflows.\" },\n      { \"id\": \"NousResearch/Hermes-3-Llama-3.1-70B\", \"description\": \"Highly aligned assistant strong complex instruction following.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-3B-Instruct\", \"description\": \"Tiny coding assistant basic code completions and explanations.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-70B\", \"description\": \"Preview fine tune enhanced reasoning and tool use indications.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-405B\", \"description\": \"Preview at frontier scale tests advanced fine tuning methods.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-deepseek-671B-MoE\", \"description\": \"Experimental blend of DeepCogito and DeepSeek approaches for reasoning.\" }\n    ]\n\ninfisical:\n  enabled: true\n  env: \"ephemeral-us-east-1\"\n\nreplicas: 1\nautoscaling:\n  enabled: false\n\nresources:\n  requests:\n    cpu: 2\n    memory: 4Gi\n  limits:\n    cpu: 4\n    memory: 8Gi\n"
  },
  {
    "path": "chart/env/prod.yaml",
    "content": "image:\n  repository: huggingface\n  name: chat-ui\n\nnodeSelector:\n  role-huggingchat: \"true\"\n\ntolerations:\n  - key: \"huggingface.co/huggingchat\"\n    operator: \"Equal\"\n    value: \"true\"\n    effect: \"NoSchedule\"\n\nserviceAccount:\n  enabled: true\n  create: true\n  name: huggingchat-prod\n\ningress:\n  path: \"/chat\"\n  annotations:\n    alb.ingress.kubernetes.io/healthcheck-path: \"/chat/healthcheck\"\n    alb.ingress.kubernetes.io/listen-ports: \"[{\\\"HTTP\\\": 80}, {\\\"HTTPS\\\": 443}]\"\n    alb.ingress.kubernetes.io/load-balancer-name: \"hub-utils-prod-cloudfront\"\n    alb.ingress.kubernetes.io/group.name: \"hub-utils-prod-cloudfront\"\n    alb.ingress.kubernetes.io/scheme: \"internal\"\n    alb.ingress.kubernetes.io/ssl-redirect: \"443\"\n    alb.ingress.kubernetes.io/tags: \"Env=prod,Project=hub,Terraform=true\"\n    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30\n    alb.ingress.kubernetes.io/target-type: \"ip\"\n    alb.ingress.kubernetes.io/certificate-arn: \"arn:aws:acm:us-east-1:707930574880:certificate/5b25b145-75db-4837-b9f3-7f238ba8a9c7,arn:aws:acm:us-east-1:707930574880:certificate/bfdf509c-f44b-400f-b9e1-6f7a861abe91\"\n    kubernetes.io/ingress.class: \"alb\"\n\ningressInternal:\n  enabled: true\n  path: \"/chat\"\n  annotations:\n    alb.ingress.kubernetes.io/healthcheck-path: \"/chat/healthcheck\"\n    alb.ingress.kubernetes.io/listen-ports: \"[{\\\"HTTP\\\": 80}, {\\\"HTTPS\\\": 443}]\"\n    alb.ingress.kubernetes.io/group.name: \"hub-prod-internal-public\"\n    alb.ingress.kubernetes.io/load-balancer-name: \"hub-prod-internal-public\"\n    alb.ingress.kubernetes.io/ssl-redirect: \"443\"\n    alb.ingress.kubernetes.io/tags: \"Env=prod,Project=hub,Terraform=true\"\n    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30\n    alb.ingress.kubernetes.io/target-type: \"ip\"\n    alb.ingress.kubernetes.io/certificate-arn: \"arn:aws:acm:us-east-1:707930574880:certificate/5b25b145-75db-4837-b9f3-7f238ba8a9c7,arn:aws:acm:us-east-1:707930574880:certificate/bfdf509c-f44b-400f-b9e1-6f7a861abe91\"\n    kubernetes.io/ingress.class: \"alb\"\n\nenvVars:\n  COUPLE_SESSION_WITH_COOKIE_NAME: \"token\"\n  OPENID_SCOPES: \"openid profile inference-api read-mcp read-billing\"\n  USE_USER_TOKEN: \"true\"\n  MCP_FORWARD_HF_USER_TOKEN: \"true\"\n  AUTOMATIC_LOGIN: \"false\"\n\n  ADDRESS_HEADER: \"X-Forwarded-For\"\n  APP_BASE: \"/chat\"\n  ALLOW_IFRAME: \"false\"\n  COOKIE_SAMESITE: \"lax\"\n  COOKIE_SECURE: \"true\"\n  EXPOSE_API: \"true\"\n  METRICS_ENABLED: \"true\"\n  LOG_LEVEL: \"debug\"\n  NODE_LOG_STRUCTURED_DATA: \"true\"\n\n  OPENAI_BASE_URL: \"https://router.huggingface.co/v1\"\n  PUBLIC_APP_ASSETS: \"huggingchat\"\n  PUBLIC_APP_NAME: \"HuggingChat\"\n  PUBLIC_APP_DESCRIPTION: \"Making the community's best AI chat models available to everyone\"\n  PUBLIC_ORIGIN: \"https://huggingface.co\"\n  PUBLIC_PLAUSIBLE_SCRIPT_URL: \"https://plausible.io/js/pa-Io_oigECawqdlgpf5qvHb.js\"\n\n  TASK_MODEL: \"Qwen/Qwen3-4B-Instruct-2507\"\n  LLM_ROUTER_ARCH_BASE_URL: \"https://router.huggingface.co/v1\"\n  LLM_ROUTER_ROUTES_PATH: \"build/client/chat/huggingchat/routes.chat.json\"\n  LLM_ROUTER_ARCH_MODEL: \"katanemo/Arch-Router-1.5B\"\n  LLM_ROUTER_OTHER_ROUTE: \"casual_conversation\"\n  LLM_ROUTER_ARCH_TIMEOUT_MS: \"10000\"\n  LLM_ROUTER_ENABLE_MULTIMODAL: \"true\"\n  LLM_ROUTER_MULTIMODAL_MODEL: \"Qwen/Qwen3.5-397B-A17B\"\n  LLM_ROUTER_ENABLE_TOOLS: \"true\"\n  LLM_ROUTER_TOOLS_MODEL: \"moonshotai/Kimi-K2-Instruct-0905\"\n  TRANSCRIPTION_MODEL: \"openai/whisper-large-v3-turbo\"\n  MCP_SERVERS: >\n    [{\"name\": \"Web Search (Exa)\", \"url\": \"https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa\"}, {\"name\": \"Hugging Face\", \"url\": \"https://hf.co/mcp?login\"}]\n  MCP_TOOL_TIMEOUT_MS: \"120000\"\n  PUBLIC_LLM_ROUTER_DISPLAY_NAME: \"Omni\"\n  PUBLIC_LLM_ROUTER_LOGO_URL: \"https://cdn-uploads.huggingface.co/production/uploads/5f17f0a0925b9863e28ad517/C5V0v1xZXv6M7FXsdJH9b.png\"\n  PUBLIC_LLM_ROUTER_ALIAS_ID: \"omni\"\n  MODELS: >\n    [\n      { \"id\": \"Qwen/Qwen3.5-9B\", \"description\": \"Dense multimodal hybrid with 262K context excelling at reasoning on-device.\" },\n      { \"id\": \"CohereLabs/tiny-aya-global\", \"description\": \"Tiny multilingual assistant covering 70+ languages for on-device deployment.\" },\n      { \"id\": \"CohereLabs/tiny-aya-earth\", \"description\": \"Regional Aya for African languages with culturally tuned on-device inference.\" },\n      { \"id\": \"CohereLabs/tiny-aya-fire\", \"description\": \"Regional Aya for South Asian languages with culturally tuned on-device inference.\" },\n      { \"id\": \"CohereLabs/tiny-aya-water\", \"description\": \"Regional Aya for Asia-Pacific and European multilingual on-device tasks.\" },\n      { \"id\": \"Qwen/Qwen3.5-122B-A10B\", \"description\": \"Multimodal MoE excelling at agentic tool use with 1M context and 201 languages.\" },\n      { \"id\": \"Qwen/Qwen3.5-35B-A3B\", \"description\": \"Compact multimodal MoE with hybrid DeltaNet, 1M context, and 201 languages.\" },\n      { \"id\": \"Qwen/Qwen3.5-27B\", \"description\": \"Dense multimodal hybrid with top-tier reasoning density and 1M context.\" },\n      { \"id\": \"Qwen/Qwen3.5-397B-A17B\", \"description\": \"Native multimodal MoE with hybrid attention, 1M context, and 201 languages.\", \"parameters\": { \"max_tokens\": 32768 } },\n      { \"id\": \"allenai/Olmo-3.1-32B-Think\", \"description\": \"Updated Olmo Think with extended RL for stronger math, code, and instruction following.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2.5\", \"description\": \"Frontier 230B MoE agent for top-tier coding, tool calling, and fast inference.\" },\n      { \"id\": \"zai-org/GLM-5\", \"description\": \"Flagship 745B MoE for agentic reasoning, coding, and creative writing.\" },\n      { \"id\": \"Qwen/Qwen3-VL-235B-A22B-Instruct\", \"description\": \"Flagship Qwen3 vision-language MoE for visual agents, documents, and GUI automation.\" },\n      { \"id\": \"google/gemma-3n-E4B-it\", \"description\": \"Mobile-first multimodal Gemma handling text, images, video, and audio on-device.\" },\n      { \"id\": \"nvidia/NVIDIA-Nemotron-Nano-9B-v2\", \"description\": \"Hybrid Mamba-Transformer with 128K context and controllable reasoning budget.\" },\n      { \"id\": \"mistralai/Mistral-7B-Instruct-v0.2\", \"description\": \"Efficient 7B instruction model with 32K context for dialogue and coding.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-Next-FP8\", \"description\": \"FP8 Qwen3-Coder-Next for efficient inference with repository-scale coding agents.\" },\n      { \"id\": \"arcee-ai/Trinity-Mini\", \"description\": \"Compact US-built MoE for multi-turn agents, tool use, and structured outputs.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-Next\", \"description\": \"Ultra-sparse coding MoE for repository-scale agents with 256K context.\" },\n      { \"id\": \"moonshotai/Kimi-K2.5\", \"description\": \"Native multimodal agent with agent swarms for parallel tool orchestration.\" },\n      { \"id\": \"allenai/Molmo2-8B\", \"description\": \"Open vision-language model excelling at video understanding, pointing, and object tracking.\" },\n      { \"id\": \"zai-org/GLM-4.7-Flash\", \"description\": \"Fast GLM-4.7 variant optimized for lower latency coding and agents.\" },\n      { \"id\": \"zai-org/GLM-4.7\", \"description\": \"Flagship GLM MoE for coding, reasoning, and agentic tool use.\" },\n      { \"id\": \"zai-org/GLM-4.7-FP8\", \"description\": \"FP8 GLM-4.7 for efficient inference with strong coding.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2.1\", \"description\": \"MoE agent model with multilingual coding and fast outputs.\" },\n      { \"id\": \"XiaomiMiMo/MiMo-V2-Flash\", \"description\": \"Fast MoE reasoning model with speculative decoding for agents.\" },\n      { \"id\": \"Qwen/Qwen3-VL-32B-Instruct\", \"description\": \"Vision-language Qwen for documents, GUI agents, and visual reasoning.\" },\n      { \"id\": \"allenai/Olmo-3.1-32B-Instruct\", \"description\": \"Fully open chat model strong at tool use and dialogue.\" },\n      { \"id\": \"zai-org/AutoGLM-Phone-9B-Multilingual\", \"description\": \"Mobile agent for multilingual Android device automation.\" },\n      { \"id\": \"utter-project/EuroLLM-22B-Instruct-2512\", \"description\": \"European multilingual model for all EU languages and translation.\" },\n      { \"id\": \"dicta-il/DictaLM-3.0-24B-Thinking\", \"description\": \"Hebrew-English reasoning model with explicit thinking traces for bilingual QA and logic.\" },\n      { \"id\": \"EssentialAI/rnj-1-instruct\", \"description\": \"8B code and STEM model rivaling larger models on agentic coding, math, and tool use.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M2\", \"description\": \"Compact MoE model tuned for fast coding, agentic workflows, and long-context chat.\" },\n      { \"id\": \"PrimeIntellect/INTELLECT-3-FP8\", \"description\": \"FP8 INTELLECT-3 variant for cheaper frontier-level math, code, and general reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-VL-30B-A3B-Instruct\", \"description\": \"Flagship Qwen3 vision-language model for high-accuracy image, text, and video reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-VL-30B-A3B-Thinking\", \"description\": \"Thinking-mode Qwen3-VL that emits detailed multimodal reasoning traces for difficult problems.\" },\n      { \"id\": \"Qwen/Qwen3-VL-8B-Instruct\", \"description\": \"Smaller Qwen3 vision-language assistant for everyday multimodal chat, captioning, and analysis.\" },\n      { \"id\": \"aisingapore/Qwen-SEA-LION-v4-32B-IT\", \"description\": \"SEA-LION v4 Qwen optimized for Southeast Asian languages and regional enterprise workloads.\" },\n      { \"id\": \"allenai/Olmo-3-32B-Think\", \"description\": \"Fully open 32B thinking model excelling at stepwise math, coding, and research reasoning.\" },\n      { \"id\": \"allenai/Olmo-3-7B-Instruct\", \"description\": \"Lightweight Olmo assistant for instruction following, Q&A, and everyday open-source workflows.\" },\n      { \"id\": \"allenai/Olmo-3-7B-Think\", \"description\": \"7B Olmo reasoning model delivering transparent multi-step thinking on modest hardware.\" },\n      { \"id\": \"deepcogito/cogito-671b-v2.1\", \"description\": \"Frontier-scale 671B MoE focused on deep reasoning, math proofs, and complex coding.\" },\n      { \"id\": \"deepcogito/cogito-671b-v2.1-FP8\", \"description\": \"FP8 Cogito v2.1 making 671B-scale reasoning more affordable to serve and experiment with.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.2\", \"description\": \"Latest DeepSeek agent model combining strong reasoning, tool-use, and efficient long-context inference.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Thinking\", \"description\": \"Reasoning-focused Kimi K2 variant for deep chain-of-thought and large agentic tool flows.\" },\n      { \"id\": \"nvidia/NVIDIA-Nemotron-Nano-12B-v2\", \"description\": \"NVIDIA Nano 12B general assistant for coding, chat, and agents with efficient deployment.\" },\n      { \"id\": \"ServiceNow-AI/Apriel-1.6-15b-Thinker\", \"description\": \"15B multimodal reasoning model with efficient thinking for enterprise and coding tasks.\" },\n      { \"id\": \"openai/gpt-oss-safeguard-20b\", \"description\": \"Safety-focused gpt-oss variant for content classification, policy enforcement, and LLM output filtering.\" },\n      { \"id\": \"zai-org/GLM-4.5\", \"description\": \"Flagship GLM agent model unifying advanced reasoning, coding, and tool-using capabilities.\" },\n      { \"id\": \"zai-org/GLM-4.5V-FP8\", \"description\": \"FP8 vision-language GLM-4.5V for efficient multilingual visual QA, understanding, and hybrid reasoning.\" },    \n      { \"id\": \"deepseek-ai/DeepSeek-V3.2-Exp\", \"description\": \"Experimental V3.2 release focused on faster, lower-cost inference with strong general reasoning and tool use.\" },\n      { \"id\": \"zai-org/GLM-4.6\", \"description\": \"Next-gen GLM with very long context and solid multilingual reasoning; good for agents and tools.\" },\n      { \"id\": \"Kwaipilot/KAT-Dev\", \"description\": \"Developer-oriented assistant tuned for coding, debugging, and lightweight agent workflows.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-72B-Instruct\", \"description\": \"Flagship multimodal Qwen (text+image) instruction model for high-accuracy visual reasoning and detailed explanations.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.1-Terminus\", \"description\": \"Refined V3.1 variant optimized for reliability on long contexts, structured outputs, and tool use.\" },\n      { \"id\": \"Qwen/Qwen3-VL-235B-A22B-Thinking\", \"description\": \"Deliberative multimodal Qwen that can produce step-wise visual+text reasoning traces for complex tasks.\" },\n      { \"id\": \"zai-org/GLM-4.6-FP8\", \"description\": \"FP8-optimized GLM-4.6 for faster/cheaper deployment with near-parity quality on most tasks.\" },\n      { \"id\": \"zai-org/GLM-4.6V\", \"description\": \"106B vision-language model with 128K context and native tool calling for multimodal agents.\", \"parameters\": { \"max_tokens\": 8192 } },\n      { \"id\": \"zai-org/GLM-4.6V-Flash\", \"description\": \"9B lightweight vision model for fast local inference with tool calling and UI understanding.\" },\n      { \"id\": \"zai-org/GLM-4.6V-FP8\", \"description\": \"FP8-quantized GLM-4.6V for efficient multimodal deployment with native tool use.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-Thinking-2507\", \"description\": \"Deliberative text-only 235B Qwen variant for transparent, step-by-step reasoning on hard problems.\" },\n      { \"id\": \"Qwen/Qwen3-Next-80B-A3B-Instruct\", \"description\": \"Instruction tuned Qwen for multilingual reasoning, coding, long contexts.\" },\n      { \"id\": \"Qwen/Qwen3-Next-80B-A3B-Thinking\", \"description\": \"Thinking mode Qwen that outputs explicit step by step reasoning.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Instruct-0905\", \"description\": \"Instruction MoE strong coding and multi step reasoning, long context.\" },\n      { \"id\": \"openai/gpt-oss-20b\", \"description\": \"Efficient open model for reasoning and tool use, runs locally.\" },\n      { \"id\": \"swiss-ai/Apertus-8B-Instruct-2509\", \"description\": \"Open, multilingual, trained on compliant data transparent global assistant.\" },\n      { \"id\": \"openai/gpt-oss-120b\", \"description\": \"High performing open model suitable for large scale applications.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-30B-A3B-Instruct\", \"description\": \"Code specialized Qwen long context strong generation and function calling.\" },\n      { \"id\": \"meta-llama/Llama-3.1-8B-Instruct\", \"description\": \"Instruction tuned Llama efficient conversational assistant with improved alignment.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-7B-Instruct\", \"description\": \"Vision language Qwen handles images and text for basic multimodal tasks.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B-Instruct-2507\", \"description\": \"Instruction tuned Qwen reliable general tasks with long context support.\" },\n      { \"id\": \"baidu/ERNIE-4.5-VL-28B-A3B-PT\", \"description\": \"Baidu multimodal MoE strong at complex vision language reasoning.\" },\n      { \"id\": \"baidu/ERNIE-4.5-0.3B-PT\", \"description\": \"Tiny efficient Baidu model surprisingly long context for lightweight chat.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1\", \"description\": \"MoE reasoning model excels at math, logic, coding with steps.\" },\n      { \"id\": \"baidu/ERNIE-4.5-21B-A3B-PT\", \"description\": \"Efficient Baidu MoE competitive generation with fewer active parameters.\" },\n      { \"id\": \"swiss-ai/Apertus-70B-Instruct-2509\", \"description\": \"Open multilingual model trained on open data transparent and capable.\" },\n      { \"id\": \"Qwen/Qwen3-4B-Instruct-2507\", \"description\": \"Compact instruction Qwen great for lightweight assistants and apps.\" },\n      { \"id\": \"meta-llama/Llama-3.2-3B-Instruct\", \"description\": \"Small efficient Llama for basic conversations and instructions.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct\", \"description\": \"Huge Qwen coder repository scale understanding and advanced generation.\" },\n      { \"id\": \"meta-llama/Meta-Llama-3-8B-Instruct\", \"description\": \"Aligned, efficient Llama dependable open source assistant tasks.\" },\n      { \"id\": \"Qwen/Qwen3-4B-Thinking-2507\", \"description\": \"Small Qwen that emits transparent step by step reasoning.\" },\n      { \"id\": \"moonshotai/Kimi-K2-Instruct\", \"description\": \"MoE assistant strong coding, reasoning, agentic tasks, long context.\" },\n      { \"id\": \"zai-org/GLM-4.5V\", \"description\": \"Vision language MoE state of the art multimodal reasoning.\" },\n      { \"id\": \"zai-org/GLM-4.6\", \"description\": \"Hybrid reasoning model top choice for intelligent agent applications.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3.1\", \"description\": \"Supports direct and thinking style reasoning within one model.\" },\n      { \"id\": \"Qwen/Qwen3-8B\", \"description\": \"Efficient Qwen assistant strong multilingual skills and formatting.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B-Thinking-2507\", \"description\": \"Thinking mode Qwen explicit reasoning for complex interpretable tasks.\" },\n      { \"id\": \"google/gemma-3-27b-it\", \"description\": \"Multimodal Gemma long context strong text and image understanding.\" },\n      { \"id\": \"zai-org/GLM-4.5-Air\", \"description\": \"Efficient GLM strong reasoning and tool use at lower cost.\" },\n      { \"id\": \"HuggingFaceTB/SmolLM3-3B\", \"description\": \"Small multilingual long context model surprisingly strong reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-30B-A3B\", \"description\": \"Qwen base model for general use or further fine tuning.\" },\n      { \"id\": \"Qwen/Qwen2.5-7B-Instruct\", \"description\": \"Compact instruction model solid for basic conversation and tasks.\" },\n      { \"id\": \"Qwen/Qwen3-32B\", \"description\": \"General purpose Qwen strong for complex queries and dialogues.\" },\n      { \"id\": \"Qwen/QwQ-32B\", \"description\": \"Preview Qwen showcasing next generation features and alignment.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\", \"description\": \"Flagship instruction Qwen near state of the art across domains.\" },\n      { \"id\": \"meta-llama/Llama-3.3-70B-Instruct\", \"description\": \"Improved Llama alignment and structure powerful complex conversations.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-32B-Instruct\", \"description\": \"Multimodal Qwen advanced visual reasoning for complex image plus text.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B\", \"description\": \"Tiny distilled Qwen stepwise math and logic reasoning.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B\", \"description\": \"Qwen base at flagship scale ideal for custom fine tuning.\" },\n      { \"id\": \"meta-llama/Llama-4-Scout-17B-16E-Instruct\", \"description\": \"Processes text and images excels at summarization and cross modal reasoning.\" },\n      { \"id\": \"NousResearch/Hermes-4-70B\", \"description\": \"Steerable assistant strong reasoning and creativity highly helpful.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-32B-Instruct\", \"description\": \"Code model strong generation and tool use bridges sizes.\" },\n      { \"id\": \"katanemo/Arch-Router-1.5B\", \"description\": \"Lightweight router model directs queries to specialized backends.\" },\n      { \"id\": \"meta-llama/Llama-3.2-1B-Instruct\", \"description\": \"Ultra small Llama handles basic Q and A and instructions.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B\", \"description\": \"Distilled Qwen excels at stepwise logic in compact footprint.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3\", \"description\": \"General language model direct answers strong creative and knowledge tasks.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-V3-0324\", \"description\": \"Updated V3 better reasoning and coding strong tool use.\" },\n      { \"id\": \"CohereLabs/command-a-translate-08-2025\", \"description\": \"Translation focused Command model high quality multilingual translation.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B\", \"description\": \"Distilled from R1 strong reasoning standout dense model.\" },\n      { \"id\": \"baidu/ERNIE-4.5-VL-424B-A47B-Base-PT\", \"description\": \"Multimodal base text image pretraining for cross modal understanding.\" },\n      { \"id\": \"meta-llama/Llama-4-Maverick-17B-128E-Instruct\", \"description\": \"MoE multimodal Llama rivals top vision language models.\" },\n      { \"id\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8\", \"description\": \"Quantized giant coder faster lighter retains advanced code generation.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-0528-Qwen3-8B\", \"description\": \"Qwen3 variant with R1 reasoning improvements compact and capable.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-0528\", \"description\": \"R1 update improved reasoning, fewer hallucinations, adds function calling.\", \"parameters\": { \"max_tokens\": 32000 } },\n      { \"id\": \"Qwen/Qwen3-14B\", \"description\": \"Balanced Qwen good performance and efficiency for assistants.\" },\n      { \"id\": \"MiniMaxAI/MiniMax-M1-80k\", \"description\": \"Long context MoE very fast excels at long range reasoning and code.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-7B-Instruct\", \"description\": \"Efficient coding assistant for lightweight programming tasks.\" },\n      { \"id\": \"aisingapore/Gemma-SEA-LION-v4-27B-IT\", \"description\": \"Gemma SEA LION optimized for Southeast Asian languages or enterprise.\" },\n      { \"id\": \"CohereLabs/aya-expanse-8b\", \"description\": \"Small Aya Expanse broad knowledge and efficient general reasoning.\" },\n      { \"id\": \"baichuan-inc/Baichuan-M2-32B\", \"description\": \"Medical reasoning specialist fine tuned for clinical QA bilingual.\" },\n      { \"id\": \"Qwen/Qwen2.5-VL-72B-Instruct\", \"description\": \"Vision language Qwen detailed image interpretation and instructions.\" },\n      { \"id\": \"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8\", \"description\": \"FP8 Maverick efficient deployment retains top multimodal capability.\" },\n      { \"id\": \"zai-org/GLM-4.1V-9B-Thinking\", \"description\": \"Vision language with explicit reasoning strong for its size.\" },\n      { \"id\": \"zai-org/GLM-4.5-Air-FP8\", \"description\": \"FP8 efficient GLM Air hybrid reasoning with minimal compute.\" },\n      { \"id\": \"google/gemma-2-2b-it\", \"description\": \"Small Gemma instruction tuned safe responsible outputs easy deployment.\" },\n      { \"id\": \"arcee-ai/AFM-4.5B\", \"description\": \"Enterprise focused model strong CPU performance compliant and practical.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Llama-8B\", \"description\": \"Llama distilled from R1 strong reasoning and structured outputs.\" },\n      { \"id\": \"CohereLabs/aya-vision-8b\", \"description\": \"Vision capable Aya handles images and text for basic multimodal.\" },\n      { \"id\": \"NousResearch/Hermes-3-Llama-3.1-405B\", \"description\": \"Highly aligned assistant excels at math, code, QA.\" },\n      { \"id\": \"Qwen/Qwen2.5-72B-Instruct\", \"description\": \"Accurate detailed instruction model supports tools and long contexts.\" },\n      { \"id\": \"meta-llama/Llama-Guard-4-12B\", \"description\": \"Safety guardrail model filters and enforces content policies.\" },\n      { \"id\": \"CohereLabs/command-a-vision-07-2025\", \"description\": \"Command model with image input captioning and visual QA.\" },\n      { \"id\": \"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1\", \"description\": \"NVIDIA tuned Llama optimized throughput for research and production.\" },\n      { \"id\": \"meta-llama/Meta-Llama-3-70B-Instruct\", \"description\": \"Instruction tuned Llama improved reasoning and reliability over predecessors.\" },\n      { \"id\": \"NousResearch/Hermes-4-405B\", \"description\": \"Frontier Hermes hybrid reasoning excels at math, code, creativity.\" },\n      { \"id\": \"NousResearch/Hermes-2-Pro-Llama-3-8B\", \"description\": \"Small Hermes highly steerable maximized helpfulness for basics.\" },\n      { \"id\": \"google/gemma-2-9b-it\", \"description\": \"Gemma with improved accuracy and context safe, easy to deploy.\" },\n      { \"id\": \"Sao10K/L3-8B-Stheno-v3.2\", \"description\": \"Community Llama variant themed tuning and unique conversational style.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-109B-MoE\", \"description\": \"MoE preview advanced reasoning tests DeepCogito v2 fine tuning.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r-08-2024\", \"description\": \"Cohere Command variant instruction following with specialized tuning.\" },\n      { \"id\": \"baidu/ERNIE-4.5-300B-A47B-Base-PT\", \"description\": \"Large base model foundation for specialized language systems.\" },\n      { \"id\": \"CohereLabs/aya-expanse-32b\", \"description\": \"Aya Expanse large comprehensive knowledge and reasoning capabilities.\" },\n      { \"id\": \"CohereLabs/c4ai-command-a-03-2025\", \"description\": \"Updated Command assistant improved accuracy and general usefulness.\" },\n      { \"id\": \"CohereLabs/command-a-reasoning-08-2025\", \"description\": \"Command variant optimized for complex multi step logical reasoning.\" },\n      { \"id\": \"alpindale/WizardLM-2-8x22B\", \"description\": \"Multi expert WizardLM MoE approach for efficient high quality generation.\" },\n      { \"id\": \"tokyotech-llm/Llama-3.3-Swallow-70B-Instruct-v0.4\", \"description\": \"Academic fine tune potential multilingual and domain improvements.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Llama-70B\", \"description\": \"Llama distilled from R1 improved reasoning enterprise friendly.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r7b-12-2024\", \"description\": \"Small Command variant research or regional adaptation focus.\" },\n      { \"id\": \"Sao10K/L3-70B-Euryale-v2.1\", \"description\": \"Creative community instruct model with distinctive persona.\" },\n      { \"id\": \"CohereLabs/aya-vision-32b\", \"description\": \"Larger Aya Vision advanced vision language with detailed reasoning.\" },\n      { \"id\": \"meta-llama/Llama-3.1-405B-Instruct\", \"description\": \"Massive instruction model very long context excels at complex tasks.\" },\n      { \"id\": \"CohereLabs/c4ai-command-r7b-arabic-02-2025\", \"description\": \"Command tuned for Arabic fluent and culturally appropriate outputs.\" },\n      { \"id\": \"Sao10K/L3-8B-Lunaris-v1\", \"description\": \"Community Llama creative role play oriented themed persona.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-7B\", \"description\": \"Small Qwen coder basic programming assistance for low resource environments.\" },\n      { \"id\": \"Qwen/QwQ-32B-Preview\", \"description\": \"Preview Qwen experimental features and architecture refinements.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B\", \"description\": \"Distilled Qwen mid size strong reasoning and clear steps.\" },\n      { \"id\": \"meta-llama/Llama-3.1-70B-Instruct\", \"description\": \"Instruction tuned Llama improved reasoning and factual reliability.\" },\n      { \"id\": \"Qwen/Qwen3-235B-A22B-FP8\", \"description\": \"FP8 quantized Qwen flagship efficient access to ultra large capabilities.\" },\n      { \"id\": \"zai-org/GLM-4-32B-0414\", \"description\": \"Open licensed GLM matches larger proprietary models on benchmarks.\" },\n      { \"id\": \"SentientAGI/Dobby-Unhinged-Llama-3.3-70B\", \"description\": \"Unfiltered candid creative outputs intentionally less restricted behavior.\" },\n      { \"id\": \"marin-community/marin-8b-instruct\", \"description\": \"Community tuned assistant helpful conversational everyday tasks.\" },\n      { \"id\": \"deepseek-ai/DeepSeek-Prover-V2-671B\", \"description\": \"Specialist for mathematical proofs and formal reasoning workflows.\" },\n      { \"id\": \"NousResearch/Hermes-3-Llama-3.1-70B\", \"description\": \"Highly aligned assistant strong complex instruction following.\" },\n      { \"id\": \"Qwen/Qwen2.5-Coder-3B-Instruct\", \"description\": \"Tiny coding assistant basic code completions and explanations.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-70B\", \"description\": \"Preview fine tune enhanced reasoning and tool use indications.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-llama-405B\", \"description\": \"Preview at frontier scale tests advanced fine tuning methods.\" },\n      { \"id\": \"deepcogito/cogito-v2-preview-deepseek-671B-MoE\", \"description\": \"Experimental blend of DeepCogito and DeepSeek approaches for reasoning.\" }\n    ]\n\ninfisical:\n  enabled: true\n  env: \"prod-us-east-1\"\n\nautoscaling:\n  enabled: true\n  minReplicas: 2\n  maxReplicas: 30\n  targetMemoryUtilizationPercentage: \"50\"\n  targetCPUUtilizationPercentage: \"50\"\n\nresources:\n  requests:\n    cpu: 2\n    memory: 4Gi\n  limits:\n    cpu: 4\n    memory: 8Gi\n"
  },
  {
    "path": "chart/templates/_helpers.tpl",
    "content": "{{- define \"name\" -}}\n{{- default $.Release.Name | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n\n{{- define \"app.name\" -}}\nchat-ui\n{{- end -}}\n\n{{- define \"labels.standard\" -}}\nrelease: {{ $.Release.Name | quote }}\nheritage: {{ $.Release.Service | quote }}\nchart: \"{{ include \"name\" . }}\"\napp: \"{{ include \"app.name\" . }}\"\n{{- end -}}\n\n{{- define \"labels.resolver\" -}}\nrelease: {{ $.Release.Name | quote }}\nheritage: {{ $.Release.Service | quote }}\nchart: \"{{ include \"name\" . }}\"\napp: \"{{ include \"app.name\" . }}-resolver\"\n{{- end -}}\n\n"
  },
  {
    "path": "chart/templates/config.yaml",
    "content": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\ndata:\n  {{- range $key, $value := $.Values.envVars }}\n  {{ $key }}: {{ $value | quote }}\n  {{- end }}\n"
  },
  {
    "path": "chart/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\n  {{- if .Values.infisical.enabled }}\n  annotations:\n    secrets.infisical.com/auto-reload: \"true\"\n  {{- end }}\nspec:\n  progressDeadlineSeconds: 600\n  {{- if not $.Values.autoscaling.enabled }}\n  replicas: {{ .Values.replicas }}\n  {{- end }}\n  revisionHistoryLimit: 10\n  selector:\n    matchLabels: {{ include \"labels.standard\" . | nindent 6 }}\n  strategy:\n    rollingUpdate:\n      maxSurge: 25%\n      maxUnavailable: 25%\n    type: RollingUpdate\n  template:\n    metadata:\n      labels: {{ include \"labels.standard\" . | nindent 8 }}\n      annotations:\n        checksum/config: {{ include (print $.Template.BasePath \"/config.yaml\") . | sha256sum }}\n        {{- if $.Values.envVars.NODE_LOG_STRUCTURED_DATA }}\n        co.elastic.logs/json.expand_keys: \"true\"\n        {{- end }}\n    spec:\n      {{- if .Values.serviceAccount.enabled }}\n      serviceAccountName: \"{{ .Values.serviceAccount.name | default (include \"name\" .) }}\"\n      {{- end }}\n      containers:\n        - name: chat-ui\n          image: \"{{ .Values.image.repository }}/{{ .Values.image.name }}:{{ .Values.image.tag }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          readinessProbe:\n            failureThreshold: 30\n            periodSeconds: 10\n            httpGet:\n              path: {{ $.Values.envVars.APP_BASE | default \"\" }}/healthcheck\n              port: {{ $.Values.envVars.APP_PORT | default 3000 | int }}\n          livenessProbe:\n            failureThreshold: 30\n            periodSeconds: 10\n            httpGet:\n              path: {{ $.Values.envVars.APP_BASE | default \"\" }}/healthcheck\n              port: {{ $.Values.envVars.APP_PORT | default 3000 | int }}\n          ports:\n            - containerPort: {{ $.Values.envVars.APP_PORT | default 3000 | int }}\n              name: http\n              protocol: TCP\n            {{- if eq \"true\" $.Values.envVars.METRICS_ENABLED }}\n            - containerPort: {{ $.Values.envVars.METRICS_PORT | default 5565 | int }}\n              name: metrics\n              protocol: TCP\n            {{- end }}\n          resources: {{ toYaml .Values.resources | nindent 12 }}\n          {{- with $.Values.extraEnv }}\n          env:\n            {{- toYaml . | nindent 14 }}\n          {{- end }}\n          envFrom:\n            - configMapRef:\n                name: {{ include \"name\" . }}\n          {{- if $.Values.infisical.enabled }}\n            - secretRef:\n                name: {{ include \"name\" $ }}-secs\n          {{- end }}\n          {{- with $.Values.extraEnvFrom }}\n            {{- toYaml . | nindent 14 }}\n          {{- end }}\n      nodeSelector: {{ toYaml .Values.nodeSelector | nindent 8 }}\n      tolerations: {{ toYaml .Values.tolerations | nindent 8 }}\n      volumes:\n        - name: config\n          configMap:\n            name: {{ include \"name\" . }}\n"
  },
  {
    "path": "chart/templates/hpa.yaml",
    "content": "{{- if $.Values.autoscaling.enabled }}\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: {{ include \"name\" . }}\n  minReplicas: {{ $.Values.autoscaling.minReplicas }}\n  maxReplicas: {{ $.Values.autoscaling.maxReplicas }}\n  metrics:\n    {{- if ne \"\" $.Values.autoscaling.targetMemoryUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: memory\n        target:\n          type: Utilization\n          averageUtilization: {{ $.Values.autoscaling.targetMemoryUtilizationPercentage | int }}\n    {{- end }}\n    {{- if ne \"\" $.Values.autoscaling.targetCPUUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: {{ $.Values.autoscaling.targetCPUUtilizationPercentage | int }}\n    {{- end }}\n  behavior:\n    scaleDown:\n      stabilizationWindowSeconds: 600\n      policies:\n        - type: Percent\n          value: 10\n          periodSeconds: 60\n    scaleUp:\n      stabilizationWindowSeconds: 0\n      policies:\n        - type: Pods\n          value: 1\n          periodSeconds: 30\n{{- end }}\n"
  },
  {
    "path": "chart/templates/infisical.yaml",
    "content": "{{- if .Values.infisical.enabled }}\napiVersion: secrets.infisical.com/v1alpha1\nkind: InfisicalSecret\nmetadata:\n  name: {{ include \"name\" $ }}-infisical-secret\n  namespace: {{ $.Release.Namespace }}\nspec:\n  authentication:\n    universalAuth:\n      credentialsRef:\n        secretName: {{ .Values.infisical.operatorSecretName | quote }}\n        secretNamespace: {{ .Values.infisical.operatorSecretNamespace | quote }}\n      secretsScope:\n        envSlug: {{ .Values.infisical.env | quote }}\n        projectSlug: {{ .Values.infisical.project | quote }}\n        secretsPath: /\n  hostAPI: {{ .Values.infisical.url | quote }}\n  managedSecretReference:\n    creationPolicy: Owner\n    secretName: {{ include \"name\" $ }}-secs\n    secretNamespace: {{ .Release.Namespace | quote }}\n    secretType: Opaque\n  resyncInterval: {{ .Values.infisical.resyncInterval }}\n{{- end }}\n"
  },
  {
    "path": "chart/templates/ingress-internal.yaml",
    "content": "{{- if $.Values.ingressInternal.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations: {{ toYaml .Values.ingressInternal.annotations | nindent 4 }}\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}-internal\n  namespace: {{ .Release.Namespace }}\nspec:\n  {{ if $.Values.ingressInternal.className }}\n  ingressClassName: {{ .Values.ingressInternal.className }}\n  {{ end }}\n  {{- with .Values.ingressInternal.tls }}\n  tls:\n    - hosts:\n        - {{ $.Values.domain | quote }}\n      {{- with .secretName }}\n      secretName: {{ . }}\n      {{- end }}\n  {{- end }}\n  rules:\n    - host: {{ .Values.domain }}\n      http:\n        paths:\n          - backend:\n              service:\n                name: {{ include \"name\" . }}\n                port:\n                  name: http\n            path: {{ $.Values.ingressInternal.path | default \"/\" }}\n            pathType: Prefix\n{{- end }}\n"
  },
  {
    "path": "chart/templates/ingress.yaml",
    "content": "{{- if $.Values.ingress.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations: {{ toYaml .Values.ingress.annotations | nindent 4 }}\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\nspec:\n  {{ if $.Values.ingress.className }}\n  ingressClassName: {{ .Values.ingress.className }}\n  {{ end }}\n  {{- with .Values.ingress.tls }}\n  tls:\n    - hosts:\n        - {{ $.Values.domain | quote }}\n      {{- with .secretName }}\n      secretName: {{ . }}\n      {{- end }}\n  {{- end }}\n  rules:\n    - host: {{ .Values.domain }}\n      http:\n        paths:\n          - backend:\n              service:\n                name: {{ include \"name\" . }}\n                port:\n                  name: http\n            path: {{ $.Values.ingress.path | default \"/\" }}\n            pathType: Prefix\n{{- end }}\n"
  },
  {
    "path": "chart/templates/network-policy.yaml",
    "content": "{{- if $.Values.networkPolicy.enabled }}\napiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\nspec:\n  egress:\n    - ports:\n        - port: 53\n          protocol: UDP\n      to:\n        - namespaceSelector:\n            matchLabels:\n              kubernetes.io/metadata.name: kube-system\n          podSelector:\n            matchLabels:\n              k8s-app: kube-dns\n    - to:\n        {{- range $ip := .Values.networkPolicy.allowedBlocks }}\n        - ipBlock:\n            cidr: {{ $ip | quote }}\n        {{- end }}\n    - to:\n        - ipBlock:\n            cidr: 0.0.0.0/0\n            except:\n              - 10.0.0.0/8\n              - 172.16.0.0/12\n              - 192.168.0.0/16\n              - 169.254.169.254/32\n  podSelector:\n    matchLabels: {{ include \"labels.standard\" . | nindent 6 }}\n  policyTypes:\n    - Egress\n{{- end }}\n"
  },
  {
    "path": "chart/templates/service-account.yaml",
    "content": "{{- if and .Values.serviceAccount.enabled .Values.serviceAccount.create }}\napiVersion: v1\nkind: ServiceAccount\nautomountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}\nmetadata:\n  name: \"{{ .Values.serviceAccount.name | default (include \"name\" .) }}\"\n  namespace: {{ .Release.Namespace }}\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "chart/templates/service-monitor.yaml",
    "content": "{{- if eq \"true\" $.Values.envVars.METRICS_ENABLED }}\napiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\n  name: {{ include \"name\" . }}\n  namespace: {{ .Release.Namespace }}\nspec:\n  selector:\n    matchLabels: {{ include \"labels.standard\" . | nindent 6 }}\n  endpoints:\n    - port: metrics\n      path: /metrics\n      interval: 10s\n      scheme: http\t\n      scrapeTimeout: 10s\n{{- end }}\n"
  },
  {
    "path": "chart/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: \"{{ include \"name\" . }}\"\n  annotations: {{ toYaml .Values.service.annotations | nindent 4 }}\n  namespace: {{ .Release.Namespace }}\n  labels: {{ include \"labels.standard\" . | nindent 4 }}\nspec:\n  ports:\n  - name: http\n    port: 80\n    protocol: TCP\n    targetPort: http\n  {{- if eq \"true\" $.Values.envVars.METRICS_ENABLED }}\n  - name: metrics\n    port: {{ $.Values.envVars.METRICS_PORT | default 5565 | int }}\n    protocol: TCP\n    targetPort: metrics\n  {{- end }}\n  selector: {{ include \"labels.standard\" . | nindent 4 }}\n  type: {{.Values.service.type}}\n"
  },
  {
    "path": "chart/values.yaml",
    "content": "image:\n  repository: ghcr.io/huggingface\n  name: chat-ui\n  tag: 0.0.0-latest\n  pullPolicy: IfNotPresent\n\nreplicas: 3\n\ndomain: huggingface.co\n\nnetworkPolicy:\n  enabled: false\n  allowedBlocks: []\n\nservice:\n  type: NodePort\n  annotations: { }\n\nserviceAccount:\n  enabled: false\n  create: false\n  name: \"\"\n  automountServiceAccountToken: true\n  annotations: { }\n\ningress:\n  enabled: true\n  path: \"/\"\n  annotations: { }\n  # className: \"nginx\"\n  tls: { }\n    # secretName: XXX\n\ningressInternal:\n  enabled: false\n  path: \"/\"\n  annotations: { }\n  # className: \"nginx\"\n  tls: { }\n\nresources:\n  requests:\n    cpu: 2\n    memory: 4Gi\n  limits:\n    cpu: 2\n    memory: 4Gi\nnodeSelector: {}\ntolerations: []\n\nenvVars: { }\n\ninfisical:\n  enabled: false\n  env: \"\"\n  project: \"huggingchat-v2-a1\"\n  url: \"\"\n  resyncInterval: 60\n  operatorSecretName: \"huggingchat-operator-secrets\"\n  operatorSecretNamespace: \"hub-utils\"\n\n# Allow to environment injections on top or instead of infisical\nextraEnvFrom: []\nextraEnv: []\n\nautoscaling:\n  enabled: false\n  minReplicas: 1\n  maxReplicas: 2\n  targetMemoryUtilizationPercentage: \"\"\n  targetCPUUtilizationPercentage: \"\"\n\n## Metrics removed; monitoring configuration no longer used\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "# For development only\n# Set MONGODB_URL=mongodb://localhost:27017 in .env.local to use this container\nservices:\n  mongo:\n    image: mongo:8\n    hostname: mongodb\n    ports:\n      - ${LOCAL_MONGO_PORT:-27017}:27017\n    command: --replSet rs0 --bind_ip_all #--setParameter notablescan=1\n    mem_limit: \"5g\"\n    mem_reservation: \"3g\"\n    healthcheck:\n      # need to specify the hostname here because the default is the container name, and we run the app outside of docker\n      test: test $$(mongosh --quiet --eval 'try {rs.status().ok} catch(e) {rs.initiate({_id:\"rs0\",members:[{_id:0,host:\"127.0.0.1:${LOCAL_MONGO_PORT:-27017}\"}]}).ok}') -eq 1\n      interval: 5s\n    volumes:\n      - mongodb-data:/data/db\n    restart: always\n\nvolumes:\n  mongodb-data:\n"
  },
  {
    "path": "docs/source/_toctree.yml",
    "content": "- local: index\n  title: Chat UI\n- title: Installation\n  sections:\n    - local: installation/local\n      title: Local\n    - local: installation/docker\n      title: Docker\n    - local: installation/helm\n      title: Helm\n- title: Configuration\n  sections:\n    - local: configuration/overview\n      title: Overview\n    - local: configuration/theming\n      title: Theming\n    - local: configuration/open-id\n      title: OpenID\n    - local: configuration/mcp-tools\n      title: MCP Tools\n    - local: configuration/llm-router\n      title: LLM Router\n    - local: configuration/metrics\n      title: Metrics\n    - local: configuration/common-issues\n      title: Common Issues\n- title: Developing\n  sections:\n    - local: developing/architecture\n      title: Architecture\n"
  },
  {
    "path": "docs/source/configuration/common-issues.md",
    "content": "# Common Issues\n\n## 403: You don't have access to this conversation\n\nThis usually happens when running Chat UI over HTTP without proper cookie configuration.\n\n**Recommended:** Set up a reverse proxy (NGINX, Caddy) to handle HTTPS.\n\n**Alternative:** If you must run over HTTP, configure cookies:\n\n```ini\nCOOKIE_SECURE=false\nCOOKIE_SAMESITE=lax\n```\n\nAlso ensure `PUBLIC_ORIGIN` matches your actual URL:\n\n```ini\nPUBLIC_ORIGIN=http://localhost:5173\n```\n\n## Models not loading\n\nIf models aren't appearing in the UI:\n\n1. Verify `OPENAI_BASE_URL` is correct and accessible\n2. Check that `OPENAI_API_KEY` is valid\n3. Ensure the endpoint returns models at `${OPENAI_BASE_URL}/models`\n\n## Database connection errors\n\nFor development, you can skip MongoDB entirely - Chat UI will use an embedded database.\n\nFor production, verify:\n\n- `MONGODB_URL` is a valid connection string\n- Your IP is whitelisted (for MongoDB Atlas)\n- The database user has read/write permissions\n"
  },
  {
    "path": "docs/source/configuration/llm-router.md",
    "content": "# LLM Router\n\nChat UI includes an intelligent routing system that automatically selects the best model for each request. When enabled, users see a virtual \"Omni\" model that routes to specialized models based on the conversation context.\n\nThe router uses [katanemo/Arch-Router-1.5B](https://huggingface.co/katanemo/Arch-Router-1.5B) for route selection.\n\n## Configuration\n\n### Basic Setup\n\n```ini\n# Arch router endpoint (OpenAI-compatible)\nLLM_ROUTER_ARCH_BASE_URL=https://router.huggingface.co/v1\nLLM_ROUTER_ARCH_MODEL=katanemo/Arch-Router-1.5B\n\n# Path to your routes policy JSON\nLLM_ROUTER_ROUTES_PATH=./config/routes.json\n```\n\n### Routes Policy\n\nCreate a JSON file defining your routes. Each route specifies:\n\n```json\n[\n\t{\n\t\t\"name\": \"coding\",\n\t\t\"description\": \"Programming, debugging, code review\",\n\t\t\"primary_model\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct\",\n\t\t\"fallback_models\": [\"meta-llama/Llama-3.3-70B-Instruct\"]\n\t},\n\t{\n\t\t\"name\": \"casual_conversation\",\n\t\t\"description\": \"General chat, questions, explanations\",\n\t\t\"primary_model\": \"meta-llama/Llama-3.3-70B-Instruct\"\n\t}\n]\n```\n\n### Fallback Behavior\n\n```ini\n# Route to use when Arch returns \"other\"\nLLM_ROUTER_OTHER_ROUTE=casual_conversation\n\n# Model to use if Arch selection fails entirely\nLLM_ROUTER_FALLBACK_MODEL=meta-llama/Llama-3.3-70B-Instruct\n\n# Selection timeout (milliseconds)\nLLM_ROUTER_ARCH_TIMEOUT_MS=10000\n```\n\n## Multimodal Routing\n\nWhen a user sends an image, the router can bypass Arch and route directly to a vision model:\n\n```ini\nLLM_ROUTER_ENABLE_MULTIMODAL=true\nLLM_ROUTER_MULTIMODAL_MODEL=meta-llama/Llama-3.2-90B-Vision-Instruct\n```\n\n## Tools Routing\n\nWhen a user has MCP servers enabled, the router can automatically select a tools-capable model:\n\n```ini\nLLM_ROUTER_ENABLE_TOOLS=true\nLLM_ROUTER_TOOLS_MODEL=meta-llama/Llama-3.3-70B-Instruct\n```\n\n## UI Customization\n\nCustomize how the router appears in the model selector:\n\n```ini\nPUBLIC_LLM_ROUTER_ALIAS_ID=omni\nPUBLIC_LLM_ROUTER_DISPLAY_NAME=Omni\nPUBLIC_LLM_ROUTER_LOGO_URL=https://example.com/logo.png\n```\n\n## How It Works\n\nWhen a user selects Omni:\n\n1. Chat UI sends the conversation context to the Arch router\n2. Arch analyzes the content and returns a route name\n3. Chat UI maps the route to the corresponding model\n4. The request streams from the selected model\n5. On errors, fallback models are tried in order\n\nThe route selection is displayed in the UI so users can see which model was chosen.\n\n## Message Length Limits\n\nTo optimize router performance, message content is trimmed before sending to Arch:\n\n```ini\n# Max characters for assistant messages (default: 500)\nLLM_ROUTER_MAX_ASSISTANT_LENGTH=500\n\n# Max characters for previous user messages (default: 400)\nLLM_ROUTER_MAX_PREV_USER_LENGTH=400\n```\n\nThe latest user message is never trimmed.\n"
  },
  {
    "path": "docs/source/configuration/mcp-tools.md",
    "content": "# MCP Tools\n\nChat UI supports tool calling via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). MCP servers expose tools that models can invoke during conversations.\n\n## Server Types\n\nChat UI supports two types of MCP servers:\n\n### Base Servers (Admin-configured)\n\nBase servers are configured by the administrator via environment variables. They appear for all users and can be enabled/disabled per-user but not removed.\n\n```ini\nMCP_SERVERS=[\n  {\"name\": \"Web Search (Exa)\", \"url\": \"https://mcp.exa.ai/mcp\"},\n  {\"name\": \"Hugging Face\", \"url\": \"https://hf.co/mcp\"}\n]\n```\n\nEach server entry requires:\n\n- `name` - Display name shown in the UI\n- `url` - MCP server endpoint URL\n- `headers` (optional) - Custom headers for authentication\n\n### User Servers (Added from UI)\n\nUsers can add their own MCP servers directly from the UI:\n\n1. Open the chat input and click the **+** button (or go to Settings)\n2. Select **MCP Servers**\n3. Click **Add Server**\n4. Enter the server name and URL\n5. Run **Health Check** to verify connectivity\n\nUser-added servers are stored in the browser and can be removed at any time. They work alongside base servers.\n\n## User Token Forwarding\n\nWhen users are logged in via Hugging Face, you can forward their access token to MCP servers:\n\n```ini\nMCP_FORWARD_HF_USER_TOKEN=true\n```\n\nThis allows MCP servers to access user-specific resources on their behalf.\n\n## Using Tools\n\n1. Enable the servers you want to use from the MCP Servers panel\n2. Start chatting - models will automatically use tools when appropriate\n\n### Model Requirements\n\nNot all models support tool calling. To enable tools for a specific model, add it to your `MODELS` override:\n\n```ini\nMODELS=`[\n  {\n    \"id\": \"meta-llama/Llama-3.3-70B-Instruct\",\n    \"supportsTools\": true\n  }\n]`\n```\n\n## Tool Execution Flow\n\nWhen a model decides to use a tool:\n\n1. The model generates a tool call with parameters\n2. Chat UI executes the call against the MCP server\n3. Results are displayed in the chat as a collapsible \"tool\" block\n4. Results are fed back to the model for follow-up responses\n\n## Integration with LLM Router\n\nWhen using the [LLM Router](./llm-router), you can configure automatic routing to a tools-capable model:\n\n```ini\nLLM_ROUTER_ENABLE_TOOLS=true\nLLM_ROUTER_TOOLS_MODEL=meta-llama/Llama-3.3-70B-Instruct\n```\n\nWhen a user has MCP servers enabled and selects the Omni model, the router will automatically use the specified tools model.\n"
  },
  {
    "path": "docs/source/configuration/metrics.md",
    "content": "# Metrics\n\nThe server can expose prometheus metrics on port `5565` but is off by default. You may enable the metrics server with `METRICS_ENABLED=true` and change the port with `METRICS_PORT=1234`.\n\n<Tip>\n\nIn development with `npm run dev`, the metrics server does not shutdown gracefully due to Sveltekit not providing hooks for restart. It's recommended to disable the metrics server in this case.\n\n</Tip>\n"
  },
  {
    "path": "docs/source/configuration/open-id.md",
    "content": "# OpenID\n\nBy default, users are attributed a unique ID based on their browser session. To authenticate users with OpenID Connect, configure the following:\n\n```ini\nOPENID_CLIENT_ID=your_client_id\nOPENID_CLIENT_SECRET=your_client_secret\nOPENID_SCOPES=\"openid profile\"\n```\n\nUse the provider URL for standard OpenID Connect discovery:\n\n```ini\nOPENID_PROVIDER_URL=https://your-provider.com\n```\n\nAdvanced: you can also provide a client metadata document via `OPENID_CONFIG`. This value must be a JSON/JSON5 object (for example, a CIMD document) and is parsed server‑side to populate OpenID settings.\n\n**Redirect URI:** `https://your-domain.com/login/callback`\n\n## Access Control\n\nRestrict access to specific users:\n\n```ini\n# Allow only specific email addresses\nALLOWED_USER_EMAILS=[\"user@example.com\", \"admin@example.com\"]\n\n# Allow all users from specific domains\nALLOWED_USER_DOMAINS=[\"example.com\", \"company.org\"]\n```\n\n## Hugging Face Login\n\nFor Hugging Face authentication, you can use automatic client registration:\n\n```ini\nOPENID_CLIENT_ID=__CIMD__\n```\n\nThis creates an OAuth app automatically when deployed. See the [CIMD spec](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) for details.\n\n## User Token Forwarding\n\nWhen users log in via Hugging Face, you can forward their token for inference:\n\n```ini\nUSE_USER_TOKEN=true\n```\n\n## Auto-Login\n\nForce authentication on all routes:\n\n```ini\nAUTOMATIC_LOGIN=true\n```\n"
  },
  {
    "path": "docs/source/configuration/overview.md",
    "content": "# Configuration Overview\n\nChat UI is configured through environment variables. Default values are in `.env`; override them in `.env.local` or via your environment.\n\n## Required Configuration\n\nChat UI connects to any OpenAI-compatible API endpoint:\n\n```ini\nOPENAI_BASE_URL=https://router.huggingface.co/v1\nOPENAI_API_KEY=hf_************************\n```\n\nModels are automatically discovered from `${OPENAI_BASE_URL}/models`. No manual model configuration is required.\n\n## Database\n\n```ini\nMONGODB_URL=mongodb://localhost:27017\nMONGODB_DB_NAME=chat-ui\n```\n\nFor development, `MONGODB_URL` is optional - Chat UI falls back to an embedded MongoDB that persists to `./db`.\n\n## Model Overrides\n\nTo customize model behavior, use the `MODELS` environment variable (JSON5 format):\n\n```ini\nMODELS=`[\n  {\n    \"id\": \"meta-llama/Llama-3.3-70B-Instruct\",\n    \"name\": \"Llama 3.3 70B\",\n    \"multimodal\": false,\n    \"supportsTools\": true\n  }\n]`\n```\n\nOverride properties:\n\n- `id` - Model identifier (must match an ID from the `/models` endpoint)\n- `name` - Display name in the UI\n- `multimodal` - Enable image uploads\n- `supportsTools` - Enable MCP tool calling for models that don’t advertise tool support\n- `parameters` - Override default parameters (temperature, max_tokens, etc.)\n\n## Task Model\n\nSet a specific model for internal tasks (title generation, etc.):\n\n```ini\nTASK_MODEL=meta-llama/Llama-3.1-8B-Instruct\n```\n\nIf not set, the current conversation model is used.\n\n## Voice Transcription\n\nEnable voice input with Whisper:\n\n```ini\nTRANSCRIPTION_MODEL=openai/whisper-large-v3-turbo\nTRANSCRIPTION_BASE_URL=https://router.huggingface.co/hf-inference/models\n```\n\n## Feature Flags\n\n```ini\nLLM_SUMMARIZATION=true          # Enable automatic conversation title generation\nENABLE_DATA_EXPORT=true         # Allow users to export their data\nALLOW_IFRAME=false              # Disallow embedding in iframes (set to true to allow)\n```\n\n## User Authentication\n\nUse OpenID Connect for authentication:\n\n```ini\nOPENID_CLIENT_ID=your_client_id\nOPENID_CLIENT_SECRET=your_client_secret\nOPENID_SCOPES=\"openid profile\"\n```\n\nSee [OpenID configuration](./open-id) for details.\n\n## Environment Variable Reference\n\nSee the [`.env` file](https://github.com/huggingface/chat-ui/blob/main/.env) for the complete list of available options.\n"
  },
  {
    "path": "docs/source/configuration/theming.md",
    "content": "# Theming\n\nCustomize the look and feel of Chat UI with these environment variables:\n\n```ini\nPUBLIC_APP_NAME=ChatUI\nPUBLIC_APP_ASSETS=chatui\nPUBLIC_APP_DESCRIPTION=\"Making the community's best AI chat models available to everyone.\"\n```\n\n- `PUBLIC_APP_NAME` - The name used as a title throughout the app\n- `PUBLIC_APP_ASSETS` - Directory for logos & favicons in `static/$PUBLIC_APP_ASSETS`. Options: `chatui`, `huggingchat`\n- `PUBLIC_APP_DESCRIPTION` - Description shown in meta tags and about sections\n\n## Additional Options\n\n```ini\nPUBLIC_APP_DATA_SHARING=1    # Show data sharing opt-in toggle in settings\nPUBLIC_ORIGIN=https://chat.example.com  # Your public URL (required for sharing)\n```\n"
  },
  {
    "path": "docs/source/developing/architecture.md",
    "content": "# Architecture\n\nThis document provides a high-level overview of the Chat UI codebase. If you're looking to contribute or understand how the codebase works, this is the place for you!\n\n## Overview\n\nChat UI provides a simple interface connecting LLMs to external tools via MCP. The project uses [MongoDB](https://www.mongodb.com/) and [SvelteKit](https://kit.svelte.dev/) with [Tailwind](https://tailwindcss.com/).\n\nKey architectural decisions:\n\n- **OpenAI-compatible only**: All model interactions use the OpenAI API format\n- **MCP for tools**: Tool calling is handled via Model Context Protocol servers\n- **Auto-discovery**: Models are discovered from the `/models` endpoint\n\n## Code Map\n\n### `routes`\n\nAll routes rendered with SSR via SvelteKit. The majority of backend and frontend logic lives here, with shared modules in `lib` (client) and `lib/server` (server).\n\n### `textGeneration`\n\nProvides a standard interface for chat features including model output, tool calls, and streaming. Outputs `MessageUpdate`s for fine-grained status updates (new tokens, tool results, etc.).\n\n### `endpoints`\n\nProvides the streaming interface for OpenAI-compatible endpoints. Models are fetched and cached from `${OPENAI_BASE_URL}/models`.\n\n### `mcp`\n\nImplements MCP client functionality for tool discovery and execution. See [MCP Tools](../configuration/mcp-tools) for configuration.\n\n### `llmRouter`\n\nIntelligent routing logic that selects the best model for each request. Uses the Arch router model for classification. See [LLM Router](../configuration/llm-router) for details.\n\n### `migrations`\n\nMongoDB migrations for maintaining backwards compatibility across schema changes. Any schema changes must include a migration.\n\n## Development\n\n```bash\nnpm install\nnpm run dev\n```\n\nThe dev server runs at `http://localhost:5173` with hot reloading.\n"
  },
  {
    "path": "docs/source/index.md",
    "content": "# Chat UI\n\nOpen source chat interface with support for tools, multimodal inputs, and intelligent routing across models. The app uses MongoDB and SvelteKit behind the scenes. Try the live version called [HuggingChat on hf.co/chat](https://huggingface.co/chat) or [setup your own instance](./installation/local).\n\nChat UI connects to any OpenAI-compatible API endpoint, making it work with:\n\n- [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers)\n- [Ollama](https://ollama.ai)\n- [llama.cpp](https://github.com/ggerganov/llama.cpp)\n- [OpenRouter](https://openrouter.ai)\n- Any other OpenAI-compatible service\n\n**[MCP Tools](./configuration/mcp-tools)**: Function calling via Model Context Protocol (MCP) servers\n\n**[LLM Router](./configuration/llm-router)**: Intelligent routing to select the best model for each request\n\n**[Multimodal](./configuration/overview)**: Image uploads on models that support vision\n\n**[OpenID](./configuration/open-id)**: Optional user authentication via OpenID Connect\n\n## Quickstart\n\n**Step 1 - Create `.env.local`:**\n\n```ini\nOPENAI_BASE_URL=https://router.huggingface.co/v1\nOPENAI_API_KEY=hf_************************\n```\n\nYou can use any OpenAI-compatible endpoint:\n\n| Provider     | `OPENAI_BASE_URL`                  | `OPENAI_API_KEY` |\n| ------------ | ---------------------------------- | ---------------- |\n| Hugging Face | `https://router.huggingface.co/v1` | `hf_xxx`         |\n| Ollama       | `http://127.0.0.1:11434/v1`        | `ollama`         |\n| llama.cpp    | `http://127.0.0.1:8080/v1`         | `sk-local`       |\n| OpenRouter   | `https://openrouter.ai/api/v1`     | `sk-or-v1-xxx`   |\n\n**Step 2 - Install and run:**\n\n```bash\ngit clone https://github.com/huggingface/chat-ui\ncd chat-ui\nnpm install\nnpm run dev -- --open\n```\n\nThat's it! Chat UI will automatically discover available models from your endpoint.\n\n> [!TIP]\n> MongoDB is optional for development. When `MONGODB_URL` is not set, Chat UI uses an embedded database that persists to `./db`.\n\nFor production deployments, see the [installation guides](./installation/local).\n"
  },
  {
    "path": "docs/source/installation/docker.md",
    "content": "# Running on Docker\n\nPre-built Docker images are available:\n\n- **`ghcr.io/huggingface/chat-ui-db`** - Includes MongoDB (recommended for quick setup)\n- **`ghcr.io/huggingface/chat-ui`** - Requires external MongoDB\n\n## Quick Start (with bundled MongoDB)\n\n```bash\ndocker run -p 3000:3000 \\\n  -e OPENAI_BASE_URL=https://router.huggingface.co/v1 \\\n  -e OPENAI_API_KEY=hf_*** \\\n  -v chat-ui-data:/data \\\n  ghcr.io/huggingface/chat-ui-db\n```\n\n## With External MongoDB\n\nIf you have an existing MongoDB instance:\n\n```bash\ndocker run -p 3000:3000 \\\n  -e OPENAI_BASE_URL=https://router.huggingface.co/v1 \\\n  -e OPENAI_API_KEY=hf_*** \\\n  -e MONGODB_URL=mongodb://host.docker.internal:27017 \\\n  ghcr.io/huggingface/chat-ui\n```\n\nUse `host.docker.internal` to reach MongoDB running on your host machine, or provide your MongoDB Atlas connection string.\n\n## Using an Environment File\n\nFor more configuration options, use `--env-file` to avoid leaking secrets in shell history:\n\n```bash\ndocker run -p 3000:3000 \\\n  --env-file .env.local \\\n  -v chat-ui-data:/data \\\n  ghcr.io/huggingface/chat-ui-db\n```\n\nSee the [configuration overview](../configuration/overview) for all available environment variables.\n"
  },
  {
    "path": "docs/source/installation/helm.md",
    "content": "# Helm\n\n<Tip warning={true}>\n\nThe Helm chart is a work in progress and should be considered unstable. Breaking changes may be pushed without migration guides. Contributions welcome!\n\n</Tip>\n\nFor Kubernetes deployment, use the Helm chart in `/chart`. No chart repository is published, so clone the repository and install by path.\n\n## Installation\n\n```bash\ngit clone https://github.com/huggingface/chat-ui\ncd chat-ui\nhelm install chat-ui ./chart -f values.yaml\n```\n\n## Example values.yaml\n\n```yaml\nreplicas: 1\n\ndomain: example.com\n\nservice:\n  type: ClusterIP\n\nresources:\n  requests:\n    cpu: 100m\n    memory: 2Gi\n  limits:\n    cpu: \"4\"\n    memory: 6Gi\n\nenvVars:\n  OPENAI_BASE_URL: https://router.huggingface.co/v1\n  OPENAI_API_KEY: hf_***\n  MONGODB_URL: mongodb://chat-ui-mongo:27017\n```\n\nSee the [configuration overview](../configuration/overview) for all available environment variables.\n"
  },
  {
    "path": "docs/source/installation/local.md",
    "content": "# Running Locally\n\n## Quick Start\n\n1. Create a `.env.local` file with your API credentials:\n\n```ini\nOPENAI_BASE_URL=https://router.huggingface.co/v1\nOPENAI_API_KEY=hf_************************\n```\n\n2. Install and run:\n\n```bash\nnpm install\nnpm run dev -- --open\n```\n\nThat's it! Chat UI will discover available models automatically from your endpoint.\n\n## Configuration\n\nChat UI connects to any OpenAI-compatible API. Set `OPENAI_BASE_URL` to your provider:\n\n| Provider     | `OPENAI_BASE_URL`                  |\n| ------------ | ---------------------------------- |\n| Hugging Face | `https://router.huggingface.co/v1` |\n| Ollama       | `http://127.0.0.1:11434/v1`        |\n| llama.cpp    | `http://127.0.0.1:8080/v1`         |\n| OpenRouter   | `https://openrouter.ai/api/v1`     |\n\nSee the [configuration overview](../configuration/overview) for all available options.\n\n## Database\n\nFor **development**, MongoDB is optional. When `MONGODB_URL` is not set, Chat UI uses an embedded MongoDB server that persists data to the `./db` folder.\n\nFor **production**, you should use a dedicated MongoDB instance:\n\n### Option 1: Local MongoDB (Docker)\n\n```bash\ndocker run -d -p 27017:27017 -v mongo-chat-ui:/data --name mongo-chat-ui mongo:latest\n```\n\nThen set `MONGODB_URL=mongodb://localhost:27017` in `.env.local`.\n\n### Option 2: MongoDB Atlas (Managed)\n\nUse [MongoDB Atlas free tier](https://www.mongodb.com/pricing) for a managed database. Copy the connection string to `MONGODB_URL`.\n\n## Running in Production\n\nFor production deployments:\n\n```bash\nnpm install\nnpm run build\nnpm run preview\n```\n\nThe server listens on `http://localhost:4173` by default.\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "ENV_LOCAL_PATH=/app/.env.local\n\nif test -z \"${DOTENV_LOCAL}\" ; then\n    if ! test -f \"${ENV_LOCAL_PATH}\" ; then\n        echo \"DOTENV_LOCAL was not found in the ENV variables and .env.local is not set using a bind volume. Make sure to set environment variables properly. \"\n    fi;\nelse\n    echo \"DOTENV_LOCAL was found in the ENV variables. Creating .env.local file.\"\n    cat <<< \"$DOTENV_LOCAL\" > ${ENV_LOCAL_PATH}\nfi;\n\nif [ \"$INCLUDE_DB\" = \"true\" ] ; then\n    echo \"Starting local MongoDB instance\"\n    nohup mongod &\nfi;\n\nexport PUBLIC_VERSION=$(node -p \"require('./package.json').version\")\n\ndotenv -e /app/.env -c -- node --dns-result-order=ipv4first /app/build/index.js -- --host 0.0.0.0 --port 3000"
  },
  {
    "path": "models/add-your-models-here.txt",
    "content": "You can add .gguf files to this folder, and they will be picked up automatically by chat-ui. "
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"chat-ui\",\n\t\"version\": \"0.20.0\",\n\t\"private\": true,\n\t\"packageManager\": \"npm@9.5.0\",\n\t\"scripts\": {\n\t\t\"dev\": \"vite dev\",\n\t\t\"build\": \"vite build\",\n\t\t\"build:static\": \"ADAPTER=static vite build\",\n\t\t\"preview\": \"vite preview\",\n\t\t\"check\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json\",\n\t\t\"check:watch\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch\",\n\t\t\"lint\": \"prettier --check . && eslint .\",\n\t\t\"format\": \"prettier --write .\",\n\t\t\"test\": \"vitest\",\n\t\t\"updateLocalEnv\": \"vite-node --options.transformMode.ssr='/.*/' scripts/updateLocalEnv.ts\",\n\t\t\"populate\": \"vite-node --options.transformMode.ssr='/.*/' scripts/populate.ts\",\n\t\t\"config\": \"vite-node --options.transformMode.ssr='/.*/' scripts/config.ts\",\n\t\t\"prepare\": \"husky\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@faker-js/faker\": \"^8.4.1\",\n\t\t\"@iconify-json/carbon\": \"^1.1.16\",\n\t\t\"@iconify-json/eos-icons\": \"^1.1.6\",\n\t\t\"@iconify-json/lucide\": \"^1.2.77\",\n\t\t\"@sveltejs/adapter-node\": \"^5.2.12\",\n\t\t\"@sveltejs/adapter-static\": \"^3.0.8\",\n\t\t\"@sveltejs/kit\": \"^2.52.2\",\n\t\t\"@sveltejs/vite-plugin-svelte\": \"^5.0.3\",\n\t\t\"@tailwindcss/typography\": \"^0.5.9\",\n\t\t\"@types/dompurify\": \"^3.0.5\",\n\t\t\"@types/js-yaml\": \"^4.0.9\",\n\t\t\"@types/katex\": \"^0.16.7\",\n\t\t\"@types/mime-types\": \"^2.1.4\",\n\t\t\"@types/minimist\": \"^1.2.5\",\n\t\t\"@types/node\": \"^22.1.0\",\n\t\t\"@types/parquetjs\": \"^0.10.3\",\n\t\t\"@types/uuid\": \"^9.0.8\",\n\t\t\"@types/yazl\": \"^3.3.0\",\n\t\t\"@typescript-eslint/eslint-plugin\": \"^6.x\",\n\t\t\"@typescript-eslint/parser\": \"^6.x\",\n\t\t\"bson-objectid\": \"^2.0.4\",\n\t\t\"dompurify\": \"^3.2.4\",\n\t\t\"eslint\": \"^8.28.0\",\n\t\t\"eslint-config-prettier\": \"^8.5.0\",\n\t\t\"eslint-plugin-svelte\": \"^2.45.1\",\n\t\t\"husky\": \"^9.0.11\",\n\t\t\"isomorphic-dompurify\": \"2.13.0\",\n\t\t\"js-yaml\": \"^4.1.1\",\n\t\t\"lint-staged\": \"^15.2.7\",\n\t\t\"minimist\": \"^1.2.8\",\n\t\t\"mongodb-memory-server\": \"^10.1.2\",\n\t\t\"playwright\": \"^1.55.1\",\n\t\t\"prettier\": \"^3.5.3\",\n\t\t\"prettier-plugin-svelte\": \"^3.2.6\",\n\t\t\"prettier-plugin-tailwindcss\": \"^0.6.11\",\n\t\t\"sade\": \"^1.8.1\",\n\t\t\"superjson\": \"^2.2.2\",\n\t\t\"svelte\": \"^5.53.7\",\n\t\t\"svelte-check\": \"^4.0.0\",\n\t\t\"tslib\": \"^2.4.1\",\n\t\t\"typescript\": \"^5.5.0\",\n\t\t\"unplugin-icons\": \"^0.16.1\",\n\t\t\"vite\": \"^6.3.5\",\n\t\t\"vite-node\": \"^3.0.9\",\n\t\t\"vitest\": \"^3.1.4\",\n\t\t\"vitest-browser-svelte\": \"^0.1.0\",\n\t\t\"yazl\": \"^3.3.1\"\n\t},\n\t\"type\": \"module\",\n\t\"dependencies\": {\n\t\t\"@huggingface/hub\": \"^2.2.0\",\n\t\t\"@huggingface/inference\": \"^4.11.3\",\n\t\t\"@iconify-json/bi\": \"^1.1.21\",\n\t\t\"@modelcontextprotocol/sdk\": \"^1.26.0\",\n\t\t\"@resvg/resvg-js\": \"^2.6.2\",\n\t\t\"ajv\": \"^8.18.0\",\n\t\t\"autoprefixer\": \"^10.4.14\",\n\t\t\"bits-ui\": \"^2.14.2\",\n\t\t\"date-fns\": \"^2.29.3\",\n\t\t\"devalue\": \"^5.6.4\",\n\t\t\"dotenv\": \"^16.5.0\",\n\t\t\"file-type\": \"^21.3.1\",\n\t\t\"handlebars\": \"^4.7.8\",\n\t\t\"highlight.js\": \"^11.7.0\",\n\t\t\"htmlparser2\": \"^10.0.0\",\n\t\t\"ip-address\": \"^9.0.5\",\n\t\t\"jsdom\": \"^28.1.0\",\n\t\t\"json5\": \"^2.2.3\",\n\t\t\"katex\": \"^0.16.21\",\n\t\t\"marked\": \"^12.0.1\",\n\t\t\"mime-types\": \"^2.1.35\",\n\t\t\"mongodb\": \"^5.8.0\",\n\t\t\"nanoid\": \"^5.0.9\",\n\t\t\"openai\": \"^4.44.0\",\n\t\t\"openid-client\": \"^5.4.2\",\n\t\t\"parquetjs\": \"^0.11.2\",\n\t\t\"pino\": \"^9.0.0\",\n\t\t\"pino-pretty\": \"^11.0.0\",\n\t\t\"postcss\": \"^8.4.31\",\n\t\t\"prom-client\": \"^15.1.3\",\n\t\t\"qs\": \"^6.14.2\",\n\t\t\"satori\": \"^0.10.11\",\n\t\t\"satori-html\": \"^0.3.2\",\n\t\t\"sharp\": \"^0.33.4\",\n\t\t\"tailwind-scrollbar\": \"^3.0.0\",\n\t\t\"tailwindcss\": \"^3.4.0\",\n\t\t\"undici\": \"^7.18.2\",\n\t\t\"uuid\": \"^10.0.0\",\n\t\t\"web-haptics\": \"^0.0.6\",\n\t\t\"zod\": \"^3.22.3\"\n\t},\n\t\"overrides\": {\n\t\t\"@reflink/reflink\": \"file:stub/@reflink/reflink\"\n\t}\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n\tplugins: {\n\t\ttailwindcss: {},\n\t\tautoprefixer: {},\n\t},\n};\n"
  },
  {
    "path": "scripts/config.ts",
    "content": "import sade from \"sade\";\n\n// @ts-expect-error: vite-node makes the var available but the typescript compiler doesn't see them\nimport { config, ready } from \"$lib/server/config\";\n\nconst prog = sade(\"config\");\nawait ready;\nprog\n\t.command(\"clear\")\n\t.describe(\"Clear all config keys\")\n\t.action(async () => {\n\t\tconsole.log(\"Clearing config...\");\n\t\tawait clear();\n\t});\n\nprog\n\t.command(\"add <key> <value>\")\n\t.describe(\"Add a new config key\")\n\t.action(async (key: string, value: string) => {\n\t\tawait add(key, value);\n\t});\n\nprog\n\t.command(\"remove <key>\")\n\t.describe(\"Remove a config key\")\n\t.action(async (key: string) => {\n\t\tconsole.log(`Removing ${key}`);\n\t\tawait remove(key);\n\t\tprocess.exit(0);\n\t});\n\nprog\n\t.command(\"help\")\n\t.describe(\"Show help information\")\n\t.action(() => {\n\t\tprog.help();\n\t\tprocess.exit(0);\n\t});\n\nasync function clear() {\n\tawait config.clear();\n\tprocess.exit(0);\n}\n\nasync function add(key: string, value: string) {\n\tif (!key || !value) {\n\t\tconsole.error(\"Key and value are required\");\n\t\tprocess.exit(1);\n\t}\n\tawait config.set(key as keyof typeof config.keysFromEnv, value);\n\tprocess.exit(0);\n}\n\nasync function remove(key: string) {\n\tif (!key) {\n\t\tconsole.error(\"Key is required\");\n\t\tprocess.exit(1);\n\t}\n\tawait config.delete(key as keyof typeof config.keysFromEnv);\n\tprocess.exit(0);\n}\n\n// Parse arguments and handle help automatically\nprog.parse(process.argv);\n"
  },
  {
    "path": "scripts/populate.ts",
    "content": "import readline from \"readline\";\nimport minimist from \"minimist\";\n\n// @ts-expect-error: vite-node makes the var available but the typescript compiler doesn't see them\nimport { env } from \"$env/dynamic/private\";\n\nimport { faker } from \"@faker-js/faker\";\nimport { ObjectId } from \"mongodb\";\n\n// @ts-expect-error: vite-node makes the var available but the typescript compiler doesn't see them\nimport { ready } from \"$lib/server/config\";\nimport { collections } from \"$lib/server/database.ts\";\nimport { models } from \"../src/lib/server/models.ts\";\nimport type { User } from \"../src/lib/types/User\";\nimport type { Assistant } from \"../src/lib/types/Assistant\";\nimport type { Conversation } from \"../src/lib/types/Conversation\";\nimport type { Settings } from \"../src/lib/types/Settings\";\nimport { Message } from \"../src/lib/types/Message.ts\";\n\nimport { addChildren } from \"../src/lib/utils/tree/addChildren.ts\";\nimport { generateSearchTokens } from \"../src/lib/utils/searchTokens.ts\";\nimport { ReviewStatus } from \"../src/lib/types/Review.ts\";\nimport fs from \"fs\";\nimport path from \"path\";\n\nconst rl = readline.createInterface({\n\tinput: process.stdin,\n\toutput: process.stdout,\n});\n\nawait ready;\n\nrl.on(\"close\", function () {\n\tprocess.exit(0);\n});\n\nconst samples = fs.readFileSync(path.join(__dirname, \"samples.txt\"), \"utf8\").split(\"\\n---\\n\");\n\nconst possibleFlags = [\"reset\", \"all\", \"users\", \"settings\", \"assistants\", \"conversations\"];\nconst argv = minimist(process.argv.slice(2));\nconst flags = argv[\"_\"].filter((flag) => possibleFlags.includes(flag));\n\nasync function generateMessages(preprompt?: string): Promise<Message[]> {\n\tconst isLinear = faker.datatype.boolean(0.5);\n\tconst isInterrupted = faker.datatype.boolean(0.05);\n\n\tconst messages: Message[] = [];\n\n\tmessages.push({\n\t\tid: crypto.randomUUID(),\n\t\tfrom: \"system\",\n\t\tcontent: preprompt ?? \"\",\n\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t});\n\n\tlet isUser = true;\n\tlet lastId = messages[0].id;\n\tif (isLinear) {\n\t\tconst convLength = faker.number.int({ min: 1, max: 25 }) * 2; // must always be even\n\n\t\tfor (let i = 0; i < convLength; i++) {\n\t\t\tlastId = addChildren(\n\t\t\t\t{\n\t\t\t\t\tmessages,\n\t\t\t\t\trootMessageId: messages[0].id,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tfrom: isUser ? \"user\" : \"assistant\",\n\t\t\t\t\tcontent:\n\t\t\t\t\t\tfaker.lorem.sentence({\n\t\t\t\t\t\t\tmin: 10,\n\t\t\t\t\t\t\tmax: isUser ? 50 : 200,\n\t\t\t\t\t\t}) +\n\t\t\t\t\t\t(!isUser && Math.random() < 0.1\n\t\t\t\t\t\t\t? \"\\n```\\n\" + faker.helpers.arrayElement(samples) + \"\\n```\\n\"\n\t\t\t\t\t\t\t: \"\"),\n\t\t\t\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\tinterrupted: !isUser && i === convLength - 1 && isInterrupted,\n\t\t\t\t},\n\t\t\t\tlastId\n\t\t\t);\n\t\t\tisUser = !isUser;\n\t\t}\n\t} else {\n\t\tconst convLength = faker.number.int({ min: 2, max: 200 });\n\n\t\tfor (let i = 0; i < convLength; i++) {\n\t\t\taddChildren(\n\t\t\t\t{\n\t\t\t\t\tmessages,\n\t\t\t\t\trootMessageId: messages[0].id,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tfrom: isUser ? \"user\" : \"assistant\",\n\t\t\t\t\tcontent:\n\t\t\t\t\t\tfaker.lorem.sentence({\n\t\t\t\t\t\t\tmin: 10,\n\t\t\t\t\t\t\tmax: isUser ? 50 : 200,\n\t\t\t\t\t\t}) +\n\t\t\t\t\t\t(!isUser && Math.random() < 0.1\n\t\t\t\t\t\t\t? \"\\n```\\n\" + faker.helpers.arrayElement(samples) + \"\\n```\\n\"\n\t\t\t\t\t\t\t: \"\"),\n\t\t\t\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\tinterrupted: !isUser && i === convLength - 1 && isInterrupted,\n\t\t\t\t},\n\t\t\t\tfaker.helpers.arrayElement([\n\t\t\t\t\tmessages[0].id,\n\t\t\t\t\t...messages.filter((m) => m.from === (isUser ? \"assistant\" : \"user\")).map((m) => m.id),\n\t\t\t\t])\n\t\t\t);\n\n\t\t\tisUser = !isUser;\n\t\t}\n\t}\n\treturn messages;\n}\n\nasync function seed() {\n\tconsole.log(\"Seeding...\");\n\tconst modelIds = models.map((model) => model.id);\n\n\tif (flags.includes(\"reset\")) {\n\t\tconsole.log(\"Starting reset of DB\");\n\t\tawait collections.users.deleteMany({});\n\t\tawait collections.settings.deleteMany({});\n\t\tawait collections.assistants.deleteMany({});\n\t\tawait collections.conversations.deleteMany({});\n\t\tawait collections.migrationResults.deleteMany({});\n\t\tawait collections.semaphores.deleteMany({});\n\t\tconsole.log(\"Reset done\");\n\t}\n\n\tif (flags.includes(\"users\") || flags.includes(\"all\")) {\n\t\tconsole.log(\"Creating 100 new users\");\n\t\tconst newUsers: User[] = Array.from({ length: 100 }, () => ({\n\t\t\t_id: new ObjectId(),\n\t\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t\t\tusername: faker.internet.userName(),\n\t\t\tname: faker.person.fullName(),\n\t\t\thfUserId: faker.string.alphanumeric(24),\n\t\t\tavatarUrl: faker.image.avatar(),\n\t\t}));\n\n\t\tawait collections.users.insertMany(newUsers);\n\t\tconsole.log(\"Done creating users.\");\n\t}\n\n\tconst users = await collections.users.find().toArray();\n\tif (flags.includes(\"settings\") || flags.includes(\"all\")) {\n\t\tconsole.log(\"Updating settings for all users\");\n\t\tusers.forEach(async (user) => {\n\t\t\tconst settings: Settings = {\n\t\t\t\tuserId: user._id,\n\t\t\t\tshareConversationsWithModelAuthors: faker.datatype.boolean(0.25),\n\t\t\t\thideEmojiOnSidebar: faker.datatype.boolean(0.25),\n\t\t\t\tactiveModel: faker.helpers.arrayElement(modelIds),\n\t\t\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\tdisableStream: faker.datatype.boolean(0.25),\n\t\t\t\tdirectPaste: faker.datatype.boolean(0.25),\n\t\t\t\thidePromptExamples: {},\n\t\t\t\tcustomPrompts: {},\n\t\t\t\tassistants: [],\n\t\t\t};\n\t\t\tawait collections.settings.updateOne(\n\t\t\t\t{ userId: user._id },\n\t\t\t\t{ $set: { ...settings } },\n\t\t\t\t{ upsert: true }\n\t\t\t);\n\t\t});\n\t\tconsole.log(\"Done updating settings.\");\n\t}\n\n\tif (flags.includes(\"assistants\") || flags.includes(\"all\")) {\n\t\tconsole.log(\"Creating assistants for all users\");\n\t\tawait Promise.all(\n\t\t\tusers.map(async (user) => {\n\t\t\t\tconst name = faker.animal.insect();\n\t\t\t\tconst assistants = faker.helpers.multiple<Assistant>(\n\t\t\t\t\t() => ({\n\t\t\t\t\t\t_id: new ObjectId(),\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tcreatedById: user._id,\n\t\t\t\t\t\tcreatedByName: user.username,\n\t\t\t\t\t\tcreatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\t\tupdatedAt: faker.date.recent({ days: 30 }),\n\t\t\t\t\t\tuserCount: faker.number.int({ min: 1, max: 100000 }),\n\t\t\t\t\t\treview: faker.helpers.enumValue(ReviewStatus),\n\t\t\t\t\t\tmodelId: faker.helpers.arrayElement(modelIds),\n\t\t\t\t\t\tdescription: faker.lorem.sentence(),\n\t\t\t\t\t\tpreprompt: faker.hacker.phrase(),\n\t\t\t\t\t\texampleInputs: faker.helpers.multiple(() => faker.lorem.sentence(), {\n\t\t\t\t\t\t\tcount: faker.number.int({ min: 0, max: 4 }),\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tsearchTokens: generateSearchTokens(name),\n\t\t\t\t\t\tlast24HoursCount: faker.number.int({ min: 0, max: 1000 }),\n\t\t\t\t\t}),\n\t\t\t\t\t{ count: faker.number.int({ min: 3, max: 10 }) }\n\t\t\t\t);\n\t\t\t\tawait collections.assistants.insertMany(assistants);\n\t\t\t\tawait collections.settings.updateOne(\n\t\t\t\t\t{ userId: user._id },\n\t\t\t\t\t{ $set: { assistants: assistants.map((a) => a._id.toString()) } },\n\t\t\t\t\t{ upsert: true }\n\t\t\t\t);\n\t\t\t})\n\t\t);\n\t\tconsole.log(\"Done creating assistants.\");\n\t}\n\n\tif (flags.includes(\"conversations\") || flags.includes(\"all\")) {\n\t\tconsole.log(\"Creating conversations for all users\");\n\t\tawait Promise.all(\n\t\t\tusers.map(async (user) => {\n\t\t\t\tconst conversations = faker.helpers.multiple(\n\t\t\t\t\tasync () => {\n\t\t\t\t\t\tconst settings = await collections.settings.findOne<Settings>({ userId: user._id });\n\n\t\t\t\t\t\tconst assistantId =\n\t\t\t\t\t\t\tsettings?.assistants && settings.assistants.length > 0 && faker.datatype.boolean(0.1)\n\t\t\t\t\t\t\t\t? faker.helpers.arrayElement<ObjectId>(settings.assistants)\n\t\t\t\t\t\t\t\t: undefined;\n\n\t\t\t\t\t\tconst preprompt =\n\t\t\t\t\t\t\t(assistantId\n\t\t\t\t\t\t\t\t? await collections.assistants\n\t\t\t\t\t\t\t\t\t\t.findOne({ _id: assistantId })\n\t\t\t\t\t\t\t\t\t\t.then((assistant: Assistant) => assistant?.preprompt ?? \"\")\n\t\t\t\t\t\t\t\t: faker.helpers.maybe(() => faker.hacker.phrase(), { probability: 0.5 })) ?? \"\";\n\n\t\t\t\t\t\tconst messages = await generateMessages(preprompt);\n\n\t\t\t\t\t\tconst conv = {\n\t\t\t\t\t\t\t_id: new ObjectId(),\n\t\t\t\t\t\t\tuserId: user._id,\n\t\t\t\t\t\t\tassistantId,\n\t\t\t\t\t\t\tpreprompt,\n\t\t\t\t\t\t\tcreatedAt: faker.date.recent({ days: 145 }),\n\t\t\t\t\t\t\tupdatedAt: faker.date.recent({ days: 145 }),\n\t\t\t\t\t\t\tmodel: faker.helpers.arrayElement(modelIds),\n\t\t\t\t\t\t\ttitle: faker.internet.emoji() + \" \" + faker.hacker.phrase(),\n\t\t\t\t\t\t\t// embeddings removed in this build\n\t\t\t\t\t\t\tmessages,\n\t\t\t\t\t\t\trootMessageId: messages[0].id,\n\t\t\t\t\t\t} satisfies Conversation;\n\n\t\t\t\t\t\treturn conv;\n\t\t\t\t\t},\n\t\t\t\t\t{ count: faker.number.int({ min: 10, max: 200 }) }\n\t\t\t\t);\n\n\t\t\t\tawait collections.conversations.insertMany(await Promise.all(conversations));\n\t\t\t})\n\t\t);\n\t\tconsole.log(\"Done creating conversations.\");\n\t}\n}\n\n// run seed\n(async () => {\n\ttry {\n\t\trl.question(\n\t\t\t\"You're about to run a seeding script on the following MONGODB_URL: \\x1b[31m\" +\n\t\t\t\tenv.MONGODB_URL +\n\t\t\t\t\"\\x1b[0m\\n\\n With the following flags: \\x1b[31m\" +\n\t\t\t\tflags.join(\"\\x1b[0m , \\x1b[31m\") +\n\t\t\t\t\"\\x1b[0m\\n \\n\\n Are you sure you want to continue? (yes/no): \",\n\t\t\tasync (confirm) => {\n\t\t\t\tif (confirm !== \"yes\") {\n\t\t\t\t\tconsole.log(\"Not 'yes', exiting.\");\n\t\t\t\t\trl.close();\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\tconsole.log(\"Starting seeding...\");\n\t\t\t\tawait seed();\n\t\t\t\tconsole.log(\"Seeding done.\");\n\t\t\t\trl.close();\n\t\t\t}\n\t\t);\n\t} catch (e) {\n\t\tconsole.error(e);\n\t\tprocess.exit(1);\n\t}\n})();\n"
  },
  {
    "path": "scripts/samples.txt",
    "content": "import { Observable, of, from, interval, throwError } from 'rxjs';\nimport { map, filter, catchError, switchMap, take, tap } from 'rxjs/operators';\n\n// Mock function to fetch stock prices (simulates API call)\nconst fetchStockPrice = (ticker: string): Observable<number> => {\n    return new Observable<number>((observer) => {\n        const intervalId = setInterval(() => {\n            if (Math.random() < 0.1) { // Simulating an error 10% of the time\n                observer.error(`Error fetching stock price for ${ticker}`);\n            } else {\n                const price = parseFloat((Math.random() * 1000).toFixed(2));\n                observer.next(price);\n            }\n        }, 1000);\n\n        return () => {\n            clearInterval(intervalId);\n            console.log(`Stopped fetching prices for ${ticker}`);\n        };\n    });\n};\n\n// Example usage: Tracking stock price updates\nconst stockTicker = 'AAPL';\nconst stockPrice$ = fetchStockPrice(stockTicker).pipe(\n    map(price => ({ ticker: stockTicker, price })),  // Transform data\n    filter(data => data.price > 500), // Only keep prices above 500\n    tap(data => console.log(`Price update:`, data)), // Side effect: Logging\n    catchError(err => {\n        console.error(err);\n        return of({ ticker: stockTicker, price: null }); // Fallback observable\n    })\n);\n\n// Subscribe to the stock price updates\nconst subscription = stockPrice$.subscribe({\n    next: data => console.log(`Subscriber received:`, data),\n    error: err => console.error(`Subscription error:`, err),\n    complete: () => console.log('Stream complete'),\n});\n\n// Automatically unsubscribe after 10 seconds\nsetTimeout(() => {\n    subscription.unsubscribe();\n    console.log('Unsubscribed from stock price updates.');\n}, 10000);\n---\nclass EnforceAttrsMeta(type):\n    \"\"\"\n    Metaclass that enforces the presence of specific attributes in a class\n    and automatically decorates methods with a logging wrapper.\n    \"\"\"\n    \n    required_attributes = ['name', 'version']\n\n    def __new__(cls, name, bases, class_dict):\n        \"\"\"\n        Create a new class with enforced attributes and method logging.\n\n        :param name: Name of the class being created.\n        :param bases: Tuple of base classes.\n        :param class_dict: Dictionary of attributes and methods of the class.\n        :return: Newly created class object.\n        \"\"\"\n        # Ensure required attributes exist\n        for attr in cls.required_attributes:\n            if attr not in class_dict:\n                raise TypeError(f\"Class '{name}' is missing required attribute '{attr}'\")\n\n        # Wrap all methods in a logging decorator\n        for key, value in class_dict.items():\n            if callable(value):  # Check if it's a method\n                class_dict[key] = cls.log_calls(value)\n\n        return super().__new__(cls, name, bases, class_dict)\n\n    @staticmethod\n    def log_calls(func):\n        \"\"\"\n        Decorator that logs method calls and arguments.\n\n        :param func: Function to be wrapped.\n        :return: Wrapped function with logging.\n        \"\"\"\n        def wrapper(*args, **kwargs):\n            print(f\"Calling {func.__name__} with args={args} kwargs={kwargs}\")\n            result = func(*args, **kwargs)\n            print(f\"{func.__name__} returned {result}\")\n            return result\n        return wrapper\n\n\nclass PluginBase(metaclass=EnforceAttrsMeta):\n    \"\"\"\n    Base class for plugins that enforces required attributes and logging.\n    \"\"\"\n    name = \"BasePlugin\"\n    version = \"1.0\"\n\n    def run(self, data):\n        \"\"\"\n        Process the input data.\n\n        :param data: The data to be processed.\n        :return: Processed result.\n        \"\"\"\n        return f\"Processed {data}\"\n\n\nclass CustomPlugin(PluginBase):\n    \"\"\"\n    Custom plugin that extends PluginBase and adheres to enforced rules.\n    \"\"\"\n    name = \"CustomPlugin\"\n    version = \"2.0\"\n\n    def run(self, data):\n        \"\"\"\n        Custom processing logic.\n\n        :param data: The data to process.\n        :return: Modified data.\n        \"\"\"\n        return f\"Custom processing of {data}\"\n\n\n# Uncommenting the following class definition will raise a TypeError\n# because 'version' attribute is missing.\n# class InvalidPlugin(PluginBase):\n#     name = \"InvalidPlugin\"\n\n\nif __name__ == \"__main__\":\n    # Instantiate and use the plugin\n    plugin = CustomPlugin()\n    print(plugin.run(\"example data\"))\n---\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Click the Box Game</title>\n    <style>\n        body {\n            text-align: center;\n            font-family: Arial, sans-serif;\n        }\n        #game-container {\n            position: relative;\n            width: 300px;\n            height: 300px;\n            margin: 20px auto;\n            border: 2px solid black;\n            overflow: hidden;\n        }\n        #target {\n            width: 50px;\n            height: 50px;\n            background-color: red;\n            position: absolute;\n            cursor: pointer;\n        }\n    </style>\n</head>\n<body>\n    <h1>Click the Box!</h1>\n    <p>Score: <span id=\"score\">0</span></p>\n    <div id=\"game-container\">\n        <div id=\"target\"></div>\n    </div>\n    <script>\n        let score = 0;\n        const target = document.getElementById(\"target\");\n        const scoreDisplay = document.getElementById(\"score\");\n        const container = document.getElementById(\"game-container\");\n        \n        function moveTarget() {\n            const maxX = container.clientWidth - target.clientWidth;\n            const maxY = container.clientHeight - target.clientHeight;\n            target.style.left = Math.random() * maxX + \"px\";\n            target.style.top = Math.random() * maxY + \"px\";\n        }\n        \n        target.addEventListener(\"click\", function() {\n            score++;\n            scoreDisplay.textContent = score;\n            moveTarget();\n        });\n        \n        moveTarget();\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "scripts/setups/vitest-setup-client.ts",
    "content": ""
  },
  {
    "path": "scripts/setups/vitest-setup-server.ts",
    "content": "import { vi, afterAll } from \"vitest\";\nimport dotenv from \"dotenv\";\nimport { resolve } from \"path\";\nimport fs from \"fs\";\nimport { MongoMemoryServer } from \"mongodb-memory-server\";\n\nlet mongoServer: MongoMemoryServer;\n// Load the .env file\nconst envPath = resolve(__dirname, \"../../.env\");\ndotenv.config({ path: envPath });\n\n// Read the .env file content\nconst envContent = fs.readFileSync(envPath, \"utf-8\");\n\n// Parse the .env content\nconst envVars = dotenv.parse(envContent);\n\n// Separate public and private variables\nconst publicEnv = {};\nconst privateEnv = {};\n\nfor (const [key, value] of Object.entries(envVars)) {\n\tif (key.startsWith(\"PUBLIC_\")) {\n\t\tpublicEnv[key] = value;\n\t} else {\n\t\tprivateEnv[key] = value;\n\t}\n}\n\nvi.mock(\"$env/dynamic/public\", () => ({\n\tenv: publicEnv,\n}));\n\nvi.mock(\"$env/dynamic/private\", async () => {\n\tmongoServer = await MongoMemoryServer.create();\n\n\treturn {\n\t\tenv: {\n\t\t\t...privateEnv,\n\t\t\tMONGODB_URL: mongoServer.getUri(),\n\t\t},\n\t};\n});\n\nafterAll(async () => {\n\tif (mongoServer) {\n\t\tawait mongoServer.stop();\n\t}\n});\n"
  },
  {
    "path": "scripts/updateLocalEnv.ts",
    "content": "import fs from \"fs\";\nimport yaml from \"js-yaml\";\n\nconst file = fs.readFileSync(\"chart/env/prod.yaml\", \"utf8\");\n\n// have to do a weird stringify/parse because of some node error\nconst prod = JSON.parse(JSON.stringify(yaml.load(file)));\nconst vars = prod.envVars as Record<string, string>;\n\nlet PUBLIC_CONFIG = \"\";\n\nObject.entries(vars)\n\t// filter keys used in prod with the proxy\n\t.filter(\n\t\t([key]) =>\n\t\t\t![\n\t\t\t\t\"XFF_DEPTH\",\n\t\t\t\t\"ADDRESS_HEADER\",\n\t\t\t\t\"APP_BASE\",\n\t\t\t\t\"PUBLIC_ORIGIN\",\n\t\t\t\t\"PUBLIC_SHARE_PREFIX\",\n\t\t\t\t\"ADMIN_CLI_LOGIN\",\n\t\t\t].includes(key)\n\t)\n\t.forEach(([key, value]) => {\n\t\tPUBLIC_CONFIG += `${key}=\\`${value}\\`\\n`;\n\t});\n\nconst SECRET_CONFIG =\n\t(fs.existsSync(\".env.SECRET_CONFIG\")\n\t\t? fs.readFileSync(\".env.SECRET_CONFIG\", \"utf8\")\n\t\t: process.env.SECRET_CONFIG) ?? \"\";\n\n// Prepend the content of the env variable SECRET_CONFIG\nlet full_config = `${PUBLIC_CONFIG}\\n${SECRET_CONFIG}`;\n\n// replace the internal proxy url with the public endpoint\nfull_config = full_config.replaceAll(\n\t\"https://internal.api-inference.huggingface.co\",\n\t\"https://router.huggingface.co/hf-inference\"\n);\n\nfull_config = full_config.replaceAll(\"COOKIE_SECURE=`true`\", \"COOKIE_SECURE=`false`\");\nfull_config = full_config.replaceAll(\"LOG_LEVEL=`debug`\", \"LOG_LEVEL=`info`\");\nfull_config = full_config.replaceAll(\"NODE_ENV=`prod`\", \"NODE_ENV=`development`\");\n\n// Write full_config to .env.local\nfs.writeFileSync(\".env.local\", full_config);\n"
  },
  {
    "path": "server.log",
    "content": "/Users/vm/.venv/bin/python3: No module named uvicorn\n/Users/vm/.venv/bin/python3: No module named uvicorn\n"
  },
  {
    "path": "src/ambient.d.ts",
    "content": "declare module \"*.ttf\" {\n\tconst value: ArrayBuffer;\n\texport default value;\n}\n\n// Legacy helpers removed: web search support is deprecated, so we intentionally\n// avoid leaking those shapes into the global ambient types.\n"
  },
  {
    "path": "src/app.d.ts",
    "content": "/// <reference types=\"@sveltejs/kit\" />\n/// <reference types=\"unplugin-icons/types/svelte\" />\n\nimport type { User } from \"$lib/types/User\";\n\n// See https://kit.svelte.dev/docs/types#app\n// for information about these interfaces\ndeclare global {\n\tnamespace App {\n\t\t// interface Error {}\n\t\tinterface Locals {\n\t\t\tsessionId: string;\n\t\t\tuser?: User;\n\t\t\tisAdmin: boolean;\n\t\t\ttoken?: string;\n\t\t\t/** Organization to bill inference requests to (from settings) */\n\t\t\tbillingOrganization?: string;\n\t\t}\n\n\t\tinterface Error {\n\t\t\tmessage: string;\n\t\t\terrorId?: ReturnType<typeof crypto.randomUUID>;\n\t\t}\n\t\t// interface PageData {}\n\t\t// interface Platform {}\n\t}\n}\n\nexport {};\n"
  },
  {
    "path": "src/app.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<meta\n\t\t\tname=\"viewport\"\n\t\t\tcontent=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\"\n\t\t/>\n\t\t<meta name=\"theme-color\" content=\"rgb(249, 250, 251)\" />\n\t\t<script>\n\t\t\t(function () {\n\t\t\t\ttry {\n\t\t\t\t\tvar prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n\t\t\t\t\tvar stored = localStorage.getItem(\"theme\");\n\t\t\t\t\tvar followSystem = stored === null || stored === \"system\";\n\t\t\t\t\tvar isDark = stored === \"dark\" || (followSystem && prefersDark);\n\t\t\t\t\tif (isDark) {\n\t\t\t\t\t\tdocument.documentElement.classList.add(\"dark\");\n\t\t\t\t\t\tdocument.querySelector('meta[name=\"theme-color\"]').setAttribute(\"content\", \"#07090d\");\n\t\t\t\t\t}\n\t\t\t\t} catch (e) {}\n\t\t\t})();\n\n\t\t\t// For some reason, Sveltekit doesn't let us load env variables from .env here, so we load it from hooks.server.ts\n\t\t\twindow.gaId = \"%gaId%\";\n\t\t</script>\n\t\t%sveltekit.head%\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\" class=\"h-dvh dark:bg-gray-900\">\n\t\t<div id=\"app\" class=\"contents h-full\">%sveltekit.body%</div>\n\n\t\t<!-- Google Tag Manager -->\n\t\t<script>\n\t\t\tif (window.gaId) {\n\t\t\t\tconst script = document.createElement(\"script\");\n\t\t\t\tscript.src = \"https://www.googletagmanager.com/gtag/js?id=\" + window.gaId;\n\t\t\t\tscript.async = true;\n\t\t\t\tdocument.head.appendChild(script);\n\n\t\t\t\twindow.dataLayer = window.dataLayer || [];\n\t\t\t\tfunction gtag() {\n\t\t\t\t\tdataLayer.push(arguments);\n\t\t\t\t}\n\t\t\t\tgtag(\"js\", new Date());\n\t\t\t\t/// ^ See https://developers.google.com/tag-platform/gtagjs/install\n\t\t\t\tgtag(\"config\", window.gaId);\n\t\t\t\tgtag(\"consent\", \"default\", { ad_storage: \"denied\", analytics_storage: \"denied\" });\n\t\t\t\t/// ^ See https://developers.google.com/tag-platform/gtagjs/reference#consent\n\t\t\t\t/// TODO: ask the user for their consent and update this with gtag('consent', 'update')\n\t\t\t}\n\t\t</script>\n\t</body>\n</html>\n"
  },
  {
    "path": "src/hooks.server.ts",
    "content": "import { building } from \"$app/environment\";\nimport type { Handle, HandleServerError, ServerInit, HandleFetch } from \"@sveltejs/kit\";\nimport { initServer } from \"$lib/server/hooks/init\";\nimport { handleRequest } from \"$lib/server/hooks/handle\";\nimport { handleServerError } from \"$lib/server/hooks/error\";\nimport { handleFetchRequest } from \"$lib/server/hooks/fetch\";\n\nexport const init: ServerInit = async () => {\n\tif (building) return;\n\treturn initServer();\n};\n\nexport const handle: Handle = async (input) => {\n\tif (building) {\n\t\t// During static build, still replace %gaId% placeholder with empty string\n\t\t// to prevent the GA script from loading with an invalid ID\n\t\treturn input.resolve(input.event, {\n\t\t\ttransformPageChunk: ({ html }) => html.replace(\"%gaId%\", \"\"),\n\t\t});\n\t}\n\treturn handleRequest(input);\n};\n\nexport const handleError: HandleServerError = async (input) => {\n\tif (building) throw input.error;\n\treturn handleServerError(input);\n};\n\nexport const handleFetch: HandleFetch = async (input) => {\n\tif (building) return input.fetch(input.request);\n\treturn handleFetchRequest(input);\n};\n"
  },
  {
    "path": "src/hooks.ts",
    "content": "import { publicConfigTransporter } from \"$lib/utils/PublicConfig.svelte\";\nimport type { Transport } from \"@sveltejs/kit\";\n\nexport const transport: Transport = {\n\tPublicConfig: publicConfigTransporter,\n};\n"
  },
  {
    "path": "src/lib/APIClient.ts",
    "content": "import { base } from \"$app/paths\";\nimport { browser } from \"$app/environment\";\nimport superjson from \"superjson\";\nimport ObjectId from \"bson-objectid\";\n\nsuperjson.registerCustom<ObjectId, string>(\n\t{\n\t\tisApplicable: (value): value is ObjectId => {\n\t\t\tif (typeof value !== \"string\" && ObjectId.isValid(value)) {\n\t\t\t\tconst str = value.toString();\n\t\t\t\treturn /^[0-9a-fA-F]{24}$/.test(str);\n\t\t\t}\n\t\t\treturn false;\n\t\t},\n\t\tserialize: (value) => value.toString(),\n\t\tdeserialize: (value) => new ObjectId(value),\n\t},\n\t\"ObjectId\"\n);\n\ntype FetchFn = typeof globalThis.fetch;\n\ninterface ApiResponse<T = unknown> {\n\tdata: T | null;\n\terror: unknown;\n\tstatus: number;\n}\n\nasync function apiCall<T = unknown>(\n\tfetcher: FetchFn,\n\turl: string,\n\tmethod: string,\n\tbody?: unknown,\n\tquery?: Record<string, string | number | undefined>\n): Promise<ApiResponse<T>> {\n\tconst u = new URL(url);\n\tif (query) {\n\t\tfor (const [k, v] of Object.entries(query)) {\n\t\t\tif (v !== undefined && v !== null) {\n\t\t\t\tu.searchParams.set(k, String(v));\n\t\t\t}\n\t\t}\n\t}\n\n\tconst init: RequestInit = { method };\n\tif (body !== undefined && body !== null) {\n\t\tinit.headers = { \"Content-Type\": \"application/json\" };\n\t\tinit.body = JSON.stringify(body);\n\t}\n\n\tconst res = await fetcher(u.toString(), init);\n\tif (!res.ok) {\n\t\tlet errorBody: unknown;\n\t\ttry {\n\t\t\terrorBody = await res.json();\n\t\t} catch {\n\t\t\terrorBody = await res.text().catch(() => res.statusText);\n\t\t}\n\t\treturn { data: null, error: errorBody, status: res.status };\n\t}\n\n\t// Handle empty responses (e.g. POST /user/settings returns empty body)\n\tconst text = await res.text();\n\tif (!text) {\n\t\treturn { data: null, error: null, status: res.status };\n\t}\n\n\treturn { data: text as unknown as T, error: null, status: res.status };\n}\n\nfunction endpoint(fetcher: FetchFn, baseUrl: string) {\n\treturn {\n\t\tget(opts?: { query?: Record<string, string | number | undefined> }) {\n\t\t\treturn apiCall(fetcher, baseUrl, \"GET\", undefined, opts?.query);\n\t\t},\n\t\tpost(body?: unknown) {\n\t\t\treturn apiCall(fetcher, baseUrl, \"POST\", body);\n\t\t},\n\t\tpatch(body?: unknown) {\n\t\t\treturn apiCall(fetcher, baseUrl, \"PATCH\", body);\n\t\t},\n\t\tdelete() {\n\t\t\treturn apiCall(fetcher, baseUrl, \"DELETE\");\n\t\t},\n\t};\n}\n\nexport function useAPIClient({\n\tfetch: customFetch,\n\torigin,\n}: {\n\tfetch?: FetchFn;\n\torigin?: string;\n} = {}) {\n\tconst fetcher = customFetch ?? globalThis.fetch;\n\tconst baseUrl = browser\n\t\t? `${window.location.origin}${base}/api/v2`\n\t\t: `${origin ?? `http://localhost:5173`}${base}/api/v2`;\n\n\treturn {\n\t\tconversations: Object.assign(\n\t\t\t// client.conversations({ id: \"...\" }) — returns endpoint for /conversations/:id\n\t\t\t(params: { id: string }) => ({\n\t\t\t\t...endpoint(fetcher, `${baseUrl}/conversations/${params.id}`),\n\t\t\t\tmessage: (msgParams: { messageId: string }) =>\n\t\t\t\t\tendpoint(fetcher, `${baseUrl}/conversations/${params.id}/message/${msgParams.messageId}`),\n\t\t\t}),\n\t\t\t// client.conversations.get(), .delete()\n\t\t\t{\n\t\t\t\t...endpoint(fetcher, `${baseUrl}/conversations`),\n\t\t\t\t\"import-share\": endpoint(fetcher, `${baseUrl}/conversations/import-share`),\n\t\t\t}\n\t\t),\n\t\tuser: {\n\t\t\t...endpoint(fetcher, `${baseUrl}/user`),\n\t\t\tsettings: endpoint(fetcher, `${baseUrl}/user/settings`),\n\t\t\treports: endpoint(fetcher, `${baseUrl}/user/reports`),\n\t\t\t\"billing-orgs\": endpoint(fetcher, `${baseUrl}/user/billing-orgs`),\n\t\t},\n\t\tmodels: {\n\t\t\t...endpoint(fetcher, `${baseUrl}/models`),\n\t\t\told: endpoint(fetcher, `${baseUrl}/models/old`),\n\t\t\trefresh: endpoint(fetcher, `${baseUrl}/models/refresh`),\n\t\t},\n\t\t\"public-config\": endpoint(fetcher, `${baseUrl}/public-config`),\n\t\t\"feature-flags\": endpoint(fetcher, `${baseUrl}/feature-flags`),\n\t\tdebug: {\n\t\t\tconfig: endpoint(fetcher, `${baseUrl}/debug/config`),\n\t\t\trefresh: endpoint(fetcher, `${baseUrl}/debug/refresh`),\n\t\t},\n\t\texport: endpoint(fetcher, `${baseUrl}/export`),\n\t};\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function handleResponse(response: ApiResponse<any>): any {\n\tif (response.error) {\n\t\tthrow new Error(JSON.stringify(response.error));\n\t}\n\n\tif (response.data === null) {\n\t\treturn null;\n\t}\n\n\treturn superjson.parse(\n\t\ttypeof response.data === \"string\" ? response.data : JSON.stringify(response.data)\n\t);\n}\n"
  },
  {
    "path": "src/lib/actions/clickOutside.ts",
    "content": "export function clickOutside(element: HTMLElement, callbackFunction: () => void) {\n\tfunction onClick(event: MouseEvent) {\n\t\tif (!element.contains(event.target as Node)) {\n\t\t\tcallbackFunction();\n\t\t}\n\t}\n\n\tdocument.body.addEventListener(\"click\", onClick);\n\n\treturn {\n\t\tupdate(newCallbackFunction: () => void) {\n\t\t\tcallbackFunction = newCallbackFunction;\n\t\t},\n\t\tdestroy() {\n\t\t\tdocument.body.removeEventListener(\"click\", onClick);\n\t\t},\n\t};\n}\n"
  },
  {
    "path": "src/lib/actions/snapScrollToBottom.ts",
    "content": "import { navigating } from \"$app/state\";\nimport { tick } from \"svelte\";\n\n// Threshold to determine if user is \"at bottom\" - larger value prevents false detachment\nconst BOTTOM_THRESHOLD = 50;\nconst USER_SCROLL_DEBOUNCE_MS = 150;\nconst PROGRAMMATIC_SCROLL_GRACE_MS = 100;\nconst TOUCH_DETACH_THRESHOLD_PX = 10;\n\ninterface ScrollDependency {\n\tsignal: unknown;\n\tforceReattach?: number;\n}\n\ntype MaybeScrollDependency = ScrollDependency | unknown;\n\nconst getForceReattach = (value: MaybeScrollDependency): number => {\n\tif (typeof value === \"object\" && value !== null && \"forceReattach\" in value) {\n\t\treturn (value as ScrollDependency).forceReattach ?? 0;\n\t}\n\treturn 0;\n};\n\n/**\n * Auto-scroll action that snaps to bottom while respecting user scroll intent.\n *\n * Key behaviors:\n * 1. Uses wheel/touch events to detect actual user intent\n * 2. Uses IntersectionObserver on a sentinel element to reliably detect \"at bottom\" state\n * 3. Larger threshold to prevent edge-case false detachments\n *\n * @param node element to snap scroll to bottom\n * @param dependency pass in { signal, forceReattach } - signal triggers scroll updates,\n *                   forceReattach (counter) forces re-attachment when incremented\n */\nexport const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDependency) => {\n\t// --- State ----------------------------------------------------------------\n\n\t// Track whether user has intentionally scrolled away from bottom\n\tlet isDetached = false;\n\n\t// Track the last forceReattach value to detect changes\n\tlet lastForceReattach = getForceReattach(dependency);\n\n\t// Track if user is actively scrolling (via wheel/touch)\n\tlet userScrolling = false;\n\tlet userScrollTimeout: ReturnType<typeof setTimeout> | undefined;\n\n\t// Track programmatic scrolls to avoid treating them as user scrolls\n\tlet isProgrammaticScroll = false;\n\tlet lastProgrammaticScrollTime = 0;\n\n\t// Track previous scroll position to detect scrollbar drags\n\tlet prevScrollTop = node.scrollTop;\n\n\t// Touch handling state\n\tlet touchStartY = 0;\n\n\t// Observers and sentinel\n\tlet resizeObserver: ResizeObserver | undefined;\n\tlet intersectionObserver: IntersectionObserver | undefined;\n\tlet sentinel: HTMLDivElement | undefined;\n\n\t// Track content height for early-return optimization during streaming\n\tlet lastScrollHeight = node.scrollHeight;\n\n\t// --- Helpers --------------------------------------------------------------\n\n\tconst clearUserScrollTimeout = () => {\n\t\tif (userScrollTimeout) {\n\t\t\tclearTimeout(userScrollTimeout);\n\t\t\tuserScrollTimeout = undefined;\n\t\t}\n\t};\n\n\tconst distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;\n\n\tconst isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD;\n\n\tconst scrollToBottom = () => {\n\t\tisProgrammaticScroll = true;\n\t\tlastProgrammaticScrollTime = Date.now();\n\n\t\tnode.scrollTo({ top: node.scrollHeight });\n\n\t\tif (typeof requestAnimationFrame === \"function\") {\n\t\t\trequestAnimationFrame(() => {\n\t\t\t\tisProgrammaticScroll = false;\n\t\t\t});\n\t\t} else {\n\t\t\tisProgrammaticScroll = false;\n\t\t}\n\t};\n\n\tconst settleScrollAfterLayout = async () => {\n\t\tif (typeof requestAnimationFrame !== \"function\") return;\n\n\t\tconst raf = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));\n\n\t\tawait raf();\n\t\tif (!userScrolling && !isDetached) {\n\t\t\tscrollToBottom();\n\t\t}\n\n\t\tawait raf();\n\t\tif (!userScrolling && !isDetached) {\n\t\t\tscrollToBottom();\n\t\t}\n\t};\n\n\tconst scheduleUserScrollEndCheck = () => {\n\t\tuserScrolling = true;\n\t\tclearUserScrollTimeout();\n\n\t\tuserScrollTimeout = setTimeout(() => {\n\t\t\tuserScrolling = false;\n\n\t\t\t// If user scrolled back to bottom, re-attach\n\t\t\tif (isAtBottom()) {\n\t\t\t\tisDetached = false;\n\t\t\t}\n\n\t\t\t// Re-trigger scroll if still attached, to catch content that arrived during scrolling\n\t\t\tif (!isDetached) {\n\t\t\t\tscrollToBottom();\n\t\t\t}\n\t\t}, USER_SCROLL_DEBOUNCE_MS);\n\t};\n\n\tconst createSentinel = () => {\n\t\tsentinel = document.createElement(\"div\");\n\t\tsentinel.style.height = \"1px\";\n\t\tsentinel.style.width = \"100%\";\n\t\tsentinel.setAttribute(\"aria-hidden\", \"true\");\n\t\tsentinel.setAttribute(\"data-scroll-sentinel\", \"\");\n\n\t\t// Find the content container (first child) and append sentinel there\n\t\tconst container = node.firstElementChild;\n\t\tif (container) {\n\t\t\tcontainer.appendChild(sentinel);\n\t\t} else {\n\t\t\tnode.appendChild(sentinel);\n\t\t}\n\t};\n\n\tconst setupIntersectionObserver = () => {\n\t\tif (typeof IntersectionObserver === \"undefined\" || !sentinel) return;\n\n\t\tintersectionObserver = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\n\t\t\t\t// If sentinel is visible and user isn't actively scrolling, we're at bottom\n\t\t\t\tif (entry?.isIntersecting && !userScrolling) {\n\t\t\t\t\tisDetached = false;\n\t\t\t\t\t// Immediately scroll to catch up with any content that arrived while detached\n\t\t\t\t\tscrollToBottom();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\troot: node,\n\t\t\t\tthreshold: 0,\n\t\t\t\trootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`,\n\t\t\t}\n\t\t);\n\n\t\tintersectionObserver.observe(sentinel);\n\t};\n\n\tconst setupResizeObserver = () => {\n\t\tif (typeof ResizeObserver === \"undefined\") return;\n\n\t\tconst target = node.firstElementChild ?? node;\n\t\tresizeObserver = new ResizeObserver(() => {\n\t\t\t// Don't auto-scroll if user has detached and we're not navigating\n\t\t\tif (isDetached && !navigating.to) return;\n\t\t\t// Don't interrupt active user scrolling\n\t\t\tif (userScrolling) return;\n\n\t\t\tscrollToBottom();\n\t\t});\n\n\t\tresizeObserver.observe(target);\n\t};\n\n\t// --- Action update logic --------------------------------------------------\n\n\tconst handleForceReattach = async (newDependency: MaybeScrollDependency) => {\n\t\tconst forceReattach = getForceReattach(newDependency);\n\n\t\tif (forceReattach > lastForceReattach) {\n\t\t\tlastForceReattach = forceReattach;\n\t\t\tisDetached = false;\n\t\t\tuserScrolling = false;\n\t\t\tclearUserScrollTimeout();\n\n\t\t\tawait tick();\n\t\t\tscrollToBottom();\n\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t};\n\n\tasync function updateScroll(newDependency?: MaybeScrollDependency) {\n\t\t// 1. Explicit force re-attach\n\t\tif (newDependency && (await handleForceReattach(newDependency))) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 2. Don't scroll if user has detached and we're not navigating\n\t\tif (isDetached && !navigating.to) return;\n\n\t\t// 3. Don't scroll if user is actively scrolling\n\t\tif (userScrolling) return;\n\n\t\t// 4. Early return if already at bottom and no content change (perf optimization for streaming)\n\t\tconst currentHeight = node.scrollHeight;\n\t\tif (isAtBottom() && currentHeight === lastScrollHeight) {\n\t\t\treturn;\n\t\t}\n\t\tlastScrollHeight = currentHeight;\n\n\t\t// 5. Wait for DOM to update, then scroll and settle after layout shifts\n\t\tawait tick();\n\t\tscrollToBottom();\n\t\tawait settleScrollAfterLayout();\n\t}\n\n\t// --- Event handlers -------------------------------------------------------\n\n\t// Detect user scroll intent via wheel events (mouse/trackpad)\n\tconst handleWheel = (event: WheelEvent) => {\n\t\tconst { deltaY } = event;\n\n\t\t// User is scrolling up - detach\n\t\tif (deltaY < 0) {\n\t\t\tisDetached = true;\n\t\t}\n\n\t\t// User is scrolling down - check for re-attachment immediately\n\t\t// This ensures fast re-attachment when user scrolls to bottom during fast generation\n\t\tif (deltaY > 0 && isAtBottom()) {\n\t\t\tisDetached = false;\n\t\t\tuserScrolling = false;\n\t\t\tclearUserScrollTimeout();\n\t\t\tscrollToBottom();\n\t\t\treturn;\n\t\t}\n\n\t\tscheduleUserScrollEndCheck();\n\t};\n\n\t// Detect user scroll intent via touch events (mobile)\n\tconst handleTouchStart = (event: TouchEvent) => {\n\t\ttouchStartY = event.touches[0]?.clientY ?? 0;\n\t};\n\n\tconst handleTouchMove = (event: TouchEvent) => {\n\t\tconst touchY = event.touches[0]?.clientY ?? 0;\n\t\tconst deltaY = touchStartY - touchY;\n\n\t\t// User is scrolling up (finger moving down)\n\t\tif (deltaY < -TOUCH_DETACH_THRESHOLD_PX) {\n\t\t\tisDetached = true;\n\t\t}\n\n\t\t// User is scrolling down (finger moving up) - check for re-attachment immediately\n\t\tif (deltaY > TOUCH_DETACH_THRESHOLD_PX && isAtBottom()) {\n\t\t\tisDetached = false;\n\t\t\tuserScrolling = false;\n\t\t\tclearUserScrollTimeout();\n\t\t\tscrollToBottom();\n\t\t\ttouchStartY = touchY;\n\t\t\treturn;\n\t\t}\n\n\t\tscheduleUserScrollEndCheck();\n\t\ttouchStartY = touchY;\n\t};\n\n\t// Handle scroll events to detect scrollbar usage and re-attach when at bottom\n\tconst handleScroll = () => {\n\t\tconst now = Date.now();\n\t\tconst timeSinceLastProgrammaticScroll = now - lastProgrammaticScrollTime;\n\t\tconst inGracePeriod =\n\t\t\tisProgrammaticScroll || timeSinceLastProgrammaticScroll < PROGRAMMATIC_SCROLL_GRACE_MS;\n\n\t\t// If not from wheel/touch, this is likely a scrollbar drag\n\t\tif (!userScrolling) {\n\t\t\tconst scrollingUp = node.scrollTop < prevScrollTop;\n\n\t\t\t// Always allow detach (scrolling up) - don't ignore user intent\n\t\t\tif (scrollingUp) {\n\t\t\t\tisDetached = true;\n\t\t\t}\n\n\t\t\t// Only re-attach when at bottom if NOT in grace period\n\t\t\t// (avoids false re-attach from content resize pushing scroll position)\n\t\t\tif (!inGracePeriod && isAtBottom()) {\n\t\t\t\tisDetached = false;\n\t\t\t\t// Immediately scroll to catch up with any content that arrived while detached\n\t\t\t\tscrollToBottom();\n\t\t\t}\n\t\t}\n\n\t\tprevScrollTop = node.scrollTop;\n\t};\n\n\t// --- Setup ----------------------------------------------------------------\n\n\tnode.addEventListener(\"wheel\", handleWheel, { passive: true });\n\tnode.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n\tnode.addEventListener(\"touchmove\", handleTouchMove, { passive: true });\n\tnode.addEventListener(\"scroll\", handleScroll, { passive: true });\n\n\tcreateSentinel();\n\tsetupIntersectionObserver();\n\tsetupResizeObserver();\n\n\t// Initial scroll if we have content\n\tif (dependency) {\n\t\tvoid (async () => {\n\t\t\tawait tick();\n\t\t\tscrollToBottom();\n\t\t})();\n\t}\n\n\t// --- Cleanup --------------------------------------------------------------\n\n\treturn {\n\t\tupdate: updateScroll,\n\t\tdestroy: () => {\n\t\t\tclearUserScrollTimeout();\n\n\t\t\tnode.removeEventListener(\"wheel\", handleWheel);\n\t\t\tnode.removeEventListener(\"touchstart\", handleTouchStart);\n\t\t\tnode.removeEventListener(\"touchmove\", handleTouchMove);\n\t\t\tnode.removeEventListener(\"scroll\", handleScroll);\n\n\t\t\tresizeObserver?.disconnect();\n\t\t\tintersectionObserver?.disconnect();\n\t\t\tsentinel?.remove();\n\t\t},\n\t};\n};\n"
  },
  {
    "path": "src/lib/buildPrompt.ts",
    "content": "import type { EndpointParameters } from \"./server/endpoints/endpoints\";\nimport type { BackendModel } from \"./server/models\";\n\ntype buildPromptOptions = Pick<EndpointParameters, \"messages\" | \"preprompt\"> & {\n\tmodel: BackendModel;\n};\n\nexport async function buildPrompt({\n\tmessages,\n\tmodel,\n\tpreprompt,\n}: buildPromptOptions): Promise<string> {\n\tconst filteredMessages = messages;\n\n\tif (filteredMessages[0].from === \"system\" && preprompt) {\n\t\tfilteredMessages[0].content = preprompt;\n\t}\n\n\tconst prompt = model\n\t\t.chatPromptRender({\n\t\t\tmessages: filteredMessages.map((m) => ({\n\t\t\t\t...m,\n\t\t\t\trole: m.from,\n\t\t\t})),\n\t\t\tpreprompt,\n\t\t})\n\t\t// Not super precise, but it's truncated in the model's backend anyway\n\t\t.split(\" \")\n\t\t.slice(-(model.parameters?.truncate ?? 0))\n\t\t.join(\" \");\n\n\treturn prompt;\n}\n"
  },
  {
    "path": "src/lib/components/AnnouncementBanner.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\ttitle?: string;\n\t\tclassNames?: string;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { title = \"\", classNames = \"\", children }: Props = $props();\n</script>\n\n<div class=\"flex items-center rounded-xl bg-gray-100 p-1 text-sm dark:bg-gray-800 {classNames}\">\n\t<span\n\t\tclass=\"mr-2 inline-flex items-center rounded-lg bg-gradient-to-br from-gray-300 px-2 py-1 text-xxs font-medium uppercase leading-3 text-gray-700 dark:from-gray-900 dark:text-gray-400\"\n\t\t>New</span\n\t>\n\t{title}\n\t<div class=\"ml-auto shrink-0\">\n\t\t{@render children?.()}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/BackgroundGenerationPoller.svelte",
    "content": "<script lang=\"ts\">\n\timport { browser, dev } from \"$app/environment\";\n\timport { invalidate } from \"$app/navigation\";\n\n\timport {\n\t\ttype BackgroundGeneration,\n\t\tbackgroundGenerationEntries,\n\t\tremoveBackgroundGeneration,\n\t} from \"$lib/stores/backgroundGenerations\";\n\timport { handleResponse, useAPIClient } from \"$lib/APIClient\";\n\timport { UrlDependency } from \"$lib/types/UrlDependency\";\n\timport type { Message } from \"$lib/types/Message\";\n\timport { isAssistantGenerationTerminal } from \"$lib/utils/generationState\";\n\n\tconst POLL_INTERVAL_MS = 1000;\n\tconst MAX_POLL_DURATION_MS = 3 * 60_000;\n\n\tconst client = useAPIClient();\n\tconst pollers = new Map<string, () => void>();\n\tconst inflight = new Set<string>();\n\tconst assistantSnapshots = new Map<string, string>();\n\tconst failureCounts = new Map<string, number>();\n\n\t$effect.root(() => {\n\t\tif (!browser) {\n\t\t\tpollers.clear();\n\t\t\treturn;\n\t\t}\n\n\t\tlet destroyed = false;\n\n\t\tconst log = (...args: unknown[]) => {\n\t\t\tif (dev) {\n\t\t\t\tconsole.log(\"background generation\", ...args);\n\t\t\t}\n\t\t};\n\n\t\tconst stopPoller = (id: string, reason?: string) => {\n\t\t\tconst stop = pollers.get(id);\n\t\t\tif (!stop) return;\n\n\t\t\tstop();\n\t\t\tpollers.delete(id);\n\t\t\tinflight.delete(id);\n\t\t\tassistantSnapshots.delete(id);\n\t\t\tfailureCounts.delete(id);\n\t\t\tlog(\"stop\", id, reason);\n\t\t};\n\n\t\tconst pollOnce = async (id: string) => {\n\t\t\tif (destroyed || inflight.has(id)) return;\n\n\t\t\tconst entry = backgroundGenerationEntries.find((candidate) => candidate.id === id);\n\t\t\tif (entry && Date.now() - entry.startedAt > MAX_POLL_DURATION_MS) {\n\t\t\t\tremoveBackgroundGeneration(id);\n\t\t\t\tstopPoller(id, \"timed out\");\n\t\t\t\tlog(\"timeout\", id);\n\t\t\t\tawait invalidate(UrlDependency.ConversationList);\n\t\t\t\tawait invalidate(UrlDependency.Conversation);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tinflight.add(id);\n\t\t\tlog(\"poll\", id);\n\n\t\t\ttry {\n\t\t\t\tconst response = await client.conversations({ id }).get({ query: {} });\n\t\t\t\tconst conversation = handleResponse(response) as {\n\t\t\t\t\tmessages?: Message[];\n\t\t\t\t} | null;\n\t\t\t\tconst messages: Message[] = conversation?.messages ?? [];\n\t\t\t\tconst lastAssistant = [...messages]\n\t\t\t\t\t.reverse()\n\t\t\t\t\t.find((message: Message) => message.from === \"assistant\");\n\n\t\t\t\tconst isTerminal = isAssistantGenerationTerminal(lastAssistant);\n\n\t\t\t\tconst snapshot = lastAssistant\n\t\t\t\t\t? JSON.stringify({\n\t\t\t\t\t\t\tid: lastAssistant.id,\n\t\t\t\t\t\t\tupdatedAt: lastAssistant.updatedAt,\n\t\t\t\t\t\t\tcontentLength: lastAssistant.content?.length ?? 0,\n\t\t\t\t\t\t\tupdatesLength: lastAssistant.updates?.length ?? 0,\n\t\t\t\t\t\t})\n\t\t\t\t\t: \"__none__\";\n\t\t\t\tconst previousSnapshot = assistantSnapshots.get(id);\n\t\t\t\tlet shouldInvalidateConversation = false;\n\n\t\t\t\tif (lastAssistant) {\n\t\t\t\t\tassistantSnapshots.set(id, snapshot);\n\t\t\t\t\tif (snapshot !== previousSnapshot) {\n\t\t\t\t\t\tshouldInvalidateConversation = true;\n\t\t\t\t\t}\n\t\t\t\t} else if (assistantSnapshots.has(id)) {\n\t\t\t\t\tassistantSnapshots.delete(id);\n\t\t\t\t\tshouldInvalidateConversation = true;\n\t\t\t\t}\n\n\t\t\t\tif (lastAssistant && isTerminal) {\n\t\t\t\t\tremoveBackgroundGeneration(id);\n\t\t\t\t\tassistantSnapshots.delete(id);\n\t\t\t\t\tfailureCounts.delete(id);\n\t\t\t\t\tshouldInvalidateConversation = true;\n\t\t\t\t\tlog(\"complete\", id, \"terminal\");\n\t\t\t\t\tawait invalidate(UrlDependency.ConversationList);\n\t\t\t\t}\n\n\t\t\t\tif (shouldInvalidateConversation) {\n\t\t\t\t\tawait invalidate(UrlDependency.Conversation);\n\t\t\t\t}\n\n\t\t\t\tfailureCounts.delete(id);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error(\"Background generation poll failed\", id, err);\n\t\t\t\tconst failures = (failureCounts.get(id) ?? 0) + 1;\n\t\t\t\tfailureCounts.set(id, failures);\n\t\t\t\tif (failures >= 3) {\n\t\t\t\t\tremoveBackgroundGeneration(id);\n\t\t\t\t\tassistantSnapshots.delete(id);\n\t\t\t\t\tfailureCounts.delete(id);\n\t\t\t\t\tlog(\"failures\", id, failures);\n\t\t\t\t\tawait invalidate(UrlDependency.ConversationList);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tinflight.delete(id);\n\t\t\t}\n\t\t};\n\n\t\tconst startPoller = (entry: BackgroundGeneration) => {\n\t\t\tif (pollers.has(entry.id)) return;\n\n\t\t\tconst intervalId = setInterval(() => {\n\t\t\t\tvoid pollOnce(entry.id);\n\t\t\t}, POLL_INTERVAL_MS);\n\n\t\t\tpollers.set(entry.id, () => clearInterval(intervalId));\n\t\t\tvoid pollOnce(entry.id);\n\t\t\tlog(\"start\", entry.id);\n\t\t};\n\n\t\t$effect(() => {\n\t\t\tconst entries = backgroundGenerationEntries;\n\n\t\t\tif (destroyed) return;\n\n\t\t\tconst activeIds = new Set(entries.map((entry) => entry.id));\n\n\t\t\tfor (const id of pollers.keys()) {\n\t\t\t\tif (!activeIds.has(id)) {\n\t\t\t\t\tstopPoller(id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor (const entry of entries) {\n\t\t\t\tstartPoller(entry);\n\t\t\t}\n\t\t});\n\n\t\treturn () => {\n\t\t\tdestroyed = true;\n\t\t\tfor (const stop of pollers.values()) stop();\n\t\t\tpollers.clear();\n\t\t\tinflight.clear();\n\t\t\tassistantSnapshots.clear();\n\t\t\tfailureCounts.clear();\n\t\t};\n\t});\n</script>\n"
  },
  {
    "path": "src/lib/components/CodeBlock.svelte",
    "content": "<script lang=\"ts\">\n\timport CopyToClipBoardBtn from \"./CopyToClipBoardBtn.svelte\";\n\timport DOMPurify from \"isomorphic-dompurify\";\n\timport HtmlPreviewModal from \"./HtmlPreviewModal.svelte\";\n\timport PlayFilledAlt from \"~icons/carbon/play-filled-alt\";\n\timport EosIconsLoading from \"~icons/eos-icons/loading\";\n\n\tinterface Props {\n\t\tcode?: string;\n\t\trawCode?: string;\n\t\tloading?: boolean;\n\t}\n\n\tlet { code = \"\", rawCode = \"\", loading = false }: Props = $props();\n\n\tlet previewOpen = $state(false);\n\n\tfunction hasStrictHtml5Doctype(input: string): boolean {\n\t\tif (!input) return false;\n\t\tconst withoutBOM = input.replace(/^\\uFEFF/, \"\");\n\t\tconst trimmed = withoutBOM.trimStart();\n\t\t// Strict HTML5 doctype: <!doctype html> with optional whitespace before >\n\t\treturn /^<!doctype\\s+html\\s*>/i.test(trimmed);\n\t}\n\n\tfunction isSvgDocument(input: string): boolean {\n\t\tconst trimmed = input.trimStart();\n\t\treturn /^(?:<\\?xml[^>]*>\\s*)?(?:<!doctype\\s+svg[^>]*>\\s*)?<svg[\\s>]/i.test(trimmed);\n\t}\n\n\tlet showPreview = $derived(hasStrictHtml5Doctype(rawCode) || isSvgDocument(rawCode));\n</script>\n\n<div class=\"group relative my-4 rounded-lg\">\n\t<div class=\"pointer-events-none sticky top-0 w-full\">\n\t\t<div\n\t\t\tclass=\"pointer-events-auto absolute right-2 top-2 flex items-center gap-1.5 md:right-3 md:top-3\"\n\t\t>\n\t\t\t{#if showPreview}\n\t\t\t\t<button\n\t\t\t\t\tclass=\"btn h-7 gap-1 rounded-lg border px-2 text-xs shadow-sm backdrop-blur transition-none hover:border-gray-500 active:shadow-inner disabled:cursor-not-allowed disabled:opacity-80 dark:border-gray-600 dark:bg-gray-600/50 dark:hover:border-gray-500\"\n\t\t\t\t\tdisabled={loading}\n\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\tif (!loading) {\n\t\t\t\t\t\t\tpreviewOpen = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\ttitle=\"Preview HTML\"\n\t\t\t\t\taria-label=\"Preview HTML\"\n\t\t\t\t>\n\t\t\t\t\t{#if loading}\n\t\t\t\t\t\t<EosIconsLoading class=\"size-3.5\" />\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<PlayFilledAlt class=\"size-3.5\" />\n\t\t\t\t\t{/if}\n\t\t\t\t\tPreview\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t\t<CopyToClipBoardBtn\n\t\t\t\ticonClassNames=\"size-3\"\n\t\t\t\tclassNames=\"btn transition-none rounded-lg border size-7 text-sm shadow-sm dark:bg-gray-600/50 backdrop-blur dark:hover:border-gray-500  active:shadow-inner dark:border-gray-600  hover:border-gray-500\"\n\t\t\t\tvalue={rawCode}\n\t\t\t/>\n\t\t</div>\n\t</div>\n\t<pre class=\"scrollbar-custom overflow-auto px-5 font-mono transition-[height]\"><code\n\t\t\t><!-- eslint-disable svelte/no-at-html-tags -->{@html DOMPurify.sanitize(code)}</code\n\t\t></pre>\n\n\t{#if previewOpen}\n\t\t<HtmlPreviewModal html={rawCode} onclose={() => (previewOpen = false)} />\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/lib/components/CopyToClipBoardBtn.svelte",
    "content": "<script lang=\"ts\">\n\timport { onDestroy } from \"svelte\";\n\timport { confirm as hapticConfirm } from \"$lib/utils/haptics\";\n\n\timport CarbonCopy from \"~icons/carbon/copy\";\n\timport Tooltip from \"./Tooltip.svelte\";\n\n\tinterface Props {\n\t\tclassNames?: string;\n\t\ticonClassNames?: string;\n\t\tvalue: string;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t\tonClick?: () => void;\n\t\tshowTooltip?: boolean;\n\t}\n\n\tlet {\n\t\tclassNames = \"\",\n\t\ticonClassNames = \"\",\n\t\tvalue,\n\t\tchildren,\n\t\tonClick,\n\t\tshowTooltip = true,\n\t}: Props = $props();\n\n\tlet isSuccess = $state(false);\n\tlet timeout: ReturnType<typeof setTimeout>;\n\n\tconst unsecuredCopy = (text: string) => {\n\t\t//Old or insecure browsers\n\n\t\tconst textArea = document.createElement(\"textarea\");\n\t\ttextArea.value = text;\n\t\tdocument.body.appendChild(textArea);\n\t\ttextArea.focus();\n\t\ttextArea.select();\n\t\tdocument.execCommand(\"copy\");\n\t\tdocument.body.removeChild(textArea);\n\n\t\treturn Promise.resolve();\n\t};\n\n\tconst copy = async (text: string) => {\n\t\tif (window.isSecureContext && navigator.clipboard) {\n\t\t\treturn navigator.clipboard.writeText(text);\n\t\t}\n\t\treturn unsecuredCopy(text);\n\t};\n\n\tconst handleClick = async () => {\n\t\ttry {\n\t\t\tawait copy(value);\n\t\t\thapticConfirm();\n\n\t\t\tisSuccess = true;\n\t\t\tif (timeout) {\n\t\t\t\tclearTimeout(timeout);\n\t\t\t}\n\t\t\ttimeout = setTimeout(() => {\n\t\t\t\tisSuccess = false;\n\t\t\t}, 1000);\n\t\t} catch (err) {\n\t\t\tconsole.error(err);\n\t\t}\n\t};\n\n\tonDestroy(() => {\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t});\n</script>\n\n<button\n\tclass={classNames}\n\ttitle={\"Copy to clipboard\"}\n\ttype=\"button\"\n\tonclick={() => {\n\t\tonClick?.();\n\t\thandleClick();\n\t}}\n>\n\t<div class=\"relative\">\n\t\t{#if children}{@render children()}{:else}\n\t\t\t<CarbonCopy class={iconClassNames} />\n\t\t{/if}\n\n\t\t{#if showTooltip}\n\t\t\t<Tooltip classNames={isSuccess ? \"opacity-100\" : \"opacity-0\"} />\n\t\t{/if}\n\t</div>\n</button>\n"
  },
  {
    "path": "src/lib/components/DeleteConversationModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport { onMount } from \"svelte\";\n\n\tinterface Props {\n\t\topen?: boolean;\n\t\ttitle?: string;\n\t\tonclose?: () => void;\n\t\tondelete?: () => void;\n\t}\n\n\tlet { open = false, title = \"\", onclose, ondelete }: Props = $props();\n\n\tlet deleteButtonEl: HTMLButtonElement | undefined = $state();\n\n\tfunction close() {\n\t\topen = false;\n\t\tonclose?.();\n\t}\n\n\tfunction confirmDelete() {\n\t\tondelete?.();\n\t\tclose();\n\t}\n\n\tonMount(() => {\n\t\tsetTimeout(() => {\n\t\t\tdeleteButtonEl?.focus();\n\t\t}, 100);\n\t});\n</script>\n\n{#if open}\n\t<Modal onclose={close} width=\"w-[90dvh] md:w-[480px]\">\n\t\t<div class=\"flex w-full flex-col gap-5 p-6\">\n\t\t\t<div class=\"flex items-start justify-between\">\n\t\t\t\t<h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200\">Delete conversation</h2>\n\t\t\t\t<button type=\"button\" class=\"group outline-none\" onclick={close} aria-label=\"Close\">\n\t\t\t\t\t<svg\n\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\tviewBox=\"0 0 32 32\"\n\t\t\t\t\t\tclass=\"size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400\"\n\t\t\t\t\t\t><path\n\t\t\t\t\t\t\td=\"M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z\"\n\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t/></svg\n\t\t\t\t\t>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<p class=\"text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\tAre you sure you want to delete \"<span class=\"font-semibold\">{title}</span>\"? This action\n\t\t\t\tcannot be undone.\n\t\t\t</p>\n\n\t\t\t<div class=\"flex items-center justify-end gap-2\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow outline-none hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600\"\n\t\t\t\t\tonclick={close}\n\t\t\t\t>\n\t\t\t\t\tCancel\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tbind:this={deleteButtonEl}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-red-600 bg-red-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 dark:border-red-500 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-400 dark:focus:ring-offset-gray-800\"\n\t\t\t\t\tonclick={confirmDelete}\n\t\t\t\t>\n\t\t\t\t\tDelete\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t</Modal>\n{/if}\n"
  },
  {
    "path": "src/lib/components/EditConversationModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport { onMount } from \"svelte\";\n\n\tinterface Props {\n\t\topen?: boolean;\n\t\ttitle?: string;\n\t\tonclose?: () => void;\n\t\tonsave?: (payload: { title: string }) => void;\n\t}\n\n\tlet { open = false, title = \"\", onclose, onsave }: Props = $props();\n\n\tlet newTitle = $state(\"\");\n\tlet inputEl: HTMLInputElement | undefined = $state();\n\n\t$effect.pre(() => {\n\t\t// keep local input in sync if parent changes title while open\n\t\tif (open) {\n\t\t\tnewTitle = title;\n\t\t}\n\t});\n\n\tfunction close() {\n\t\topen = false;\n\t\tonclose?.();\n\t}\n\n\tfunction save() {\n\t\tconst trimmed = (newTitle ?? \"\").trim();\n\t\tif (!trimmed) return;\n\t\tonsave?.({ title: trimmed });\n\t\tclose();\n\t}\n\n\tonMount(() => {\n\t\t// small delay to ensure modal mounted then focus/select\n\t\tsetTimeout(() => {\n\t\t\tinputEl?.focus();\n\t\t\tinputEl?.select();\n\t\t}, 0);\n\t});\n</script>\n\n{#if open}\n\t<Modal onclose={close} width=\"w-[90dvh] md:w-[480px]\">\n\t\t<form\n\t\t\tclass=\"flex w-full flex-col gap-5 p-6\"\n\t\t\tonsubmit={(e) => {\n\t\t\t\te.preventDefault();\n\t\t\t\tsave();\n\t\t\t}}\n\t\t>\n\t\t\t<div class=\"flex items-start justify-between\">\n\t\t\t\t<h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200\">Rename conversation</h2>\n\t\t\t\t<button type=\"button\" class=\"group\" onclick={close} aria-label=\"Close\">\n\t\t\t\t\t<svg\n\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\tviewBox=\"0 0 32 32\"\n\t\t\t\t\t\tclass=\"size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400\"\n\t\t\t\t\t\t><path\n\t\t\t\t\t\t\td=\"M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z\"\n\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t/></svg\n\t\t\t\t\t>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t<div class=\"flex flex-col gap-2\">\n\t\t\t\t<label for=\"conv-title\" class=\"text-sm text-gray-600 dark:text-gray-400\">Title</label>\n\t\t\t\t<input\n\t\t\t\t\tautocomplete=\"off\"\n\t\t\t\t\tid=\"conv-title\"\n\t\t\t\t\tbind:this={inputEl}\n\t\t\t\t\tvalue={newTitle}\n\t\t\t\t\toninput={(e) => (newTitle = (e.currentTarget as HTMLInputElement).value)}\n\t\t\t\t\tclass=\"w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-[15px] text-gray-800 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:ring-gray-700\"\n\t\t\t\t\tplaceholder=\"Enter a title\"\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<div class=\"flex items-center justify-end gap-2\">\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600\"\n\t\t\t\t\tonclick={close}\n\t\t\t\t>\n\t\t\t\t\tCancel\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-gray-900 bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white\"\n\t\t\t\t\tdisabled={!newTitle?.trim()}\n\t\t\t\t>\n\t\t\t\t\tSave\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</form>\n\t</Modal>\n{/if}\n"
  },
  {
    "path": "src/lib/components/ExpandNavigation.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tisCollapsed: boolean;\n\t\tonClick: () => void;\n\t\tclassNames: string;\n\t}\n\n\tlet { isCollapsed, classNames, onClick }: Props = $props();\n</script>\n\n<button\n\tonclick={onClick}\n\ttitle={isCollapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n\tclass=\"{classNames} group flex h-16 w-6 flex-col items-center justify-center -space-y-1 outline-none *:h-3 *:w-1 *:rounded-full *:hover:bg-gray-400 dark:*:hover:bg-gray-400 max-md:hidden {!isCollapsed\n\t\t? '*:bg-gray-300/70 dark:*:bg-gray-600'\n\t\t: '*:bg-gray-300/70 dark:*:bg-gray-600'}\"\n\tname=\"sidebar-toggle\"\n\taria-label=\"Toggle sidebar navigation\"\n>\n\t<div class={!isCollapsed ? \"group-hover:rotate-[20deg]\" : \"group-hover:-rotate-[20deg]\"}></div>\n\t<div class={!isCollapsed ? \"group-hover:-rotate-[20deg]\" : \"group-hover:rotate-[20deg]\"}></div>\n</button>\n"
  },
  {
    "path": "src/lib/components/HoverTooltip.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel?: string;\n\t\tposition?: \"top\" | \"bottom\" | \"left\" | \"right\";\n\t\tTooltipClassNames?: string;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { label = \"\", position = \"bottom\", TooltipClassNames = \"\", children }: Props = $props();\n\n\tconst positionClasses = {\n\t\ttop: \"bottom-full mb-2\",\n\t\tbottom: \"top-full mt-2\",\n\t\tleft: \"right-full mr-2 top-1/2 -translate-y-1/2\",\n\t\tright: \"left-full ml-2 top-1/2 -translate-y-1/2\",\n\t};\n</script>\n\n<div class=\"group/tooltip inline-block md:relative\">\n\t{@render children?.()}\n\n\t<div\n\t\tclass=\"\n\t\tinvisible\n\t\tabsolute\n\t\tz-10\n\t\tw-64\n\t\twhitespace-normal\n\t\trounded-md\n\t\tbg-black\n\t\tp-2\n\t\ttext-center\n\t\ttext-white\n\t\tgroup-hover/tooltip:visible\n\t\tgroup-active/tooltip:visible\n\t\tmax-sm:left-1/2\n\t\tmax-sm:-translate-x-1/2\n\t\t{positionClasses[position]}\n\t\t{TooltipClassNames}\n\t  \"\n\t>\n\t\t{label}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/HtmlPreviewModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"./Modal.svelte\";\n\timport { onMount, onDestroy } from \"svelte\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport { pendingChatInput } from \"$lib/stores/pendingChatInput\";\n\n\tinterface Props {\n\t\thtml: string;\n\t\tonclose?: () => void;\n\t}\n\n\tlet { html, onclose }: Props = $props();\n\n\tlet iframeEl: HTMLIFrameElement | undefined = $state();\n\tlet channel = $state(`preview_${Math.random().toString(36).slice(2)}`);\n\tlet errors: { message: string; stack?: string }[] = $state([]);\n\n\tfunction buildSrcdoc(content: string, channel: string): string {\n\t\tconst trimmed = content.trimStart();\n\t\tconst svgPattern = /^(?:<\\?xml[^>]*>\\s*)?(?:<!doctype\\s+svg[^>]*>\\s*)?<svg[\\s>]/i;\n\t\tconst baseTag = '<base target=\"_blank\">';\n\t\tconst disabledLinkStyles = `<style>\n\t\t\ta[data-chatui-link-disabled] {}\n\t\t</style>`;\n\t\tconst endScriptTag = \"</scr\" + \"ipt>\";\n\t\tconst errorHook = `\\n<script>\\n(function(){\\n  function send(detail){\\n    try{ parent.postMessage({ type: 'chatui.preview.error', channel: '${channel}', detail: detail }, '*'); }catch(e){}\\n  }\\n  function markDisabled(anchor){\\n    if (!anchor || anchor.dataset.chatuiLinkDisabled === 'true') return;\\n    anchor.dataset.chatuiLinkDisabled = 'true';\\n    var note = 'Link disabled in preview';\\n    var title = anchor.getAttribute('title');\\n    if (!title) {\\n      anchor.setAttribute('title', note);\\n    } else if (title.indexOf(note) === -1) {\\n      anchor.setAttribute('title', title + ' — ' + note);\\n    }\\n  }\\n  function disableAnchors(scope){\\n    try {\\n      var root = scope && scope.querySelectorAll ? scope : document;\\n      var anchors = root.querySelectorAll ? root.querySelectorAll('a') : [];\\n      for (var i = 0; i < anchors.length; i++) {\\n        markDisabled(anchors[i]);\\n      }\\n    } catch (err) {}\\n  }\\n  function nearestAnchor(node){\\n    while (node && node !== document) {\\n      if (node.tagName && node.tagName.toLowerCase() === 'a') return node;\\n      node = node.parentNode;\\n    }\\n    return null;\\n  }\\n  function intercept(ev){\\n    var anchor = nearestAnchor(ev.target);\\n    if (!anchor) return;\\n    markDisabled(anchor);\\n    ev.preventDefault();\\n    ev.stopPropagation();\\n  }\\n  disableAnchors();\\n  if (document.readyState === 'loading') {\\n    document.addEventListener('DOMContentLoaded', function(){ disableAnchors(); });\\n  } else {\\n    setTimeout(function(){ disableAnchors(); }, 0);\\n  }\\n  if (window.MutationObserver) {\\n    var observer = new MutationObserver(function(mutations){\\n      for (var i = 0; i < mutations.length; i++) {\\n        var nodes = mutations[i].addedNodes;\\n        for (var j = 0; j < nodes.length; j++) {\\n          var node = nodes[j];\\n          if (!node || node.nodeType !== 1) continue;\\n          if (node.tagName && node.tagName.toLowerCase() === 'a') {\\n            markDisabled(node);\\n          } else {\\n            disableAnchors(node);\\n          }\\n        }\\n      }\\n    });\\n    observer.observe(document.documentElement, { childList: true, subtree: true });\\n  }\\n  window.addEventListener('click', intercept, true);\\n  window.addEventListener('auxclick', intercept, true);\\n  window.addEventListener('keydown', function(ev){\\n    if (ev.key === 'Enter' || ev.key === ' ') {\\n      intercept(ev);\\n    }\\n  }, true);\\n  window.addEventListener('error', function(ev){\\n    var msg = ev && ev.message ? ev.message : 'Script error';\\n    var stack = ev && ev.error && ev.error.stack ? ev.error.stack : undefined;\\n    send({ message: msg, stack: stack });\\n  });\\n  window.addEventListener('unhandledrejection', function(ev){\\n    var r = ev && ev.reason;\\n    var msg = (typeof r === 'string') ? r : (r && r.message) ? r.message : 'Unhandled promise rejection';\\n    var stack = r && r.stack ? r.stack : undefined;\\n    send({ message: msg, stack: stack });\\n  });\\n})();\\n${endScriptTag}`;\n\n\t\tif (svgPattern.test(trimmed)) {\n\t\t\tconst svgContent = trimmed\n\t\t\t\t.replace(/^(<\\?xml[^>]*>\\s*)/i, \"\")\n\t\t\t\t.replace(/^(<!doctype[^>]*>\\s*)/i, \"\");\n\t\t\treturn `<!doctype html><html><head>${baseTag}${disabledLinkStyles}${errorHook}</head><body>${svgContent}</body></html>`;\n\t\t}\n\n\t\tconst headMatch = content.match(/<head[^>]*>/i);\n\t\tif (headMatch) {\n\t\t\treturn content.replace(headMatch[0], headMatch[0] + baseTag + disabledLinkStyles + errorHook);\n\t\t}\n\t\tconst htmlTagMatch = content.match(/<html[^>]*>/i);\n\t\tif (htmlTagMatch) {\n\t\t\treturn content.replace(\n\t\t\t\thtmlTagMatch[0],\n\t\t\t\thtmlTagMatch[0] + \"\\n<head>\" + baseTag + disabledLinkStyles + errorHook + \"</head>\"\n\t\t\t);\n\t\t}\n\t\tconst doctypeMatch = content.match(/<!doctype[^>]*>/i);\n\t\tif (doctypeMatch) {\n\t\t\tconst idx = content.indexOf(doctypeMatch[0]) + doctypeMatch[0].length;\n\t\t\treturn (\n\t\t\t\tcontent.slice(0, idx) +\n\t\t\t\t\"\\n<head>\" +\n\t\t\t\tbaseTag +\n\t\t\t\tdisabledLinkStyles +\n\t\t\t\terrorHook +\n\t\t\t\t\"</head>\" +\n\t\t\t\tcontent.slice(idx)\n\t\t\t);\n\t\t}\n\t\treturn \"<head>\" + baseTag + disabledLinkStyles + errorHook + \"</head>\\n\" + content;\n\t}\n\n\tlet srcdoc = $derived(buildSrcdoc(html, channel));\n\n\ttype PreviewMessage = {\n\t\ttype: string;\n\t\tchannel: string;\n\t\tdetail?: { message?: unknown; stack?: string };\n\t};\n\n\tfunction onMessage(ev: MessageEvent) {\n\t\tif (!iframeEl || ev.source !== iframeEl.contentWindow) return;\n\t\tconst raw = ev.data as unknown;\n\t\tif (!raw || typeof raw !== \"object\") return;\n\t\tconst data = raw as Partial<PreviewMessage>;\n\t\tif (data.type !== \"chatui.preview.error\" || data.channel !== channel) return;\n\t\tconst detail = (data.detail ?? {}) as { message?: unknown; stack?: string };\n\t\terrors = [...errors, { message: String(detail.message ?? \"Error\"), stack: detail.stack }];\n\t}\n\n\tonMount(() => {\n\t\twindow.addEventListener(\"message\", onMessage);\n\t});\n\tonDestroy(() => {\n\t\twindow.removeEventListener(\"message\", onMessage);\n\t});\n\n\tfunction composeText(): string {\n\t\tconst lines = errors.map((e, i) => `${i + 1}. ${e.message}${e.stack ? `\\n${e.stack}` : \"\"}`);\n\t\tconst summary = lines[0] ?? \"Unknown error\";\n\t\treturn errors.length > 1\n\t\t\t? `it's not working: ${summary} (+${errors.length - 1} more) - can you fix it?`\n\t\t\t: `it's not working: ${summary} - can you fix it?`;\n\t}\n\n\tfunction handleKeydown(event: KeyboardEvent) {\n\t\t// Close preview on ESC key\n\t\tif (event.key === \"Escape\") {\n\t\t\tevent.preventDefault();\n\t\t\tonclose?.();\n\t\t}\n\t}\n</script>\n\n<svelte:window on:keydown={handleKeydown} />\n\n<Modal\n\twidth=\"max-w-none max-h-none w-[100dvw] h-[100dvh] !rounded-none\"\n\tonclose={() => onclose?.()}\n>\n\t<div class=\"relative h-[100dvh] w-[100dvw]\">\n\t\t<iframe\n\t\t\tbind:this={iframeEl}\n\t\t\ttitle=\"HTML Preview\"\n\t\t\tclass=\"h-full w-full\"\n\t\t\tsandbox=\"allow-scripts allow-popups\"\n\t\t\treferrerpolicy=\"no-referrer\"\n\t\t\t{srcdoc}\n\t\t></iframe>\n\n\t\t<!-- Close button with visible container -->\n\t\t<button\n\t\t\tclass=\"btn fixed right-6 top-4 z-50 flex h-7 items-center gap-1 rounded-lg border border-gray-500/60 bg-gray-800 px-2 text-xs text-white shadow-sm backdrop-blur transition-none hover:border-gray-500 hover:bg-gray-700 active:shadow-inner\"\n\t\t\ttitle=\"Close preview (Esc)\"\n\t\t\tonclick={() => onclose?.()}\n\t\t>\n\t\t\t<CarbonClose class=\"size-3.5\" />\n\t\t\tClose preview\n\t\t</button>\n\n\t\t{#if errors.length > 0}\n\t\t\t<button\n\t\t\t\tclass=\"btn fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border-2 border-red-500/60 bg-red-800/90 px-4 py-1.5 text-sm text-white shadow-lg\"\n\t\t\t\ttitle=\"Send error to chat\"\n\t\t\t\tonclick={() => {\n\t\t\t\t\tpendingChatInput.set(composeText());\n\t\t\t\t\tonclose?.();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<span>Error caught ({errors.length})</span>\n\t\t\t</button>\n\t\t{/if}\n\t</div>\n</Modal>\n"
  },
  {
    "path": "src/lib/components/InfiniteScroll.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from \"svelte\";\n\tinterface Props {\n\t\tonvisible?: () => void;\n\t}\n\n\tlet { onvisible }: Props = $props();\n\n\tlet loader: HTMLDivElement | undefined = $state();\n\tlet observer: IntersectionObserver;\n\tlet intervalId: ReturnType<typeof setInterval> | undefined;\n\n\tonMount(() => {\n\t\tif (!loader) {\n\t\t\treturn;\n\t\t}\n\n\t\tobserver = new IntersectionObserver((entries) => {\n\t\t\tentries.forEach((entry) => {\n\t\t\t\tif (entry.isIntersecting) {\n\t\t\t\t\t// Clear any existing interval\n\t\t\t\t\tif (intervalId) {\n\t\t\t\t\t\tclearInterval(intervalId);\n\t\t\t\t\t}\n\t\t\t\t\t// Start new interval that dispatches every 250ms\n\t\t\t\t\tintervalId = setInterval(() => {\n\t\t\t\t\t\tonvisible?.();\n\t\t\t\t\t}, 250);\n\t\t\t\t} else {\n\t\t\t\t\t// Clear interval when not intersecting\n\t\t\t\t\tif (intervalId) {\n\t\t\t\t\t\tclearInterval(intervalId);\n\t\t\t\t\t\tintervalId = undefined;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\tobserver.observe(loader);\n\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t\tif (intervalId) {\n\t\t\t\tclearInterval(intervalId);\n\t\t\t}\n\t\t};\n\t});\n</script>\n\n<div bind:this={loader} class=\"h-2\"></div>\n"
  },
  {
    "path": "src/lib/components/MobileNav.svelte",
    "content": "<script lang=\"ts\" module>\n\tlet isOpen = $state(false);\n\n\texport function closeMobileNav() {\n\t\tisOpen = false;\n\t}\n\n\texport function openMobileNav() {\n\t\tisOpen = true;\n\t}\n</script>\n\n<script lang=\"ts\">\n\timport { browser } from \"$app/environment\";\n\timport { beforeNavigate } from \"$app/navigation\";\n\timport { onMount, onDestroy } from \"svelte\";\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\timport IconNew from \"$lib/components/icons/IconNew.svelte\";\n\timport IconShare from \"$lib/components/icons/IconShare.svelte\";\n\timport IconBurger from \"$lib/components/icons/IconBurger.svelte\";\n\timport { Spring } from \"svelte/motion\";\n\timport { shareModal } from \"$lib/stores/shareModal\";\n\timport { loading } from \"$lib/stores/loading\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport { tap } from \"$lib/utils/haptics\";\n\n\tinterface Props {\n\t\ttitle: string | undefined;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { title = $bindable(), children }: Props = $props();\n\n\tlet closeEl: HTMLButtonElement | undefined = $state();\n\tlet openEl: HTMLButtonElement | undefined = $state();\n\n\tconst isHuggingChat = $derived(Boolean(page.data?.publicConfig?.isHuggingChat));\n\tconst canShare = $derived(\n\t\tisHuggingChat &&\n\t\t\t!$loading &&\n\t\t\tBoolean(page.params?.id) &&\n\t\t\tpage.route.id?.startsWith(\"/conversation/\")\n\t);\n\n\t// Define the width for the drawer (less than 100% to create the gap)\n\tconst drawerWidthPercentage = 85;\n\n\t$effect(() => {\n\t\ttitle ??= \"New Chat\";\n\t});\n\n\tbeforeNavigate(() => {\n\t\tisOpen = false;\n\t});\n\n\tlet shouldFocusClose = $derived(isOpen && closeEl);\n\tlet shouldRefocusOpen = $derived(!isOpen && browser && document.activeElement === closeEl);\n\n\t$effect(() => {\n\t\tif (shouldFocusClose) {\n\t\t\tcloseEl?.focus();\n\t\t} else if (shouldRefocusOpen) {\n\t\t\topenEl?.focus();\n\t\t}\n\t});\n\n\t// Function to close the drawer when background is tapped\n\tfunction closeDrawer() {\n\t\tisOpen = false;\n\t}\n\n\t// Swipe gesture support for opening/closing the nav with live feedback\n\t// Thresholds from vaul drawer library\n\tconst VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction\n\tconst DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction\n\n\tlet touchstart: Touch | null = null;\n\tlet lastTouchX: number | null = null;\n\tlet dragStartTime: number = 0;\n\tlet isDragging = $state(false);\n\tlet dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)\n\tlet dragStartedOpen = false;\n\n\t// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll\n\tlet directionLock: \"horizontal\" | \"vertical\" | null = null;\n\tlet potentialDrag = false;\n\n\t// Spring target: follows dragOffset during drag, follows isOpen after drag ends\n\tconst springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);\n\tconst tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });\n\n\tfunction onTouchStart(e: TouchEvent) {\n\t\t// Ignore touch events when a modal is open (app is inert)\n\t\tif (document.getElementById(\"app\")?.hasAttribute(\"inert\")) return;\n\n\t\tconst touch = e.changedTouches[0];\n\t\ttouchstart = touch;\n\t\tdragStartTime = Date.now();\n\t\tdirectionLock = null;\n\n\t\tconst drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);\n\t\tconst touchOnDrawer = isOpen && touch.clientX < drawerWidth;\n\n\t\t// Check if touch is on an interactive element (don't block taps on buttons/links)\n\t\tconst target = e.target as HTMLElement;\n\t\tconst isInteractive = target.closest(\"button, a, input, [role='button']\");\n\n\t\t// Potential drag scenarios - never start isDragging until direction is locked\n\t\t// Exception: overlay tap (no scroll content, so no direction conflict)\n\t\tif (!isOpen && touch.clientX < 40) {\n\t\t\t// Opening gesture - wait for direction lock before starting drag\n\t\t\t// Prevent Safari's back navigation gesture on iOS (but not on interactive elements)\n\t\t\tif (!isInteractive) {\n\t\t\t\te.preventDefault();\n\t\t\t}\n\t\t\tpotentialDrag = true;\n\t\t\tdragStartedOpen = false;\n\t\t} else if (isOpen && !touchOnDrawer) {\n\t\t\t// Touch on overlay - can start immediately (no scroll conflict)\n\t\t\tpotentialDrag = true;\n\t\t\tisDragging = true;\n\t\t\tdragStartedOpen = true;\n\t\t\tdragOffset = 0;\n\t\t\tdirectionLock = \"horizontal\";\n\t\t} else if (isOpen && touchOnDrawer) {\n\t\t\t// Touch on drawer content - wait for direction lock\n\t\t\tpotentialDrag = true;\n\t\t\tdragStartedOpen = true;\n\t\t}\n\t}\n\n\tfunction onTouchMove(e: TouchEvent) {\n\t\tif (!touchstart || !potentialDrag) return;\n\n\t\tconst touch = e.changedTouches[0];\n\t\tconst deltaX = touch.clientX - touchstart.clientX;\n\t\tconst deltaY = touch.clientY - touchstart.clientY;\n\n\t\t// Determine direction lock if not yet decided\n\t\tif (directionLock === null) {\n\t\t\tconst absX = Math.abs(deltaX);\n\t\t\tconst absY = Math.abs(deltaY);\n\n\t\t\tif (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {\n\t\t\t\tif (absX > absY) {\n\t\t\t\t\t// Horizontal movement - commit to drawer drag\n\t\t\t\t\tdirectionLock = \"horizontal\";\n\t\t\t\t\tisDragging = true;\n\t\t\t\t\tdragOffset = dragStartedOpen ? 0 : -100;\n\t\t\t\t} else {\n\t\t\t\t\t// Vertical movement - abort potential drag, let content scroll\n\t\t\t\t\tdirectionLock = \"vertical\";\n\t\t\t\t\tpotentialDrag = false;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tif (directionLock !== \"horizontal\") return;\n\n\t\tconst drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);\n\n\t\tif (dragStartedOpen) {\n\t\t\tdragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));\n\t\t} else {\n\t\t\tdragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));\n\t\t}\n\n\t\tlastTouchX = touch.clientX;\n\t}\n\n\tfunction onTouchEnd(e: TouchEvent) {\n\t\tif (!potentialDrag) return;\n\n\t\tif (!isDragging || !touchstart) {\n\t\t\tresetDragState();\n\t\t\treturn;\n\t\t}\n\n\t\tconst touch = e.changedTouches[0];\n\t\tconst timeTaken = Date.now() - dragStartTime;\n\t\tconst distMoved = touch.clientX - touchstart.clientX;\n\t\tconst velocity = Math.abs(distMoved) / timeTaken;\n\n\t\t// Determine snap direction based on velocity first, then final movement direction\n\t\tif (velocity > VELOCITY_THRESHOLD) {\n\t\t\tisOpen = distMoved > 0;\n\t\t} else {\n\t\t\t// For slow drags, use the final movement direction (allows \"change of mind\")\n\t\t\tconst finalDirection = lastTouchX !== null ? touch.clientX - lastTouchX : distMoved;\n\t\t\tisOpen = finalDirection > 0;\n\t\t}\n\n\t\ttap();\n\t\tresetDragState();\n\t}\n\n\tfunction onTouchCancel() {\n\t\tif (isDragging) {\n\t\t\tisOpen = dragStartedOpen;\n\t\t}\n\t\tresetDragState();\n\t}\n\n\tfunction resetDragState() {\n\t\tisDragging = false;\n\t\tpotentialDrag = false;\n\t\ttouchstart = null;\n\t\tlastTouchX = null;\n\t\tdirectionLock = null;\n\t}\n\n\tonMount(() => {\n\t\t// touchstart needs passive: false to allow preventDefault() for Safari back gesture\n\t\twindow.addEventListener(\"touchstart\", onTouchStart, { passive: false });\n\t\twindow.addEventListener(\"touchmove\", onTouchMove, { passive: true });\n\t\twindow.addEventListener(\"touchend\", onTouchEnd, { passive: true });\n\t\twindow.addEventListener(\"touchcancel\", onTouchCancel, { passive: true });\n\t});\n\n\tonDestroy(() => {\n\t\tif (browser) {\n\t\t\twindow.removeEventListener(\"touchstart\", onTouchStart);\n\t\t\twindow.removeEventListener(\"touchmove\", onTouchMove);\n\t\t\twindow.removeEventListener(\"touchend\", onTouchEnd);\n\t\t\twindow.removeEventListener(\"touchcancel\", onTouchCancel);\n\t\t}\n\t});\n</script>\n\n<nav\n\tclass=\"mx-4 mt-4 flex h-12 items-center justify-between rounded-b-xl border-b bg-gray-50 px-3 dark:border-gray-800 dark:bg-gray-800/30 dark:shadow-xl max-md:rounded-xl max-md:border md:hidden\"\n>\n\t<button\n\t\ttype=\"button\"\n\t\tclass=\"-ml-3 flex size-12 shrink-0 items-center justify-center text-lg\"\n\t\tonclick={() => (isOpen = true)}\n\t\taria-label=\"Open menu\"\n\t\tbind:this={openEl}><IconBurger /></button\n\t>\n\t<div class=\"flex h-full items-center justify-center overflow-hidden\">\n\t\t{#if page.params?.id}\n\t\t\t<span class=\"max-w-full truncate px-4 first-letter:uppercase\" data-testid=\"chat-title\"\n\t\t\t\t>{title}</span\n\t\t\t>\n\t\t{/if}\n\t</div>\n\t<div class=\"-mr-3 flex items-center\">\n\t\t{#if isHuggingChat}\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"flex h-12 w-6 shrink-0 items-center justify-center text-lg\"\n\t\t\t\tdisabled={!canShare}\n\t\t\t\tonclick={() => {\n\t\t\t\t\tif (!canShare) return;\n\t\t\t\t\tshareModal.open();\n\t\t\t\t}}\n\t\t\t\taria-label=\"Share conversation\"\n\t\t\t>\n\t\t\t\t<IconShare classNames={!canShare ? \"opacity-40\" : \"\"} />\n\t\t\t</button>\n\t\t{/if}\n\t\t<a\n\t\t\thref=\"{base}/\"\n\t\t\tclass=\"flex size-12 shrink-0 items-center justify-center text-lg\"\n\t\t\tonclick={(e) => {\n\t\t\t\tif (requireAuthUser()) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<IconNew />\n\t\t</a>\n\t</div>\n</nav>\n\n<!-- Mobile drawer overlay - shows when drawer is open or dragging -->\n{#if isOpen || isDragging}\n\t<button\n\t\ttype=\"button\"\n\t\tclass=\"fixed inset-0 z-20 cursor-default bg-black/30 md:hidden\"\n\t\tstyle=\"opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;\"\n\t\tonclick={closeDrawer}\n\t\taria-label=\"Close mobile navigation\"\n\t></button>\n{/if}\n\n<nav\n\tstyle=\"transform: translateX({isDragging\n\t\t? dragOffset\n\t\t: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;\"\n\tclass:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}\n\tclass=\"fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1\n\tgrid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden\"\n>\n\t{@render children?.()}\n</nav>\n"
  },
  {
    "path": "src/lib/components/Modal.svelte",
    "content": "<script lang=\"ts\">\n\timport { onDestroy, onMount } from \"svelte\";\n\timport { cubicOut } from \"svelte/easing\";\n\timport { fade, fly } from \"svelte/transition\";\n\timport Portal from \"./Portal.svelte\";\n\timport { browser } from \"$app/environment\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport { tap } from \"$lib/utils/haptics\";\n\n\tinterface Props {\n\t\twidth?: string;\n\t\tcloseButton?: boolean;\n\t\tdisableFly?: boolean;\n\t\t/** When false, clicking backdrop will not close the modal */\n\t\tcloseOnBackdrop?: boolean;\n\t\tonclose?: () => void;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet {\n\t\twidth = \"max-w-sm\",\n\t\tchildren,\n\t\tcloseButton = false,\n\t\tdisableFly = false,\n\t\tcloseOnBackdrop = true,\n\t\tonclose,\n\t}: Props = $props();\n\n\tlet backdropEl: HTMLDivElement | undefined = $state();\n\tlet modalEl: HTMLDivElement | undefined = $state();\n\n\tfunction handleKeydown(event: KeyboardEvent) {\n\t\t// close on ESC\n\t\tif (event.key === \"Escape\") {\n\t\t\tevent.preventDefault();\n\t\t\tonclose?.();\n\t\t}\n\t}\n\n\tfunction handleBackdropClick(event: MouseEvent) {\n\t\tif (window?.getSelection()?.toString()) {\n\t\t\treturn;\n\t\t}\n\t\tif (event.target === backdropEl && closeOnBackdrop) {\n\t\t\tonclose?.();\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\tdocument.getElementById(\"app\")?.setAttribute(\"inert\", \"true\");\n\t\tmodalEl?.focus();\n\t\ttap();\n\t\t// Ensure Escape closes even if focus isn't within modal\n\t\twindow.addEventListener(\"keydown\", handleKeydown, { capture: true });\n\t});\n\n\tonDestroy(() => {\n\t\tif (!browser) return;\n\t\tdocument.getElementById(\"app\")?.removeAttribute(\"inert\");\n\t\twindow.removeEventListener(\"keydown\", handleKeydown, { capture: true });\n\t});\n</script>\n\n<Portal>\n\t<div\n\t\trole=\"presentation\"\n\t\ttabindex=\"-1\"\n\t\tbind:this={backdropEl}\n\t\tonclick={(e) => {\n\t\t\te.stopPropagation();\n\t\t\thandleBackdropClick(e);\n\t\t}}\n\t\ttransition:fade|local={{ easing: cubicOut, duration: 300 }}\n\t\tclass=\"fixed inset-0 z-40 flex items-center justify-center bg-black/80 backdrop-blur-sm dark:bg-black/50\"\n\t>\n\t\t{#if disableFly}\n\t\t\t<div\n\t\t\t\trole=\"dialog\"\n\t\t\t\ttabindex=\"-1\"\n\t\t\t\tbind:this={modalEl}\n\t\t\t\tonkeydown={handleKeydown}\n\t\t\t\tclass={[\n\t\t\t\t\t\"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200\",\n\t\t\t\t\twidth,\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t{#if closeButton}\n\t\t\t\t\t<button class=\"absolute right-4 top-4 z-50\" onclick={() => onclose?.()}>\n\t\t\t\t\t\t<CarbonClose class=\"size-6 text-gray-700 dark:text-gray-300\" />\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t\t{@render children?.()}\n\t\t\t</div>\n\t\t{:else}\n\t\t\t<div\n\t\t\t\trole=\"dialog\"\n\t\t\t\ttabindex=\"-1\"\n\t\t\t\tbind:this={modalEl}\n\t\t\t\tonkeydown={handleKeydown}\n\t\t\t\tin:fly={{ y: 100 }}\n\t\t\t\tclass={[\n\t\t\t\t\t\"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200\",\n\t\t\t\t\twidth,\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t{#if closeButton}\n\t\t\t\t\t<button class=\"absolute right-4 top-4 z-50\" onclick={() => onclose?.()}>\n\t\t\t\t\t\t<CarbonClose class=\"size-6 text-gray-700 dark:text-gray-300\" />\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t\t{@render children?.()}\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n</Portal>\n"
  },
  {
    "path": "src/lib/components/ModelCardMetadata.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonEarth from \"~icons/carbon/earth\";\n\timport CarbonArrowUpRight from \"~icons/carbon/arrow-up-right\";\n\timport BIMeta from \"~icons/bi/meta\";\n\timport CarbonCode from \"~icons/carbon/code\";\n\timport type { Model } from \"$lib/types/Model\";\n\n\tinterface Props {\n\t\tmodel: Pick<\n\t\t\tModel,\n\t\t\t\"name\" | \"datasetName\" | \"websiteUrl\" | \"modelUrl\" | \"datasetUrl\" | \"hasInferenceAPI\"\n\t\t>;\n\t\tvariant?: \"light\" | \"dark\";\n\t}\n\n\tlet { model, variant = \"light\" }: Props = $props();\n</script>\n\n<div\n\tclass=\"flex items-center gap-5 rounded-xl bg-gray-100 px-3 py-2 text-xs sm:text-sm\n\t{variant === 'dark'\n\t\t? 'text-gray-600 dark:bg-gray-800 dark:text-gray-300'\n\t\t: 'text-gray-800 dark:bg-gray-100 dark:text-gray-600'}\"\n>\n\t<a\n\t\thref={model.modelUrl || \"https://huggingface.co/\" + model.name}\n\t\ttarget=\"_blank\"\n\t\trel=\"noreferrer\"\n\t\tclass=\"flex items-center hover:underline\"\n\t\t><CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs text-gray-400\" />\n\t\tModel\n\t\t<div class=\"max-sm:hidden\">&nbsp;page</div></a\n\t>\n\t{#if model.datasetName || model.datasetUrl}\n\t\t<a\n\t\t\thref={model.datasetUrl || \"https://huggingface.co/datasets/\" + model.datasetName}\n\t\t\ttarget=\"_blank\"\n\t\t\trel=\"noreferrer\"\n\t\t\tclass=\"flex items-center hover:underline\"\n\t\t\t><CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs text-gray-400\" />\n\t\t\tDataset\n\t\t\t<div class=\"max-sm:hidden\">&nbsp;page</div></a\n\t\t>\n\t{/if}\n\t{#if model.hasInferenceAPI}\n\t\t<a\n\t\t\thref={\"https://huggingface.co/playground?modelId=\" + model.name}\n\t\t\ttarget=\"_blank\"\n\t\t\trel=\"noreferrer\"\n\t\t\tclass=\"flex items-center hover:underline\"\n\t\t\t><CarbonCode class=\"mr-1.5 shrink-0 text-xs text-gray-400\" />\n\t\t\tAPI\n\t\t</a>\n\t{/if}\n\t{#if model.websiteUrl}\n\t\t<a\n\t\t\thref={model.websiteUrl}\n\t\t\ttarget=\"_blank\"\n\t\t\tclass=\"ml-auto flex items-center hover:underline\"\n\t\t\trel=\"noreferrer\"\n\t\t>\n\t\t\t{#if model.name.startsWith(\"meta-llama/Meta-Llama\")}\n\t\t\t\t<BIMeta class=\"mr-1.5 shrink-0 text-xs text-gray-400\" />\n\t\t\t\tBuilt with Llama\n\t\t\t{:else}\n\t\t\t\t<CarbonEarth class=\"mr-1.5 shrink-0 text-xs text-gray-400\" />\n\t\t\t\tWebsite\n\t\t\t{/if}\n\t\t</a>\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/lib/components/NavConversationItem.svelte",
    "content": "<script lang=\"ts\">\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\timport { tick } from \"svelte\";\n\n\timport CarbonTrashCan from \"~icons/carbon/trash-can\";\n\timport CarbonEdit from \"~icons/carbon/edit\";\n\timport type { ConvSidebar } from \"$lib/types/ConvSidebar\";\n\n\timport EditConversationModal from \"$lib/components/EditConversationModal.svelte\";\n\timport DeleteConversationModal from \"$lib/components/DeleteConversationModal.svelte\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\n\tinterface Props {\n\t\tconv: ConvSidebar;\n\t\treadOnly?: true;\n\t\tondeleteConversation?: (id: string) => void;\n\t\toneditConversationTitle?: (payload: { id: string; title: string }) => void;\n\t}\n\n\tlet { conv, readOnly, ondeleteConversation, oneditConversationTitle }: Props = $props();\n\n\tlet deleteOpen = $state(false);\n\tlet renameOpen = $state(false);\n\tlet inlineEditing = $state(false);\n\tlet inlineCancelled = $state(false);\n\tlet inlineTitle = $state(\"\");\n\tlet inputEl: HTMLInputElement | undefined = $state();\n\n\tasync function startInlineEdit() {\n\t\tif (readOnly || requireAuthUser()) return;\n\t\tinlineTitle = conv.title;\n\t\tinlineCancelled = false;\n\t\tinlineEditing = true;\n\t\tawait tick();\n\t\tinputEl?.focus();\n\t\tinputEl?.select();\n\t}\n\n\tfunction commitInlineEdit() {\n\t\tif (!inlineEditing || inlineCancelled) return;\n\t\tconst trimmed = inlineTitle.trim();\n\t\tinlineEditing = false;\n\t\tif (trimmed && trimmed !== conv.title) {\n\t\t\toneditConversationTitle?.({ id: conv.id.toString(), title: trimmed });\n\t\t}\n\t}\n\n\tfunction cancelInlineEdit() {\n\t\tinlineCancelled = true;\n\t\tinlineEditing = false;\n\t}\n</script>\n\n<a\n\tdata-sveltekit-noscroll\n\tdata-sveltekit-preload-data=\"tap\"\n\thref=\"{base}/conversation/{conv.id}\"\n\tclass=\"group flex h-[2.15rem] flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 max-sm:h-10\n\t\t{conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}\"\n\tonclick={(e) => {\n\t\tif (e.detail >= 2) {\n\t\t\te.preventDefault();\n\t\t\tstartInlineEdit();\n\t\t}\n\t}}\n>\n\t{#if inlineEditing}\n\t\t<input\n\t\t\tbind:this={inputEl}\n\t\t\ttype=\"text\"\n\t\t\tvalue={inlineTitle}\n\t\t\toninput={(e) => (inlineTitle = (e.currentTarget as HTMLInputElement).value)}\n\t\t\tonkeydown={(e) => {\n\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tcommitInlineEdit();\n\t\t\t\t} else if (e.key === \"Escape\") {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tcancelInlineEdit();\n\t\t\t\t}\n\t\t\t}}\n\t\t\tonblur={commitInlineEdit}\n\t\t\tonclick={(e) => e.preventDefault()}\n\t\t\tclass=\"my-0 h-full min-w-0 flex-1 truncate border-none bg-transparent p-0 text-inherit outline-none first-letter:uppercase focus:ring-0\"\n\t\t/>\n\t{:else}\n\t\t<div class=\"my-2 min-w-0 flex-1 truncate first-letter:uppercase\">\n\t\t\t<span>{conv.title}</span>\n\t\t</div>\n\t{/if}\n\n\t{#if !readOnly && !inlineEditing}\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclass=\"flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex\"\n\t\t\ttitle=\"Edit conversation title\"\n\t\t\tonclick={(e) => {\n\t\t\t\te.preventDefault();\n\t\t\t\tif (requireAuthUser()) return;\n\t\t\t\trenameOpen = true;\n\t\t\t}}\n\t\t>\n\t\t\t<CarbonEdit class=\"text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300\" />\n\t\t</button>\n\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclass=\"flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex\"\n\t\t\ttitle=\"Delete conversation\"\n\t\t\tonclick={(event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tif (requireAuthUser()) return;\n\t\t\t\tif (event.shiftKey) {\n\t\t\t\t\tondeleteConversation?.(conv.id.toString());\n\t\t\t\t} else {\n\t\t\t\t\tdeleteOpen = true;\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<CarbonTrashCan class=\"text-xs text-gray-400  hover:text-gray-500 dark:hover:text-gray-300\" />\n\t\t</button>\n\t{/if}\n</a>\n\n<!-- Edit title modal -->\n{#if renameOpen}\n\t<EditConversationModal\n\t\topen={renameOpen}\n\t\ttitle={conv.title}\n\t\tonclose={() => (renameOpen = false)}\n\t\tonsave={(payload) => {\n\t\t\trenameOpen = false;\n\t\t\toneditConversationTitle?.({ id: conv.id.toString(), title: payload.title });\n\t\t}}\n\t/>\n{/if}\n\n<!-- Delete confirmation modal -->\n{#if deleteOpen}\n\t<DeleteConversationModal\n\t\topen={deleteOpen}\n\t\ttitle={conv.title}\n\t\tonclose={() => (deleteOpen = false)}\n\t\tondelete={() => {\n\t\t\tdeleteOpen = false;\n\t\t\tondeleteConversation?.(conv.id.toString());\n\t\t}}\n\t/>\n{/if}\n"
  },
  {
    "path": "src/lib/components/NavMenu.svelte",
    "content": "<script lang=\"ts\" module>\n\texport const titles: { [key: string]: string } = {\n\t\ttoday: \"Today\",\n\t\tweek: \"This week\",\n\t\tmonth: \"This month\",\n\t\tolder: \"Older\",\n\t} as const;\n</script>\n\n<script lang=\"ts\">\n\timport { base } from \"$app/paths\";\n\n\timport Logo from \"$lib/components/icons/Logo.svelte\";\n\timport IconSun from \"$lib/components/icons/IconSun.svelte\";\n\timport IconMoon from \"$lib/components/icons/IconMoon.svelte\";\n\timport { switchTheme, subscribeToTheme } from \"$lib/switchTheme\";\n\timport { isAborted } from \"$lib/stores/isAborted\";\n\timport { onDestroy } from \"svelte\";\n\n\timport NavConversationItem from \"./NavConversationItem.svelte\";\n\timport type { LayoutData } from \"../../routes/$types\";\n\timport type { ConvSidebar } from \"$lib/types/ConvSidebar\";\n\timport type { Model } from \"$lib/types/Model\";\n\timport { page } from \"$app/state\";\n\timport InfiniteScroll from \"./InfiniteScroll.svelte\";\n\timport { CONV_NUM_PER_PAGE } from \"$lib/constants/pagination\";\n\timport { browser } from \"$app/environment\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\timport { useAPIClient, handleResponse } from \"$lib/APIClient\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport { enabledServersCount } from \"$lib/stores/mcpServers\";\n\timport { isPro } from \"$lib/stores/isPro\";\n\timport IconPro from \"$lib/components/icons/IconPro.svelte\";\n\timport MCPServerManager from \"./mcp/MCPServerManager.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\tconst client = useAPIClient();\n\n\tinterface Props {\n\t\tconversations: ConvSidebar[];\n\t\tuser: LayoutData[\"user\"];\n\t\tp?: number;\n\t\tondeleteConversation?: (id: string) => void;\n\t\toneditConversationTitle?: (payload: { id: string; title: string }) => void;\n\t}\n\n\tlet {\n\t\tconversations = $bindable(),\n\t\tuser,\n\t\tp = $bindable(0),\n\t\tondeleteConversation,\n\t\toneditConversationTitle,\n\t}: Props = $props();\n\n\tlet hasMore = $state(true);\n\n\tfunction handleNewChatClick(e: MouseEvent) {\n\t\tisAborted.set(true);\n\n\t\tif (requireAuthUser()) {\n\t\t\te.preventDefault();\n\t\t}\n\t}\n\n\tfunction handleNavItemClick(e: MouseEvent) {\n\t\tif (requireAuthUser()) {\n\t\t\te.preventDefault();\n\t\t}\n\t}\n\n\tconst dateRanges = [\n\t\tnew Date().setDate(new Date().getDate() - 1),\n\t\tnew Date().setDate(new Date().getDate() - 7),\n\t\tnew Date().setMonth(new Date().getMonth() - 1),\n\t];\n\n\tlet groupedConversations = $derived({\n\t\ttoday: conversations.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),\n\t\tweek: conversations.filter(\n\t\t\t({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]\n\t\t),\n\t\tmonth: conversations.filter(\n\t\t\t({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]\n\t\t),\n\t\tolder: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),\n\t});\n\n\tconst nModels: number = page.data.models.filter((el: Model) => !el.unlisted).length;\n\n\tasync function handleVisible() {\n\t\tp++;\n\t\tconst newConvs = await client.conversations\n\t\t\t.get({\n\t\t\t\tquery: {\n\t\t\t\t\tp,\n\t\t\t\t},\n\t\t\t})\n\t\t\t.then(handleResponse)\n\t\t\t.then((r) => r.conversations)\n\t\t\t.catch((): ConvSidebar[] => []);\n\n\t\tif (newConvs.length === 0) {\n\t\t\thasMore = false;\n\t\t}\n\n\t\tconversations = [...conversations, ...newConvs];\n\t}\n\n\t$effect(() => {\n\t\tif (conversations.length <= CONV_NUM_PER_PAGE) {\n\t\t\t// reset p to 0 if there's only one page of content\n\t\t\t// that would be caused by a data loading invalidation\n\t\t\tp = 0;\n\t\t}\n\t});\n\n\tlet isDark = $state(false);\n\tlet unsubscribeTheme: (() => void) | undefined;\n\tlet showMcpModal = $state(false);\n\n\tif (browser) {\n\t\tunsubscribeTheme = subscribeToTheme(({ isDark: nextIsDark }) => {\n\t\t\tisDark = nextIsDark;\n\t\t});\n\t}\n\n\tonDestroy(() => {\n\t\tunsubscribeTheme?.();\n\t});\n</script>\n\n<div\n\tclass=\"sticky top-0 flex flex-none touch-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0\"\n>\n\t<a\n\t\tclass=\"flex select-none items-center rounded-xl text-lg font-semibold\"\n\t\thref=\"{publicConfig.PUBLIC_ORIGIN}{base}/\"\n\t>\n\t\t<Logo classNames=\"dark:invert mr-[2px]\" />\n\t\t{publicConfig.PUBLIC_APP_NAME}\n\t</a>\n\t<a\n\t\thref={`${base}/`}\n\t\tonclick={handleNewChatClick}\n\t\tclass=\"flex rounded-lg border bg-white px-2 py-0.5 text-center shadow-sm hover:shadow-none dark:border-gray-600 dark:bg-gray-700 sm:text-smd\"\n\t\ttitle=\"Ctrl/Cmd + Shift + O\"\n\t>\n\t\tNew Chat\n\t</a>\n</div>\n\n<div\n\tclass=\"scrollbar-custom flex touch-pan-y flex-col gap-1 overflow-y-auto rounded-r-xl border border-l-0 border-gray-100 from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:border-transparent dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l\"\n>\n\t<div class=\"flex flex-col gap-0.5\">\n\t\t{#each Object.entries(groupedConversations) as [group, convs]}\n\t\t\t{#if convs.length}\n\t\t\t\t<h4 class=\"mb-1.5 mt-4 pl-0.5 text-sm text-gray-400 first:mt-0 dark:text-gray-500\">\n\t\t\t\t\t{titles[group]}\n\t\t\t\t</h4>\n\t\t\t\t{#each convs as conv}\n\t\t\t\t\t<NavConversationItem {conv} {oneditConversationTitle} {ondeleteConversation} />\n\t\t\t\t{/each}\n\t\t\t{/if}\n\t\t{/each}\n\t</div>\n\t{#if hasMore}\n\t\t<InfiniteScroll onvisible={handleVisible} />\n\t{/if}\n</div>\n<div\n\tclass=\"flex touch-none flex-col gap-1 rounded-r-xl border border-l-0 border-gray-100 p-3 text-sm dark:border-transparent md:mt-3 md:bg-gradient-to-l md:from-gray-50 md:dark:from-gray-800/30\"\n>\n\t{#if user?.username || user?.email}\n\t\t<div\n\t\t\tclass=\"group flex h-9 items-center gap-1.5 rounded-lg pl-2.5 pr-2 hover:bg-gray-100 first:hover:bg-transparent dark:hover:bg-gray-700 first:dark:hover:bg-transparent\"\n\t\t>\n\t\t\t<img\n\t\t\t\tsrc=\"https://huggingface.co/api/users/{user.username}/avatar?redirect=true\"\n\t\t\t\tclass=\"size-3.5 rounded-full border bg-gray-500 dark:border-white/40\"\n\t\t\t\talt=\"\"\n\t\t\t/>\n\t\t\t<span\n\t\t\t\tclass=\"flex flex-none shrink items-center gap-1.5 truncate pr-2 text-gray-500 dark:text-gray-400\"\n\t\t\t\t>{user?.username || user?.email}</span\n\t\t\t>\n\n\t\t\t{#if publicConfig.isHuggingChat && $isPro === false}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/subscribe/pro?from=HuggingChat\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclass=\"ml-auto flex h-[20px] items-center gap-1 px-1.5 py-0.5 text-xs text-gray-500 dark:text-gray-400\"\n\t\t\t\t>\n\t\t\t\t\t<IconPro />\n\t\t\t\t\tGet PRO\n\t\t\t\t</a>\n\t\t\t{:else if publicConfig.isHuggingChat && $isPro === true}\n\t\t\t\t<span\n\t\t\t\t\tclass=\"ml-auto flex h-[20px] items-center gap-1 px-1.5 py-0.5 text-xs text-gray-500 dark:text-gray-400\"\n\t\t\t\t>\n\t\t\t\t\t<IconPro />\n\t\t\t\t\tPRO\n\t\t\t\t</span>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n\t<a\n\t\thref=\"{base}/models\"\n\t\tclass=\"flex h-9 flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700\"\n\t\tonclick={handleNavItemClick}\n\t>\n\t\tModels\n\t\t<span\n\t\t\tclass=\"ml-auto rounded-md bg-gray-500/5 px-1.5 py-0.5 text-xs text-gray-400 dark:bg-gray-500/20 dark:text-gray-400\"\n\t\t\t>{nModels}</span\n\t\t>\n\t</a>\n\n\t{#if user?.username || user?.email}\n\t\t<button\n\t\t\tonclick={() => (showMcpModal = true)}\n\t\t\tclass=\"flex h-9 flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700\"\n\t\t>\n\t\t\tMCP Servers\n\t\t\t{#if $enabledServersCount > 0}\n\t\t\t\t<span\n\t\t\t\t\tclass=\"ml-auto rounded-md bg-blue-600/10 px-1.5 py-0.5 text-xs text-blue-600 dark:bg-blue-600/20 dark:text-blue-400\"\n\t\t\t\t>\n\t\t\t\t\t{$enabledServersCount}\n\t\t\t\t</span>\n\t\t\t{/if}\n\t\t</button>\n\t{/if}\n\n\t<span class=\"flex gap-1\">\n\t\t<a\n\t\t\thref=\"{base}/settings/application\"\n\t\t\tclass=\"flex h-9 flex-none flex-grow items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700\"\n\t\t\tonclick={handleNavItemClick}\n\t\t>\n\t\t\tSettings\n\t\t</a>\n\t\t<button\n\t\t\tonclick={() => {\n\t\t\t\tswitchTheme();\n\t\t\t}}\n\t\t\taria-label=\"Toggle theme\"\n\t\t\tclass=\"flex size-9 min-w-[1.5em] flex-none items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700\"\n\t\t>\n\t\t\t{#if browser}\n\t\t\t\t{#if isDark}\n\t\t\t\t\t<IconSun />\n\t\t\t\t{:else}\n\t\t\t\t\t<IconMoon />\n\t\t\t\t{/if}\n\t\t\t{/if}\n\t\t</button>\n\t</span>\n</div>\n\n{#if showMcpModal}\n\t<MCPServerManager onclose={() => (showMcpModal = false)} />\n{/if}\n"
  },
  {
    "path": "src/lib/components/Pagination.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from \"$app/state\";\n\timport { getHref } from \"$lib/utils/getHref\";\n\timport PaginationArrow from \"./PaginationArrow.svelte\";\n\n\tinterface Props {\n\t\tclassNames?: string;\n\t\tnumItemsPerPage: number;\n\t\tnumTotalItems: number;\n\t}\n\n\tlet { classNames = \"\", numItemsPerPage, numTotalItems }: Props = $props();\n\n\tconst ELLIPSIS_IDX = -1 as const;\n\n\tfunction getPageIndexes(pageIdx: number, nTotalPages: number) {\n\t\tlet pageIdxs: number[] = [];\n\n\t\tconst NUM_EXTRA_BUTTONS = 2; // The number of page links to show on either side of the current page link.\n\n\t\tconst minIdx = 0;\n\t\tconst maxIdx = nTotalPages - 1;\n\n\t\tpageIdxs = [pageIdx];\n\n\t\t// forward\n\t\tfor (let i = 1; i < NUM_EXTRA_BUTTONS + 1; i++) {\n\t\t\tconst newPageIdx = pageIdx + i;\n\t\t\tif (newPageIdx > maxIdx) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tpageIdxs.push(newPageIdx);\n\t\t}\n\t\tif (maxIdx - pageIdxs[pageIdxs.length - 1] > 1) {\n\t\t\tpageIdxs.push(...[ELLIPSIS_IDX, maxIdx]);\n\t\t} else if (maxIdx - pageIdxs[pageIdxs.length - 1] === 1) {\n\t\t\tpageIdxs.push(maxIdx);\n\t\t}\n\n\t\t// backward\n\t\tfor (let i = 1; i < NUM_EXTRA_BUTTONS + 1; i++) {\n\t\t\tconst newPageIdx = pageIdx - i;\n\t\t\tif (newPageIdx < minIdx) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tpageIdxs.unshift(newPageIdx);\n\t\t}\n\t\tif (pageIdxs[0] - minIdx > 1) {\n\t\t\tpageIdxs.unshift(...[minIdx, ELLIPSIS_IDX]);\n\t\t} else if (pageIdxs[0] - minIdx === 1) {\n\t\t\tpageIdxs.unshift(minIdx);\n\t\t}\n\t\treturn pageIdxs;\n\t}\n\tlet numTotalPages = $derived(Math.ceil(numTotalItems / numItemsPerPage));\n\tlet pageIndex = $derived(parseInt(page.url.searchParams.get(\"p\") ?? \"0\"));\n\tlet pageIndexes = $derived(getPageIndexes(pageIndex, numTotalPages));\n</script>\n\n{#if numTotalPages > 1}\n\t<nav>\n\t\t<ul\n\t\t\tclass=\"flex select-none items-center justify-between space-x-2 text-gray-700 dark:text-gray-300 sm:justify-center {classNames}\"\n\t\t>\n\t\t\t<li>\n\t\t\t\t<PaginationArrow\n\t\t\t\t\thref={getHref(page.url, { newKeys: { p: (pageIndex - 1).toString() } })}\n\t\t\t\t\tdirection=\"previous\"\n\t\t\t\t\tisDisabled={pageIndex - 1 < 0}\n\t\t\t\t/>\n\t\t\t</li>\n\t\t\t{#each pageIndexes as pageIdx}\n\t\t\t\t<li class=\"hidden sm:block\">\n\t\t\t\t\t<a\n\t\t\t\t\t\tclass=\"\n\t\t\t\t\t\t\trounded-lg px-2.5 py-1\n\t\t\t\t\t\t\t{pageIndex === pageIdx\n\t\t\t\t\t\t\t? 'bg-gray-50 font-semibold ring-1 ring-inset ring-gray-200 dark:bg-gray-800 dark:text-yellow-500 dark:ring-gray-700'\n\t\t\t\t\t\t\t: ''}\n\t\t\t\t\t\t\"\n\t\t\t\t\t\tclass:pointer-events-none={pageIdx === ELLIPSIS_IDX || pageIndex === pageIdx}\n\t\t\t\t\t\thref={getHref(page.url, { newKeys: { p: pageIdx.toString() } })}\n\t\t\t\t\t>\n\t\t\t\t\t\t{pageIdx === ELLIPSIS_IDX ? \"...\" : pageIdx + 1}\n\t\t\t\t\t</a>\n\t\t\t\t</li>\n\t\t\t{/each}\n\t\t\t<li>\n\t\t\t\t<PaginationArrow\n\t\t\t\t\thref={getHref(page.url, { newKeys: { p: (pageIndex + 1).toString() } })}\n\t\t\t\t\tdirection=\"next\"\n\t\t\t\t\tisDisabled={pageIndex + 1 >= numTotalPages}\n\t\t\t\t/>\n\t\t\t</li>\n\t\t</ul>\n\t</nav>\n{/if}\n"
  },
  {
    "path": "src/lib/components/PaginationArrow.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonCaretLeft from \"~icons/carbon/caret-left\";\n\timport CarbonCaretRight from \"~icons/carbon/caret-right\";\n\n\tinterface Props {\n\t\thref: string;\n\t\tdirection: \"next\" | \"previous\";\n\t\tisDisabled?: boolean;\n\t}\n\n\tlet { href, direction, isDisabled = false }: Props = $props();\n</script>\n\n<a\n\tclass=\"flex items-center rounded-lg px-2.5 py-1 hover:bg-gray-50 dark:hover:bg-gray-800 {isDisabled\n\t\t? 'pointer-events-none opacity-50'\n\t\t: ''}\"\n\t{href}\n>\n\t{#if direction === \"previous\"}\n\t\t<CarbonCaretLeft classNames=\"mr-1.5\" />\n\t\tPrevious\n\t{:else}\n\t\tNext\n\t\t<CarbonCaretRight classNames=\"ml-1.5\" />\n\t{/if}\n</a>\n"
  },
  {
    "path": "src/lib/components/Portal.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount, onDestroy } from \"svelte\";\n\tinterface Props {\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { children }: Props = $props();\n\n\tlet el: HTMLElement | undefined = $state();\n\n\tonMount(() => {\n\t\tel?.ownerDocument.body.appendChild(el);\n\t});\n\n\tonDestroy(() => {\n\t\tif (el?.parentNode) {\n\t\t\tel.parentNode.removeChild(el);\n\t\t}\n\t});\n</script>\n\n<div bind:this={el} class=\"contents\" hidden>\n\t{@render children?.()}\n</div>\n"
  },
  {
    "path": "src/lib/components/RetryBtn.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonRotate360 from \"~icons/carbon/rotate-360\";\n\n\tinterface Props {\n\t\tclassNames?: string;\n\t\tonClick?: () => void;\n\t}\n\n\tlet { classNames = \"\", onClick }: Props = $props();\n</script>\n\n<button\n\ttype=\"button\"\n\tonclick={onClick}\n\tclass=\"btn flex h-7 rounded-lg border bg-white px-2 py-1 text-sm text-gray-500 shadow-sm hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 {classNames}\"\n>\n\t<CarbonRotate360 class=\"mr-1 -translate-y-px text-[.65rem]\" /> Retry\n</button>\n"
  },
  {
    "path": "src/lib/components/ScrollToBottomBtn.svelte",
    "content": "<script lang=\"ts\">\n\timport { fade } from \"svelte/transition\";\n\timport IconChevron from \"./icons/IconChevron.svelte\";\n\n\tinterface Props {\n\t\tscrollNode: HTMLElement;\n\t\tclass?: string;\n\t}\n\n\tlet { scrollNode, class: className = \"\" }: Props = $props();\n\n\tlet visible = $state(false);\n\tlet observer: ResizeObserver | null = $state(null);\n\n\tfunction updateVisibility() {\n\t\tif (!scrollNode) return;\n\t\tvisible =\n\t\t\tMath.ceil(scrollNode.scrollTop) + 200 < scrollNode.scrollHeight - scrollNode.clientHeight;\n\t}\n\n\tfunction destroy() {\n\t\tobserver?.disconnect();\n\t\tscrollNode?.removeEventListener(\"scroll\", updateVisibility);\n\t}\n\tconst cleanup = $effect.root(() => {\n\t\t$effect(() => {\n\t\t\tif (scrollNode) {\n\t\t\t\tif (window.ResizeObserver) {\n\t\t\t\t\tobserver = new ResizeObserver(() => updateVisibility());\n\t\t\t\t\tobserver.observe(scrollNode);\n\t\t\t\t\tcleanup();\n\t\t\t\t}\n\t\t\t\tscrollNode?.addEventListener(\"scroll\", updateVisibility);\n\t\t\t}\n\t\t});\n\t\treturn () => destroy();\n\t});\n</script>\n\n{#if visible}\n\t<button\n\t\ttransition:fade={{ duration: 150 }}\n\t\tonclick={() => scrollNode.scrollTo({ top: scrollNode.scrollHeight, behavior: \"smooth\" })}\n\t\tclass=\"btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}\"\n\t\t><IconChevron classNames=\"mt-[2px]\" /></button\n\t>\n{/if}\n"
  },
  {
    "path": "src/lib/components/ScrollToPreviousBtn.svelte",
    "content": "<script lang=\"ts\">\n\timport { fade } from \"svelte/transition\";\n\timport { onDestroy, untrack } from \"svelte\";\n\timport IconChevron from \"./icons/IconChevron.svelte\";\n\n\tlet visible = $state(false);\n\tinterface Props {\n\t\tscrollNode: HTMLElement;\n\t\tclass?: string;\n\t}\n\n\tlet { scrollNode, class: className = \"\" }: Props = $props();\n\tlet observer: ResizeObserver | null = $state(null);\n\n\tfunction updateVisibility() {\n\t\tif (!scrollNode) return;\n\t\tvisible =\n\t\t\tMath.ceil(scrollNode.scrollTop) + 200 < scrollNode.scrollHeight - scrollNode.clientHeight &&\n\t\t\tscrollNode.scrollTop > 200;\n\t}\n\n\tfunction scrollToPrevious() {\n\t\tif (!scrollNode) return;\n\t\tconst messages = scrollNode.querySelectorAll(\"[data-message-id]\");\n\t\tconst scrollTop = scrollNode.scrollTop;\n\t\tlet previousMessage: Element | null = null;\n\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst messageTop =\n\t\t\t\tmessages[i].getBoundingClientRect().top +\n\t\t\t\tscrollTop -\n\t\t\t\tscrollNode.getBoundingClientRect().top;\n\t\t\tif (messageTop < scrollTop - 1) {\n\t\t\t\tpreviousMessage = messages[i];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (previousMessage) {\n\t\t\tpreviousMessage.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n\t\t}\n\t}\n\n\tfunction destroy() {\n\t\tobserver?.disconnect();\n\t\tscrollNode?.removeEventListener(\"scroll\", updateVisibility);\n\t}\n\n\tonDestroy(destroy);\n\n\t$effect(() => {\n\t\tscrollNode &&\n\t\t\tuntrack(() => {\n\t\t\t\tif (scrollNode) {\n\t\t\t\t\tdestroy();\n\n\t\t\t\t\tif (window.ResizeObserver) {\n\t\t\t\t\t\tobserver = new ResizeObserver(() => {\n\t\t\t\t\t\t\tupdateVisibility();\n\t\t\t\t\t\t});\n\t\t\t\t\t\tobserver.observe(scrollNode);\n\t\t\t\t\t}\n\t\t\t\t\tscrollNode.addEventListener(\"scroll\", updateVisibility);\n\t\t\t\t}\n\t\t\t});\n\t});\n</script>\n\n{#if visible}\n\t<button\n\t\ttransition:fade={{ duration: 150 }}\n\t\tonclick={scrollToPrevious}\n\t\tclass=\"btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}\"\n\t>\n\t\t<IconChevron classNames=\"rotate-180 mt-[2px]\" />\n\t</button>\n{/if}\n"
  },
  {
    "path": "src/lib/components/ShareConversationModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\timport CarbonLink from \"~icons/carbon/link\";\n\timport CarbonCheckmark from \"~icons/carbon/checkmark\";\n\timport EosIconsLoading from \"~icons/eos-icons/loading\";\n\timport CopyToClipBoardBtn from \"$lib/components/CopyToClipBoardBtn.svelte\";\n\timport { onMount } from \"svelte\";\n\timport { createShareLink } from \"$lib/createShareLink\";\n\n\tinterface Props {\n\t\topen?: boolean;\n\t\tonclose?: () => void;\n\t\toncopied?: () => void;\n\t}\n\n\tlet { open = false, onclose, oncopied }: Props = $props();\n\n\tlet creating = $state(false);\n\tlet createdUrl: string | null = $state(null);\n\tlet errorMsg: string | null = $state(null);\n\tlet justCopied = $state(false);\n\n\tasync function handleCreate() {\n\t\ttry {\n\t\t\tcreating = true;\n\t\t\terrorMsg = null;\n\t\t\tcreatedUrl = await createShareLink(page.params.id ?? \"\");\n\t\t} catch (e) {\n\t\t\terrorMsg = (e as Error).message || \"Could not create link\";\n\t\t} finally {\n\t\t\tcreating = false;\n\t\t}\n\t}\n\n\tfunction close() {\n\t\topen = false;\n\t\tonclose?.();\n\t}\n\n\t// If the current page is already a shared chat (7-char id), pre-fill the link\n\tonMount(async () => {\n\t\tif (page.params.id && page.params.id.length === 7) {\n\t\t\ttry {\n\t\t\t\tcreatedUrl = await createShareLink(page.params.id);\n\t\t\t} catch (e) {\n\t\t\t\t// ignore\n\t\t\t}\n\t\t}\n\t});\n\n\tfunction withLeafId(url: string | null): string | null {\n\t\tif (!url) return url;\n\t\ttry {\n\t\t\tconst leafId = localStorage.getItem(\"leafId\");\n\t\t\tif (!leafId) return url;\n\t\t\tconst u = new URL(url);\n\t\t\tu.searchParams.set(\"leafId\", leafId);\n\t\t\treturn u.toString();\n\t\t} catch (e) {\n\t\t\treturn url;\n\t\t}\n\t}\n</script>\n\n{#if open}\n\t<Modal onclose={close} width=\"w-[90dvh] md:w-[500px]\">\n\t\t<div class=\"flex w-full flex-col gap-3 p-5 sm:gap-5 sm:p-6\">\n\t\t\t<!-- Header + copy -->\n\t\t\t{#if createdUrl}\n\t\t\t\t<div class=\"flex items-start justify-between\">\n\t\t\t\t\t<div class=\"text-xl font-semibold text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\tPublic link created\n\t\t\t\t\t</div>\n\t\t\t\t\t<button type=\"button\" class=\"group\" onclick={close} aria-label=\"Close\">\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\t\tviewBox=\"0 0 32 32\"\n\t\t\t\t\t\t\tclass=\"size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\td=\"M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z\"\n\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\t\tA public link to your chat has been created.\n\t\t\t\t</div>\n\t\t\t{:else}\n\t\t\t\t<div class=\"flex items-start justify-between\">\n\t\t\t\t\t<div class=\"text-xl font-semibold text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\tShare public link to chat\n\t\t\t\t\t</div>\n\t\t\t\t\t<button type=\"button\" class=\"group\" onclick={close} aria-label=\"Close\">\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\t\tviewBox=\"0 0 32 32\"\n\t\t\t\t\t\t\tclass=\"size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\td=\"M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z\"\n\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\t\tAny messages you add after sharing stay private.\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t{#if errorMsg}\n\t\t\t\t<div\n\t\t\t\t\tclass=\"rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-700 dark:bg-red-500/10 dark:text-red-300\"\n\t\t\t\t>\n\t\t\t\t\t{errorMsg}\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<!-- URL row -->\n\t\t\t<div\n\t\t\t\tclass=\"flex h-12 items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-gray-50 p-2.5 dark:border-gray-700 dark:bg-gray-800\"\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tclass=\"w-full truncate bg-transparent text-[15px] text-gray-700 outline-none placeholder:text-gray-400 dark:text-gray-200 dark:placeholder:text-gray-500 max-sm:text-sm\"\n\t\t\t\t\treadonly\n\t\t\t\t\tvalue={createdUrl ??\n\t\t\t\t\t\t`${page.data.publicConfig.PUBLIC_SHARE_PREFIX || `${page.data.publicConfig.PUBLIC_ORIGIN || page.url.origin}${base}`}/r/...`}\n\t\t\t\t/>\n\n\t\t\t\t{#if createdUrl}\n\t\t\t\t\t<CopyToClipBoardBtn\n\t\t\t\t\t\tclassNames=\"inline-flex items-center rounded-xl -mr-1 border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow enabled:hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:enabled:hover:bg-gray-600\"\n\t\t\t\t\t\tshowTooltip={false}\n\t\t\t\t\t\tvalue={withLeafId(createdUrl) ?? createdUrl}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tjustCopied = true;\n\t\t\t\t\t\t\toncopied?.();\n\t\t\t\t\t\t\tsetTimeout(() => (justCopied = false), 1200);\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{#snippet children()}\n\t\t\t\t\t\t\t<span class=\"inline-flex items-center gap-1.5\">\n\t\t\t\t\t\t\t\t{#if justCopied}\n\t\t\t\t\t\t\t\t\t<CarbonCheckmark class=\"text-[.95em] text-green-600 dark:text-green-400\" />\n\t\t\t\t\t\t\t\t\tCopied\n\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t<!-- Use the copy icon provided by CopyToClipBoardBtn default otherwise -->\n\t\t\t\t\t\t\t\t\t<svg width=\"1em\" height=\"1em\" viewBox=\"0 0 32 32\" class=\"text-[.95em]\"\n\t\t\t\t\t\t\t\t\t\t><path\n\t\t\t\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\t\td=\"M28 10v18H10V10zm-2 2H12v14h14zm-4-8v2H6v14H4V4z\"\n\t\t\t\t\t\t\t\t\t\t/></svg\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tCopy link\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{/snippet}\n\t\t\t\t\t</CopyToClipBoardBtn>\n\t\t\t\t{:else}\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"-mr-1 inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600\"\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tdisabled={creating}\n\t\t\t\t\t\tonclick={handleCreate}\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if creating}\n\t\t\t\t\t\t\t<EosIconsLoading class=\"text-[1.05em]\" />\n\t\t\t\t\t\t\tCreating…\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<CarbonLink class=\"text-[1.05em]\" />\n\t\t\t\t\t\t\tCreate link\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</Modal>\n{/if}\n"
  },
  {
    "path": "src/lib/components/StopGeneratingBtn.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonStopFilledAlt from \"~icons/carbon/stop-filled-alt\";\n\n\tinterface Props {\n\t\tclassNames?: string;\n\t\tonClick?: () => void;\n\t\tshowBorder?: boolean;\n\t}\n\n\tlet { classNames = \"\", onClick, showBorder = false }: Props = $props();\n</script>\n\n<button\n\ttype=\"button\"\n\tonclick={onClick}\n\tclass={`btn stop-generating-btn ${showBorder ? \"stop-generating-btn--spinning\" : \"\"} ${classNames}`}\n\taria-label=\"Stop generating\"\n>\n\t<span class=\"sr-only\">Stop generating</span>\n\t<CarbonStopFilledAlt class=\"size-3.5 text-gray-500\" />\n</button>\n\n<style lang=\"postcss\">\n\t.stop-generating-btn {\n\t\tposition: relative;\n\t\tdisplay: inline-flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tborder-radius: 9999px;\n\t\t--stop-generating-ring-color: rgba(31, 41, 55, 0.35);\n\t}\n\n\t.stop-generating-btn :global(svg) {\n\t\tdisplay: block;\n\t}\n\n\t.stop-generating-btn::after {\n\t\tcontent: \"\";\n\t\tposition: absolute;\n\t\tinset: -2px;\n\t\tborder-radius: inherit;\n\t\tpointer-events: none;\n\t\tbackground: transparent;\n\t}\n\n\t.stop-generating-btn--spinning::after {\n\t\tbackground: conic-gradient(\n\t\t\tfrom 0deg,\n\t\t\ttransparent 0deg 240deg,\n\t\t\tvar(--stop-generating-ring-color) 240deg 360deg\n\t\t);\n\t\tmask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1px));\n\t\tanimation: stop-generating-rotate 1.2s linear infinite;\n\t}\n\n\t:global(.dark) .stop-generating-btn {\n\t\t--stop-generating-ring-color: rgba(255, 255, 255, 0.2);\n\t}\n\n\t@keyframes stop-generating-rotate {\n\t\tfrom {\n\t\t\ttransform: rotate(0deg);\n\t\t}\n\n\t\tto {\n\t\t\ttransform: rotate(360deg);\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/SubscribeModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport { isPro } from \"$lib/stores/isPro\";\n\timport IconPro from \"$lib/components/icons/IconPro.svelte\";\n\timport IconDazzled from \"$lib/components/icons/IconDazzled.svelte\";\n\n\tinterface Props {\n\t\tclose: () => void;\n\t}\n\n\tlet { close }: Props = $props();\n</script>\n\n<Modal closeOnBackdrop={false} onclose={close} width=\"!max-w-[420px] !m-4\">\n\t<div\n\t\tclass=\"flex w-full flex-col gap-8 bg-white bg-gradient-to-b to-transparent px-6 pb-7 dark:bg-black dark:from-white/10 dark:to-white/5\"\n\t>\n\t\t<div\n\t\t\tclass=\"-mx-6 grid h-48 select-none place-items-center bg-gradient-to-t from-black/5 dark:from-white/10\"\n\t\t>\n\t\t\t<div class=\"flex flex-col items-center justify-center gap-2.5 px-8 text-center\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"flex size-14 items-center justify-center rounded-full text-3xl {$isPro\n\t\t\t\t\t\t? 'bg-gradient-to-br from-yellow-500/15 via-orange-500/15 to-red-500/15'\n\t\t\t\t\t\t: 'bg-gradient-to-br from-pink-500/15 from-15% via-green-500/15 to-yellow-500/15'}\"\n\t\t\t\t>\n\t\t\t\t\t{#if $isPro}\n\t\t\t\t\t\t<IconDazzled />\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<IconPro classNames=\"!mr-0\" />\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t\t<h2 class=\"text-2xl font-semibold text-gray-900 dark:text-gray-100\">\n\t\t\t\t\t{$isPro ? \"Out of Credits\" : \"Upgrade Required\"}\n\t\t\t\t</h2>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"text-gray-700 dark:text-gray-200\">\n\t\t\t{#if $isPro}\n\t\t\t\t<p class=\"text-[15px] leading-relaxed\">\n\t\t\t\t\tYou've used all your available credits. Purchase additional credits to continue using\n\t\t\t\t\tHuggingChat.\n\t\t\t\t</p>\n\t\t\t\t<p class=\"mt-3 text-[15px] italic leading-relaxed opacity-75\">\n\t\t\t\t\tYour credits can be used in other HF services and external apps via Inference Providers.\n\t\t\t\t</p>\n\t\t\t{:else}\n\t\t\t\t<p class=\"text-[15px] leading-relaxed\">\n\t\t\t\t\tYou've reached your message limit. Upgrade to Hugging Face PRO to continue using\n\t\t\t\t\tHuggingChat.\n\t\t\t\t</p>\n\t\t\t\t<p class=\"mt-3 text-[15px] italic leading-relaxed opacity-75\">\n\t\t\t\t\tIt's also possible to use your PRO credits in your favorite AI tools.\n\t\t\t\t</p>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<div class=\"flex flex-col gap-2.5\">\n\t\t\t{#if $isPro}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/settings/billing?add-credits=true\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclass=\"w-full rounded-xl bg-black px-5 py-2.5 text-center text-base font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200\"\n\t\t\t\t>\n\t\t\t\t\tPurchase Credits\n\t\t\t\t</a>\n\t\t\t{:else}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/subscribe/pro?from=HuggingChat\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclass=\"w-full rounded-xl bg-black px-5 py-2.5 text-center text-base font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200\"\n\t\t\t\t>\n\t\t\t\t\tUpgrade to Pro\n\t\t\t\t</a>\n\t\t\t{/if}\n\t\t\t<button\n\t\t\t\tclass=\"w-full rounded-xl bg-gray-200 px-5 py-2.5 text-base font-medium text-gray-700 hover:bg-gray-300/80 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10\"\n\t\t\t\tonclick={close}\n\t\t\t>\n\t\t\t\tMaybe later\n\t\t\t</button>\n\t\t</div>\n\t</div>\n</Modal>\n"
  },
  {
    "path": "src/lib/components/Switch.svelte",
    "content": "<script lang=\"ts\">\n\timport { tap } from \"$lib/utils/haptics\";\n\n\tinterface Props {\n\t\tchecked: boolean;\n\t\tname: string;\n\t}\n\n\tlet { checked = $bindable(), name }: Props = $props();\n\n\tfunction toggle() {\n\t\tchecked = !checked;\n\t\ttap();\n\t}\n\n\tfunction onKeydown(e: KeyboardEvent) {\n\t\tif (e.key === \" \" || e.key === \"Enter\") {\n\t\t\te.preventDefault();\n\t\t\ttoggle();\n\t\t}\n\t}\n</script>\n\n<input bind:checked type=\"checkbox\" {name} class=\"peer pointer-events-none absolute opacity-0\" />\n<div\n\taria-checked={checked}\n\taria-roledescription=\"switch\"\n\taria-label=\"switch\"\n\trole=\"switch\"\n\ttabindex=\"0\"\n\tonclick={toggle}\n\tonkeydown={onKeydown}\n\tclass=\"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-gray-300 p-1 shadow-inner ring-gray-400 peer-checked:bg-blue-600 hover:bg-gray-400 peer-checked:hover:bg-blue-600 focus-visible:ring focus-visible:ring-offset-1 dark:bg-gray-600 dark:ring-gray-700 dark:hover:bg-gray-500 dark:peer-checked:hover:bg-blue-600 peer-checked:[&>div]:translate-x-3.5\"\n>\n\t<div class=\"h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform\"></div>\n</div>\n"
  },
  {
    "path": "src/lib/components/SystemPromptModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"./Modal.svelte\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport CarbonBlockchain from \"~icons/carbon/blockchain\";\n\n\tinterface Props {\n\t\tpreprompt: string;\n\t}\n\n\tlet { preprompt }: Props = $props();\n\n\tlet isOpen = $state(false);\n</script>\n\n<button\n\ttype=\"button\"\n\tclass=\"mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 px-3 py-1 text-xs text-gray-500 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700\"\n\tonclick={() => (isOpen = !isOpen)}\n\tonkeydown={(e) => e.key === \"Enter\" && (isOpen = !isOpen)}\n>\n\t<CarbonBlockchain class=\"text-xxs\" /> Using Custom System Prompt\n</button>\n\n{#if isOpen}\n\t<Modal onclose={() => (isOpen = false)} width=\"w-full !max-w-xl\">\n\t\t<div class=\"flex w-full flex-col gap-5 p-6\">\n\t\t\t<div\n\t\t\t\tclass=\"flex items-start justify-between text-xl font-semibold text-gray-800 dark:text-gray-200\"\n\t\t\t>\n\t\t\t\t<h2>System Prompt</h2>\n\t\t\t\t<button type=\"button\" class=\"group\" onclick={() => (isOpen = false)}>\n\t\t\t\t\t<CarbonClose\n\t\t\t\t\t\tclass=\"mt-auto text-gray-900 group-hover:text-gray-500 dark:text-gray-200 dark:group-hover:text-gray-400\"\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t\t<textarea\n\t\t\t\tdisabled\n\t\t\t\tvalue={preprompt}\n\t\t\t\tclass=\"min-h-[420px] w-full resize-none rounded-lg border bg-gray-50 p-2.5 text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 max-sm:text-sm\"\n\t\t\t></textarea>\n\t\t</div>\n\t</Modal>\n{/if}\n"
  },
  {
    "path": "src/lib/components/Toast.svelte",
    "content": "<script lang=\"ts\">\n\timport { fade } from \"svelte/transition\";\n\timport Portal from \"./Portal.svelte\";\n\timport IconDazzled from \"$lib/components/icons/IconDazzled.svelte\";\n\n\tinterface Props {\n\t\tmessage?: string;\n\t}\n\n\tlet { message = \"\" }: Props = $props();\n</script>\n\n<Portal>\n\t<div\n\t\ttransition:fade|global={{ duration: 300 }}\n\t\tclass=\"pointer-events-none fixed right-0 top-12 z-50 bg-gradient-to-bl from-red-500/20 via-red-500/0 to-red-500/0 pb-36 pl-36 pr-2 pt-2 max-sm:text-sm md:top-0 md:pr-8 md:pt-5\"\n\t>\n\t\t<div\n\t\t\tclass=\"pointer-events-auto flex items-center rounded-full bg-white/90 px-3 py-1 shadow-sm dark:bg-gray-900/80\"\n\t\t>\n\t\t\t<IconDazzled classNames=\"text-2xl mr-2 flex-none\" />\n\t\t\t<h2 class=\"line-clamp-2 max-w-2xl font-semibold text-gray-800 dark:text-gray-200\">\n\t\t\t\t{message}\n\t\t\t</h2>\n\t\t</div>\n\t</div>\n</Portal>\n"
  },
  {
    "path": "src/lib/components/Tooltip.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t\tlabel?: string;\n\t\tposition?: string;\n\t}\n\n\tlet {\n\t\tclassNames = \"\",\n\t\tlabel = \"Copied\",\n\t\tposition = \"left-1/2 top-full transform -translate-x-1/2 translate-y-2\",\n\t}: Props = $props();\n</script>\n\n<div\n\tclass=\"\n\t\tpointer-events-none absolute rounded bg-black px-2 py-1 font-normal leading-tight text-white shadow transition-opacity\n\t\t{position}\n\t\t{classNames}\n\t\"\n>\n\t<div\n\t\tclass=\"absolute bottom-full left-1/2 h-0 w-0 -translate-x-1/2 transform border-4 border-t-0 border-black\"\n\t\tstyle=\"\n\t\t\t\tborder-left-color: transparent;\n\t\t\t\tborder-right-color: transparent;\n\t\t\t\"\n\t></div>\n\t{label}\n</div>\n"
  },
  {
    "path": "src/lib/components/WelcomeModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport IconOmni from \"$lib/components/icons/IconOmni.svelte\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\tinterface Props {\n\t\tclose: () => void;\n\t}\n\n\tlet { close }: Props = $props();\n</script>\n\n<Modal closeOnBackdrop={false} onclose={close} width=\"!max-w-[420px] !m-4\">\n\t<div\n\t\tclass=\"flex w-full flex-col gap-8 bg-white bg-gradient-to-b to-transparent px-6 pb-7 dark:bg-black dark:from-white/10 dark:to-white/5\"\n\t>\n\t\t<div\n\t\t\tclass=\"relative -mx-6 grid h-48 select-none place-items-center bg-gradient-to-t from-black/5 dark:from-white/10\"\n\t\t>\n\t\t\t<img\n\t\t\t\tclass=\"size-full bg-black object-cover\"\n\t\t\t\tsrc=\"{publicConfig.assetPath}/omni-welcome.gif\"\n\t\t\t\talt=\"Omni AI model router animation\"\n\t\t\t/>\n\t\t\t<!-- <h2\n\t\t\t\tclass=\"flex translate-y-1 items-center text-3xl font-semibold text-gray-900 dark:text-gray-100\"\n\t\t\t>\n\t\t\t\t<Logo classNames=\"mr-2 size-12 dark:invert\" />\n\t\t\t\t{publicConfig.PUBLIC_APP_NAME}\n\t\t\t</h2> -->\n\t\t\t<div\n\t\t\t\tclass=\"absolute bottom-3 right-3 rounded-lg border border-blue-500/20 bg-blue-500/20 px-2 py-0.5 text-sm font-semibold text-blue-500\"\n\t\t\t>\n\t\t\t\tNow with MCP!\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"text-gray-700 dark:text-gray-200\">\n\t\t\t<p class=\"text-[15px] leading-relaxed\">\n\t\t\t\tWelcome to {publicConfig.PUBLIC_APP_NAME}, the chat app powered by open source AI models.\n\t\t\t</p>\n\t\t\t<p class=\"mt-3 text-[15px] leading-relaxed\">\n\t\t\t\t<IconOmni classNames=\"-translate-y-px\" /> Omni automatically picks the best AI model to give\n\t\t\t\tyou optimal answers depending on your requests.\n\t\t\t</p>\n\t\t\t<p class=\"mt-3 text-[15px] leading-relaxed\">\n\t\t\t\tYou can also choose from any available open source models to chat with directly.\n\t\t\t</p>\n\t\t</div>\n\n\t\t<button\n\t\t\tclass=\"k w-full rounded-xl bg-black px-5 py-2.5 text-base font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200\"\n\t\t\tonclick={close}\n\t\t>\n\t\t\tStart chatting\n\t\t</button>\n\t</div>\n</Modal>\n"
  },
  {
    "path": "src/lib/components/chat/Alternatives.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Message } from \"$lib/types/Message\";\n\timport CarbonChevronLeft from \"~icons/carbon/chevron-left\";\n\timport CarbonChevronRight from \"~icons/carbon/chevron-right\";\n\n\tinterface Props {\n\t\tmessage: Message;\n\t\talternatives?: Message[\"id\"][];\n\t\tloading?: boolean;\n\t\tclassNames?: string;\n\t\tonshowAlternateMsg?: (payload: { id: Message[\"id\"] }) => void;\n\t}\n\n\tlet {\n\t\tmessage,\n\t\talternatives = [],\n\t\tloading = false,\n\t\tclassNames = \"\",\n\t\tonshowAlternateMsg,\n\t}: Props = $props();\n\n\tlet currentIdx = $derived(alternatives.findIndex((id) => id === message.id));\n\n\t// API client removed as deletion UI is commented out\n</script>\n\n<div\n\tclass=\"font-white group/navbranch z-0 flex h-6 w-fit select-none items-center justify-center gap-1 whitespace-nowrap text-sm {classNames}\"\n>\n\t<button\n\t\tclass=\"inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200\"\n\t\tonclick={() => onshowAlternateMsg?.({ id: alternatives[Math.max(0, currentIdx - 1)] })}\n\t\tdisabled={currentIdx === 0 || loading}\n\t>\n\t\t<CarbonChevronLeft class=\"text-sm\" />\n\t</button>\n\t<span class=\" text-gray-400 dark:text-gray-500\">\n\t\t{currentIdx + 1} / {alternatives.length}\n\t</span>\n\t<button\n\t\tclass=\"inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200\"\n\t\tonclick={() =>\n\t\t\tonshowAlternateMsg?.({\n\t\t\t\tid: alternatives[Math.min(alternatives.length - 1, currentIdx + 1)],\n\t\t\t})}\n\t\tdisabled={currentIdx === alternatives.length - 1 || loading}\n\t>\n\t\t<CarbonChevronRight class=\"text-sm\" />\n\t</button>\n\t<!-- {#if !loading && message.children}\n\t\t<button\n\t\t\tclass=\"hidden group-hover/navbranch:block\"\n\t\t\tonclick={() => {\n\t\t\t\tif (confirm(\"Are you sure you want to delete this branch?\")) {\n\t\t\t\t\tclient\n\t\t\t\t\t\t.conversations({ id: page.params.id })\n\t\t\t\t\t\t.message({ messageId: message.id })\n\t\t\t\t\t\t.delete()\n\t\t\t\t\t\t.then(handleResponse)\n\t\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\t\tawait invalidate(UrlDependency.Conversation);\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.catch((err) => {\n\t\t\t\t\t\t\tconsole.error(err);\n\t\t\t\t\t\t\t$error = String(err);\n\t\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tclass=\"flex items-center justify-center text-xs text-gray-400 hover:text-gray-800 dark:text-gray-500 dark:hover:text-gray-200\"\n\t\t\t>\n\t\t\t\t<CarbonTrashCan />\n\t\t\t</div>\n\t\t</button>\n\t{/if} -->\n</div>\n"
  },
  {
    "path": "src/lib/components/chat/BlockWrapper.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Snippet } from \"svelte\";\n\n\tinterface Props {\n\t\ticon: Snippet;\n\t\ticonBg?: string;\n\t\ticonRing?: string;\n\t\thasNext?: boolean;\n\t\tloading?: boolean;\n\t\tchildren: Snippet;\n\t}\n\n\tlet {\n\t\ticon,\n\t\ticonBg = \"bg-gray-50 dark:bg-gray-800\",\n\t\ticonRing = \"ring-gray-100 dark:ring-gray-700\",\n\t\thasNext = false,\n\t\tloading = false,\n\t\tchildren,\n\t}: Props = $props();\n</script>\n\n<div class=\"group flex gap-2 has-[+.prose]:mb-1.5 [.prose+&]:mt-3\">\n\t<!-- Left column: icon + connector line -->\n\t<div class=\"flex w-[22px] flex-shrink-0 flex-col items-center\">\n\t\t<div\n\t\t\tclass=\"relative z-0 flex h-[22px] w-[22px] items-center justify-center rounded-md ring-1 {iconBg} {iconRing}\"\n\t\t>\n\t\t\t{@render icon()}\n\t\t\t{#if loading}\n\t\t\t\t<svg\n\t\t\t\t\tclass=\"pointer-events-none absolute inset-0 h-[22px] w-[22px]\"\n\t\t\t\t\tviewBox=\"0 0 22 22\"\n\t\t\t\t\tfill=\"none\"\n\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t>\n\t\t\t\t\t<rect\n\t\t\t\t\t\tx=\"0.5\"\n\t\t\t\t\t\ty=\"0.5\"\n\t\t\t\t\t\twidth=\"21\"\n\t\t\t\t\t\theight=\"21\"\n\t\t\t\t\t\trx=\"5.5\"\n\t\t\t\t\t\tclass=\"loading-path stroke-current text-purple-500/20\"\n\t\t\t\t\t\tstroke-width=\"1\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t/>\n\t\t\t\t</svg>\n\t\t\t{/if}\n\t\t</div>\n\t\t{#if hasNext}\n\t\t\t<div class=\"my-1 w-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n\t\t{/if}\n\t</div>\n\n\t<!-- Right column: content -->\n\t<div class=\"min-w-0 flex-1 pb-2 pt-px\">\n\t\t{@render children()}\n\t</div>\n</div>\n\n<style>\n\t@keyframes loading {\n\t\tto {\n\t\t\tstroke-dashoffset: -100;\n\t\t}\n\t}\n\n\t.loading-path {\n\t\tstroke-dasharray: 60 40;\n\t\tanimation: loading 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/chat/ChatInput.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount, tick } from \"svelte\";\n\n\timport { afterNavigate } from \"$app/navigation\";\n\n\timport { DropdownMenu } from \"bits-ui\";\n\timport IconPlus from \"~icons/lucide/plus\";\n\timport CarbonImage from \"~icons/carbon/image\";\n\timport CarbonDocument from \"~icons/carbon/document\";\n\timport CarbonUpload from \"~icons/carbon/upload\";\n\timport CarbonLink from \"~icons/carbon/link\";\n\timport CarbonChevronRight from \"~icons/carbon/chevron-right\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport UrlFetchModal from \"./UrlFetchModal.svelte\";\n\timport { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from \"$lib/constants/mime\";\n\timport MCPServerManager from \"$lib/components/mcp/MCPServerManager.svelte\";\n\timport IconMCP from \"$lib/components/icons/IconMCP.svelte\";\n\n\timport { isVirtualKeyboard } from \"$lib/utils/isVirtualKeyboard\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport {\n\t\tenabledServersCount,\n\t\tselectedServerIds,\n\t\tallMcpServers,\n\t\ttoggleServer,\n\t\tdisableAllServers,\n\t} from \"$lib/stores/mcpServers\";\n\timport { getMcpServerFaviconUrl } from \"$lib/utils/favicon\";\n\timport { page } from \"$app/state\";\n\n\tinterface Props {\n\t\tfiles?: File[];\n\t\tmimeTypes?: string[];\n\t\tvalue?: string;\n\t\tplaceholder?: string;\n\t\tloading?: boolean;\n\t\tdisabled?: boolean;\n\t\t// tools removed\n\t\tmodelIsMultimodal?: boolean;\n\t\t// Whether the currently selected model supports tool calling (incl. overrides)\n\t\tmodelSupportsTools?: boolean;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t\tonPaste?: (e: ClipboardEvent) => void;\n\t\tfocused?: boolean;\n\t\tonsubmit?: () => void;\n\t}\n\n\tlet {\n\t\tfiles = $bindable([]),\n\t\tmimeTypes = [],\n\t\tvalue = $bindable(\"\"),\n\t\tplaceholder = \"\",\n\t\tloading = false,\n\t\tdisabled = false,\n\n\t\tmodelIsMultimodal = false,\n\t\tmodelSupportsTools = true,\n\t\tchildren,\n\t\tonPaste,\n\t\tfocused = $bindable(false),\n\t\tonsubmit,\n\t}: Props = $props();\n\n\tconst onFileChange = async (e: Event) => {\n\t\tif (!e.target) return;\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst selected = Array.from(target.files ?? []);\n\t\tif (selected.length === 0) return;\n\t\tfiles = [...files, ...selected];\n\t\tawait tick();\n\t\tvoid focusTextarea();\n\t};\n\n\tlet textareaElement: HTMLTextAreaElement | undefined = $state();\n\tlet isCompositionOn = $state(false);\n\tlet blurTimeout: ReturnType<typeof setTimeout> | null = $state(null);\n\n\tlet fileInputEl: HTMLInputElement | undefined = $state();\n\tlet isUrlModalOpen = $state(false);\n\tlet isMcpManagerOpen = $state(false);\n\tlet isDropdownOpen = $state(false);\n\n\tfunction openPickerWithAccept(accept: string) {\n\t\tif (!fileInputEl) return;\n\t\tconst allAccept = mimeTypes.join(\",\");\n\t\tfileInputEl.setAttribute(\"accept\", accept);\n\t\tfileInputEl.click();\n\t\tqueueMicrotask(() => fileInputEl?.setAttribute(\"accept\", allAccept));\n\t}\n\n\tfunction openFilePickerText() {\n\t\tconst textAccept =\n\t\t\tmimeTypes.filter((m) => !(m === \"image/*\" || m.startsWith(\"image/\"))).join(\",\") ||\n\t\t\tTEXT_MIME_ALLOWLIST.join(\",\");\n\t\topenPickerWithAccept(textAccept);\n\t}\n\n\tfunction openFilePickerImage() {\n\t\tconst imageAccept =\n\t\t\tmimeTypes.filter((m) => m === \"image/*\" || m.startsWith(\"image/\")).join(\",\") ||\n\t\t\tIMAGE_MIME_ALLOWLIST_DEFAULT.join(\",\");\n\t\topenPickerWithAccept(imageAccept);\n\t}\n\n\tconst waitForAnimationFrame = () =>\n\t\ttypeof requestAnimationFrame === \"function\"\n\t\t\t? new Promise<void>((resolve) => {\n\t\t\t\t\trequestAnimationFrame(() => resolve());\n\t\t\t\t})\n\t\t\t: Promise.resolve();\n\n\tasync function focusTextarea() {\n\t\tif (page.data.shared && page.data.loginEnabled && !page.data.user) return;\n\t\tif (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return;\n\t\tif (typeof document !== \"undefined\" && document.activeElement === textareaElement) return;\n\n\t\tawait tick();\n\n\t\tif (typeof requestAnimationFrame === \"function\") {\n\t\t\tawait waitForAnimationFrame();\n\t\t\tawait waitForAnimationFrame();\n\t\t}\n\n\t\tif (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return;\n\n\t\ttry {\n\t\t\ttextareaElement.focus({ preventScroll: true });\n\t\t} catch {\n\t\t\ttextareaElement.focus();\n\t\t}\n\n\t\t// Retry only when focus failed due to #app being inert (modal closing transition)\n\t\tif (\n\t\t\ttypeof document !== \"undefined\" &&\n\t\t\tdocument.activeElement !== textareaElement &&\n\t\t\tdocument.getElementById(\"app\")?.hasAttribute(\"inert\")\n\t\t) {\n\t\t\tsetTimeout(() => {\n\t\t\t\tif (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return;\n\t\t\t\tif (document.activeElement === textareaElement) return;\n\t\t\t\ttry {\n\t\t\t\t\ttextareaElement.focus({ preventScroll: true });\n\t\t\t\t} catch {\n\t\t\t\t\ttextareaElement.focus();\n\t\t\t\t}\n\t\t\t}, 350);\n\t\t}\n\t}\n\n\tfunction handleFetchedFiles(newFiles: File[]) {\n\t\tif (!newFiles?.length) return;\n\t\tfiles = [...files, ...newFiles];\n\t\tqueueMicrotask(async () => {\n\t\t\tawait tick();\n\t\t\tvoid focusTextarea();\n\t\t});\n\t}\n\n\tonMount(() => {\n\t\tvoid focusTextarea();\n\t});\n\n\tafterNavigate(() => {\n\t\tvoid focusTextarea();\n\t});\n\n\tfunction adjustTextareaHeight() {\n\t\tif (!textareaElement) {\n\t\t\treturn;\n\t\t}\n\n\t\ttextareaElement.style.height = \"auto\";\n\t\ttextareaElement.style.height = `${textareaElement.scrollHeight}px`;\n\n\t\tif (textareaElement.selectionStart === textareaElement.value.length) {\n\t\t\ttextareaElement.scrollTop = textareaElement.scrollHeight;\n\t\t}\n\t}\n\n\t$effect(() => {\n\t\tif (!textareaElement) return;\n\t\tvoid value;\n\t\tadjustTextareaHeight();\n\t});\n\n\tfunction handleKeydown(event: KeyboardEvent) {\n\t\tif (\n\t\t\tevent.key === \"Enter\" &&\n\t\t\t!event.shiftKey &&\n\t\t\t!isCompositionOn &&\n\t\t\t!isVirtualKeyboard() &&\n\t\t\tvalue.trim() !== \"\"\n\t\t) {\n\t\t\tevent.preventDefault();\n\t\t\ttick();\n\t\t\tonsubmit?.();\n\t\t}\n\t}\n\n\tfunction handleFocus() {\n\t\tif (requireAuthUser()) {\n\t\t\treturn;\n\t\t}\n\t\tif (blurTimeout) {\n\t\t\tclearTimeout(blurTimeout);\n\t\t\tblurTimeout = null;\n\t\t}\n\t\tfocused = true;\n\t}\n\n\tfunction handleBlur() {\n\t\tif (!isVirtualKeyboard()) {\n\t\t\tfocused = false;\n\t\t\treturn;\n\t\t}\n\n\t\tif (blurTimeout) {\n\t\t\tclearTimeout(blurTimeout);\n\t\t}\n\n\t\tblurTimeout = setTimeout(() => {\n\t\t\tblurTimeout = null;\n\t\t\tfocused = false;\n\t\t});\n\t}\n\n\t// Show file upload when any mime is allowed (text always; images if multimodal)\n\tlet showFileUpload = $derived(mimeTypes.length > 0);\n\tlet showNoTools = $derived(!showFileUpload);\n\tlet selectedServers = $derived(\n\t\t$allMcpServers.filter((server) => $selectedServerIds.has(server.id))\n\t);\n</script>\n\n<div class=\"flex min-h-full flex-1 flex-col\" onpaste={onPaste}>\n\t<textarea\n\t\trows=\"1\"\n\t\ttabindex=\"0\"\n\t\tinputmode=\"text\"\n\t\tclass=\"scrollbar-custom max-h-[4lh] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-2.5 py-2.5 outline-none focus:ring-0 focus-visible:ring-0 sm:px-3 md:max-h-[8lh]\"\n\t\tclass:text-gray-400={disabled}\n\t\tbind:value\n\t\tbind:this={textareaElement}\n\t\tonkeydown={handleKeydown}\n\t\toncompositionstart={() => (isCompositionOn = true)}\n\t\toncompositionend={() => (isCompositionOn = false)}\n\t\t{placeholder}\n\t\t{disabled}\n\t\tonfocus={handleFocus}\n\t\tonblur={handleBlur}\n\t\tonbeforeinput={requireAuthUser}\n\t></textarea>\n\n\t{#if !showNoTools}\n\t\t<div\n\t\t\tclass={[\n\t\t\t\t\"scrollbar-custom -ml-0.5 flex max-w-[calc(100%-40px)] flex-wrap items-center justify-start gap-2.5 px-3 pb-2.5 pt-1.5 text-gray-500 dark:text-gray-400 max-md:flex-nowrap max-md:overflow-x-auto sm:gap-2\",\n\t\t\t]}\n\t\t>\n\t\t\t{#if showFileUpload}\n\t\t\t\t<div class=\"flex items-center\">\n\t\t\t\t\t<input\n\t\t\t\t\t\tbind:this={fileInputEl}\n\t\t\t\t\t\tdisabled={loading}\n\t\t\t\t\t\tclass=\"absolute hidden size-0\"\n\t\t\t\t\t\taria-label=\"Upload file\"\n\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t\tmultiple\n\t\t\t\t\t\tonchange={onFileChange}\n\t\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\t\tif (requireAuthUser()) {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\taccept={mimeTypes.join(\",\")}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<DropdownMenu.Root\n\t\t\t\t\t\tbind:open={isDropdownOpen}\n\t\t\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\t\t\tif (open && requireAuthUser()) {\n\t\t\t\t\t\t\t\tisDropdownOpen = false;\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tisDropdownOpen = open;\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<DropdownMenu.Trigger\n\t\t\t\t\t\t\tclass=\"btn size-8 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600 sm:size-7\"\n\t\t\t\t\t\t\tdisabled={loading}\n\t\t\t\t\t\t\taria-label=\"Add attachment\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconPlus class=\"text-base sm:text-sm\" />\n\t\t\t\t\t\t</DropdownMenu.Trigger>\n\t\t\t\t\t\t<DropdownMenu.Portal>\n\t\t\t\t\t\t\t<DropdownMenu.Content\n\t\t\t\t\t\t\t\tclass=\"z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100\"\n\t\t\t\t\t\t\t\tside=\"top\"\n\t\t\t\t\t\t\t\tsideOffset={8}\n\t\t\t\t\t\t\t\talign=\"start\"\n\t\t\t\t\t\t\t\ttrapFocus={false}\n\t\t\t\t\t\t\t\tonCloseAutoFocus={(e) => e.preventDefault()}\n\t\t\t\t\t\t\t\tinteractOutsideBehavior=\"defer-otherwise-close\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#if modelIsMultimodal}\n\t\t\t\t\t\t\t\t\t<DropdownMenu.Item\n\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t\tonSelect={() => openFilePickerImage()}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<CarbonImage class=\"size-4 opacity-90 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\tAdd image(s)\n\t\t\t\t\t\t\t\t\t</DropdownMenu.Item>\n\t\t\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t\t\t<DropdownMenu.Sub>\n\t\t\t\t\t\t\t\t\t<DropdownMenu.SubTrigger\n\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div class=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<CarbonDocument class=\"size-4 opacity-90 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t\tAdd text file\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"ml-auto flex items-center\">\n\t\t\t\t\t\t\t\t\t\t\t<CarbonChevronRight class=\"size-4 opacity-70 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</DropdownMenu.SubTrigger>\n\t\t\t\t\t\t\t\t\t<DropdownMenu.SubContent\n\t\t\t\t\t\t\t\t\t\tclass=\"z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100\"\n\t\t\t\t\t\t\t\t\t\tsideOffset={10}\n\t\t\t\t\t\t\t\t\t\ttrapFocus={false}\n\t\t\t\t\t\t\t\t\t\tonCloseAutoFocus={(e) => e.preventDefault()}\n\t\t\t\t\t\t\t\t\t\tinteractOutsideBehavior=\"defer-otherwise-close\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<DropdownMenu.Item\n\t\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t\t\tonSelect={() => openFilePickerText()}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<CarbonUpload class=\"size-4 opacity-90 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t\tUpload from device\n\t\t\t\t\t\t\t\t\t\t</DropdownMenu.Item>\n\t\t\t\t\t\t\t\t\t\t<DropdownMenu.Item\n\t\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t\t\tonSelect={() => (isUrlModalOpen = true)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<CarbonLink class=\"size-4 opacity-90 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t\tFetch from URL\n\t\t\t\t\t\t\t\t\t\t</DropdownMenu.Item>\n\t\t\t\t\t\t\t\t\t</DropdownMenu.SubContent>\n\t\t\t\t\t\t\t\t</DropdownMenu.Sub>\n\n\t\t\t\t\t\t\t\t<!-- MCP Servers submenu -->\n\t\t\t\t\t\t\t\t<DropdownMenu.Sub>\n\t\t\t\t\t\t\t\t\t<DropdownMenu.SubTrigger\n\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<div class=\"flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<IconMCP classNames=\"size-4 opacity-90 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t\tMCP Servers\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"ml-auto flex items-center\">\n\t\t\t\t\t\t\t\t\t\t\t<CarbonChevronRight class=\"size-4 opacity-70 dark:opacity-80\" />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</DropdownMenu.SubTrigger>\n\t\t\t\t\t\t\t\t\t<DropdownMenu.SubContent\n\t\t\t\t\t\t\t\t\t\tclass=\"z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100\"\n\t\t\t\t\t\t\t\t\t\tsideOffset={10}\n\t\t\t\t\t\t\t\t\t\ttrapFocus={false}\n\t\t\t\t\t\t\t\t\t\tonCloseAutoFocus={(e) => e.preventDefault()}\n\t\t\t\t\t\t\t\t\t\tinteractOutsideBehavior=\"defer-otherwise-close\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{#each $allMcpServers as server (server.id)}\n\t\t\t\t\t\t\t\t\t\t\t<DropdownMenu.CheckboxItem\n\t\t\t\t\t\t\t\t\t\t\t\tchecked={$selectedServerIds.has(server.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={() => toggleServer(server.id)}\n\t\t\t\t\t\t\t\t\t\t\t\tcloseOnSelect={false}\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-2 rounded-md px-2 text-sm leading-none text-gray-800 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-100 dark:data-[highlighted]:bg-white/10\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{#snippet children({ checked })}\n\t\t\t\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsrc={getMcpServerFaviconUrl(server.url)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"size-4 flex-shrink-0 rounded\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"max-w-52 truncate py-1\">{server.name}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"ml-auto flex items-center\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<!-- Toggle visual -->\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"relative mt-px flex h-4 w-7 items-center self-center rounded-full transition-colors\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchecked ? \"bg-blue-600/80\" : \"bg-gray-300 dark:bg-gray-700\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"block size-3 translate-x-0.5 rounded-full bg-white shadow transition-transform\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchecked ? \"translate-x-[14px]\" : \"translate-x-0.5\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{/snippet}\n\t\t\t\t\t\t\t\t\t\t\t</DropdownMenu.CheckboxItem>\n\t\t\t\t\t\t\t\t\t\t{/each}\n\n\t\t\t\t\t\t\t\t\t\t{#if $allMcpServers.length > 0}\n\t\t\t\t\t\t\t\t\t\t\t<DropdownMenu.Separator class=\"my-1 h-px bg-gray-200 dark:bg-gray-700/60\" />\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t\t<DropdownMenu.Item\n\t\t\t\t\t\t\t\t\t\t\tclass=\"flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8\"\n\t\t\t\t\t\t\t\t\t\t\tonSelect={() => (isMcpManagerOpen = true)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tManage MCP Servers\n\t\t\t\t\t\t\t\t\t\t</DropdownMenu.Item>\n\t\t\t\t\t\t\t\t\t</DropdownMenu.SubContent>\n\t\t\t\t\t\t\t\t</DropdownMenu.Sub>\n\t\t\t\t\t\t\t</DropdownMenu.Content>\n\t\t\t\t\t\t</DropdownMenu.Portal>\n\t\t\t\t\t</DropdownMenu.Root>\n\n\t\t\t\t\t{#if $enabledServersCount > 0}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"ml-1.5 inline-flex h-8 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 sm:h-7\"\n\t\t\t\t\t\t\tclass:grayscale={!modelSupportsTools}\n\t\t\t\t\t\t\tclass:opacity-60={!modelSupportsTools}\n\t\t\t\t\t\t\tclass:cursor-help={!modelSupportsTools}\n\t\t\t\t\t\t\ttitle={modelSupportsTools\n\t\t\t\t\t\t\t\t? \"MCP servers enabled\"\n\t\t\t\t\t\t\t\t: \"Current model doesn’t support tools\"}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclass=\"inline-flex cursor-pointer select-none items-center gap-1 bg-transparent p-0 leading-none text-current focus:outline-none\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\ttitle=\"Manage MCP Servers\"\n\t\t\t\t\t\t\t\tonclick={() => (isMcpManagerOpen = true)}\n\t\t\t\t\t\t\t\tclass:line-through={!modelSupportsTools}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#if selectedServers.length}\n\t\t\t\t\t\t\t\t\t<span class=\"flex items-center -space-x-1\">\n\t\t\t\t\t\t\t\t\t\t{#each selectedServers.slice(0, 3) as server (server.id)}\n\t\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\t\tsrc={getMcpServerFaviconUrl(server.url)}\n\t\t\t\t\t\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"size-4 rounded bg-white p-px shadow-sm ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t\t\t\t{#if selectedServers.length > 3}\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"ml-1 text-[10px] font-semibold text-blue-800 dark:text-blue-200\">\n\t\t\t\t\t\t\t\t\t\t\t\t+{selectedServers.length - 3}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\tMCP ({$enabledServersCount})\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclass=\"grid size-5 place-items-center rounded-full bg-blue-600/15 text-blue-700 transition-colors hover:bg-blue-600/25 dark:bg-blue-600/25 dark:text-blue-300 dark:hover:bg-blue-600/35\"\n\t\t\t\t\t\t\t\taria-label=\"Disable all MCP servers\"\n\t\t\t\t\t\t\t\tonclick={() => disableAllServers()}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<CarbonClose class=\"size-3.5\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n\t{@render children?.()}\n\n\t<UrlFetchModal\n\t\tbind:open={isUrlModalOpen}\n\t\tacceptMimeTypes={mimeTypes}\n\t\tonfiles={handleFetchedFiles}\n\t/>\n\n\t{#if isMcpManagerOpen}\n\t\t<MCPServerManager onclose={() => (isMcpManagerOpen = false)} />\n\t{/if}\n</div>\n\n<style lang=\"postcss\">\n\t:global(pre),\n\t:global(textarea) {\n\t\tfont-family: inherit;\n\t\tbox-sizing: border-box;\n\t\tline-height: 1.5;\n\t\tfont-size: 16px;\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/chat/ChatIntroduction.svelte",
    "content": "<script lang=\"ts\">\n\timport Logo from \"$lib/components/icons/Logo.svelte\";\n\timport type { Model } from \"$lib/types/Model\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\tinterface Props {\n\t\tcurrentModel: Model;\n\t\tonmessage?: (content: string) => void;\n\t}\n\n\tlet { currentModel: _currentModel, onmessage }: Props = $props();\n\n\t$effect(() => {\n\t\t// referenced to appease linter while UI blocks are commented out\n\t\tvoid _currentModel;\n\t\tvoid onmessage;\n\t});\n</script>\n\n<div class=\"my-auto grid items-center justify-center gap-8 text-center\">\n\t<div\n\t\tclass=\"flex -translate-y-16 select-none items-center rounded-xl text-3xl font-semibold md:-translate-y-12 md:text-5xl\"\n\t>\n\t\t<Logo classNames=\"size-12 md:size-20 dark:invert mr-0.5\" />\n\t\t{publicConfig.PUBLIC_APP_NAME}\n\t</div>\n\t<!-- <div class=\"lg:col-span-1\">\n\t\t<div>\n\t\t\t<div class=\"mb-3 flex items-center text-2xl font-semibold\">\n\t\t\t\t<Logo classNames=\"mr-1 flex-none dark:invert\" />\n\t\t\t\t{publicConfig.PUBLIC_APP_NAME}\n\t\t\t\t<div\n\t\t\t\t\tclass=\"ml-3 flex h-6 items-center rounded-lg border border-gray-100 bg-gray-50 px-2 text-base text-gray-400 dark:border-gray-700/60 dark:bg-gray-800\"\n\t\t\t\t>\n\t\t\t\t\t{publicConfig.PUBLIC_VERSION}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<p class=\"text-base text-gray-600 dark:text-gray-400\">\n\t\t\t\t{publicConfig.PUBLIC_APP_DESCRIPTION ||\n\t\t\t\t\t\"Making the community's best AI chat models available to everyone.\"}\n\t\t\t</p>\n\t\t</div>\n\t</div>\n\t<div class=\"lg:col-span-2 lg:pl-24\">\n\t\t{#each JSON5.parse(publicConfig.PUBLIC_ANNOUNCEMENT_BANNERS || \"[]\") as banner}\n\t\t\t<AnnouncementBanner classNames=\"mb-4\" title={banner.title}>\n\t\t\t\t<a\n\t\t\t\t\ttarget={banner.external ? \"_blank\" : \"_self\"}\n\t\t\t\t\thref={banner.linkHref}\n\t\t\t\t\tclass=\"mr-2 flex items-center underline hover:no-underline\">{banner.linkTitle}</a\n\t\t\t\t>\n\t\t\t</AnnouncementBanner>\n\t\t{/each}\n\t\t<div class=\"overflow-hidden rounded-xl border dark:border-gray-800\">\n\t\t\t<div class=\"flex p-3\">\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"text-sm text-gray-600 dark:text-gray-400\">Current Model</div>\n\t\t\t\t\t<div class=\"flex items-center gap-1.5 font-semibold max-sm:text-smd\">\n\t\t\t\t\t\t{#if currentModel.logoUrl}\n\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\tclass=\"aspect-square size-4 rounded border bg-white dark:border-gray-700\"\n\t\t\t\t\t\t\t\tsrc={currentModel.logoUrl}\n\t\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"size-4 rounded border border-transparent bg-gray-300 dark:bg-gray-800\"\n\t\t\t\t\t\t\t></div>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t{currentModel.displayName}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<a\n\t\t\t\t\thref=\"{base}/settings/{currentModel.id}\"\n\t\t\t\t\taria-label=\"Settings\"\n\t\t\t\t\tclass=\"btn ml-auto flex h-7 w-7 self-start rounded-full bg-gray-100 p-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-600\"\n\t\t\t\t\t><IconGear /></a\n\t\t\t\t>\n\t\t\t</div>\n\t\t\t<ModelCardMetadata variant=\"dark\" model={currentModel} />\n\t\t</div>\n\t</div>\n\t<div class=\"h-40 sm:h-24\"></div> -->\n</div>\n"
  },
  {
    "path": "src/lib/components/chat/ChatMessage.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Message } from \"$lib/types/Message\";\n\timport { tick } from \"svelte\";\n\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\tconst publicConfig = usePublicConfig();\n\timport CopyToClipBoardBtn from \"../CopyToClipBoardBtn.svelte\";\n\timport IconLoading from \"../icons/IconLoading.svelte\";\n\timport CarbonRotate360 from \"~icons/carbon/rotate-360\";\n\t// import CarbonDownload from \"~icons/carbon/download\";\n\n\timport CarbonPen from \"~icons/carbon/pen\";\n\timport UploadedFile from \"./UploadedFile.svelte\";\n\n\timport MarkdownRenderer from \"./MarkdownRenderer.svelte\";\n\timport OpenReasoningResults from \"./OpenReasoningResults.svelte\";\n\timport Alternatives from \"./Alternatives.svelte\";\n\timport MessageAvatar from \"./MessageAvatar.svelte\";\n\timport { PROVIDERS_HUB_ORGS } from \"@huggingface/inference\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport ToolUpdate from \"./ToolUpdate.svelte\";\n\timport { isMessageToolUpdate } from \"$lib/utils/messageUpdates\";\n\timport { MessageUpdateType, type MessageToolUpdate } from \"$lib/types/MessageUpdate\";\n\timport ImageLightbox from \"./ImageLightbox.svelte\";\n\n\tinterface Props {\n\t\tmessage: Message;\n\t\tloading?: boolean;\n\t\tisAuthor?: boolean;\n\t\treadOnly?: boolean;\n\t\tisTapped?: boolean;\n\t\talternatives?: Message[\"id\"][];\n\t\teditMsdgId?: Message[\"id\"] | null;\n\t\tisLast?: boolean;\n\t\tonretry?: (payload: { id: Message[\"id\"]; content?: string }) => void;\n\t\tonshowAlternateMsg?: (payload: { id: Message[\"id\"] }) => void;\n\t}\n\n\tlet {\n\t\tmessage,\n\t\tloading = false,\n\t\tisAuthor: _isAuthor = true,\n\t\treadOnly: _readOnly = false,\n\t\tisTapped = $bindable(false),\n\t\talternatives = [],\n\t\teditMsdgId = $bindable(null),\n\t\tisLast = false,\n\t\tonretry,\n\t\tonshowAlternateMsg,\n\t}: Props = $props();\n\n\tlet contentEl: HTMLElement | undefined = $state();\n\tlet isCopied = $state(false);\n\tlet messageWidth: number = $state(0);\n\tlet messageInfoWidth: number = $state(0);\n\tlet lightboxSrc: string | null = $state(null);\n\n\tfunction handleContentClick(e: MouseEvent) {\n\t\tconst target = e.target as HTMLElement;\n\t\tif (target.tagName === \"IMG\" && target instanceof HTMLImageElement) {\n\t\t\te.preventDefault();\n\t\t\te.stopPropagation();\n\t\t\tlightboxSrc = target.src;\n\t\t}\n\t}\n\n\t$effect(() => {\n\t\t// referenced to appease linter for currently-unused props\n\t\tvoid _isAuthor;\n\t\tvoid _readOnly;\n\t});\n\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\tif (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n\t\t\teditFormEl?.requestSubmit();\n\t\t}\n\t\tif (e.key === \"Escape\") {\n\t\t\teditMsdgId = null;\n\t\t}\n\t}\n\n\tfunction handleCopy(event: ClipboardEvent) {\n\t\tif (!contentEl) return;\n\n\t\tconst selection = window.getSelection();\n\t\tif (!selection || selection.isCollapsed) return;\n\t\tif (!selection.anchorNode || !selection.focusNode) return;\n\n\t\tconst anchorInside = contentEl.contains(selection.anchorNode);\n\t\tconst focusInside = contentEl.contains(selection.focusNode);\n\t\tif (!anchorInside && !focusInside) return;\n\n\t\tif (!event.clipboardData) return;\n\n\t\tconst range = selection.getRangeAt(0);\n\t\tconst wrapper = document.createElement(\"div\");\n\t\twrapper.appendChild(range.cloneContents());\n\n\t\twrapper.querySelectorAll(\"[data-exclude-from-copy]\").forEach((el) => {\n\t\t\tel.remove();\n\t\t});\n\n\t\twrapper.querySelectorAll(\"*\").forEach((el) => {\n\t\t\tel.removeAttribute(\"style\");\n\t\t\tel.removeAttribute(\"class\");\n\t\t\tel.removeAttribute(\"color\");\n\t\t\tel.removeAttribute(\"bgcolor\");\n\t\t\tel.removeAttribute(\"background\");\n\n\t\t\tfor (const attr of Array.from(el.attributes)) {\n\t\t\t\tif (attr.name === \"id\" || attr.name.startsWith(\"data-\")) {\n\t\t\t\t\tel.removeAttribute(attr.name);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tconst html = wrapper.innerHTML;\n\t\tconst text = wrapper.textContent ?? \"\";\n\n\t\tevent.preventDefault();\n\t\tevent.clipboardData.setData(\"text/html\", html);\n\t\tevent.clipboardData.setData(\"text/plain\", text);\n\t}\n\n\tlet editContentEl: HTMLTextAreaElement | undefined = $state();\n\tlet editFormEl: HTMLFormElement | undefined = $state();\n\n\t// Zero-config reasoning autodetection: detect <think> blocks in content\n\tconst THINK_BLOCK_REGEX = /(<think>[\\s\\S]*?(?:<\\/think>|$))/gi;\n\t// Non-global version for .test() calls to avoid lastIndex side effects\n\tconst THINK_BLOCK_TEST_REGEX = /(<think>[\\s\\S]*?(?:<\\/think>|$))/i;\n\tlet hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1);\n\n\t// Strip think blocks for clipboard copy (always, regardless of detection)\n\tlet contentWithoutThink = $derived.by(() =>\n\t\tmessage.content.replace(THINK_BLOCK_REGEX, \"\").trim()\n\t);\n\n\ttype Block =\n\t\t| { type: \"text\"; content: string }\n\t\t| { type: \"tool\"; uuid: string; updates: MessageToolUpdate[] };\n\n\ttype ToolBlock = Extract<Block, { type: \"tool\" }>;\n\n\tlet blocks = $derived.by(() => {\n\t\tconst updates = message.updates ?? [];\n\t\tconst res: Block[] = [];\n\t\tconst hasTools = updates.some(isMessageToolUpdate);\n\t\tlet contentCursor = 0;\n\t\tlet sawFinalAnswer = false;\n\n\t\t// Fast path: no tool updates at all\n\t\tif (!hasTools && updates.length === 0) {\n\t\t\tif (message.content) return [{ type: \"text\" as const, content: message.content }];\n\t\t\treturn [];\n\t\t}\n\n\t\tfor (const update of updates) {\n\t\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\t\tconst token =\n\t\t\t\t\ttypeof update.token === \"string\" && update.token.length > 0 ? update.token : null;\n\t\t\t\tconst len = token !== null ? token.length : (update.len ?? 0);\n\t\t\t\tconst chunk =\n\t\t\t\t\ttoken ??\n\t\t\t\t\t(message.content ? message.content.slice(contentCursor, contentCursor + len) : \"\");\n\t\t\t\tcontentCursor += len;\n\t\t\t\tif (!chunk) continue;\n\t\t\t\tconst last = res.at(-1);\n\t\t\t\tif (last?.type === \"text\") last.content += chunk;\n\t\t\t\telse res.push({ type: \"text\" as const, content: chunk });\n\t\t\t} else if (isMessageToolUpdate(update)) {\n\t\t\t\tconst existingBlock = res.find(\n\t\t\t\t\t(b): b is ToolBlock => b.type === \"tool\" && b.uuid === update.uuid\n\t\t\t\t);\n\t\t\t\tif (existingBlock) {\n\t\t\t\t\texistingBlock.updates.push(update);\n\t\t\t\t} else {\n\t\t\t\t\tres.push({ type: \"tool\" as const, uuid: update.uuid, updates: [update] });\n\t\t\t\t}\n\t\t\t} else if (update.type === MessageUpdateType.FinalAnswer) {\n\t\t\t\tsawFinalAnswer = true;\n\t\t\t\tconst finalText = update.text ?? \"\";\n\t\t\t\tconst currentText = res\n\t\t\t\t\t.filter((b) => b.type === \"text\")\n\t\t\t\t\t.map((b) => (b as { type: \"text\"; content: string }).content)\n\t\t\t\t\t.join(\"\");\n\n\t\t\t\tlet addedText = \"\";\n\t\t\t\tif (finalText.startsWith(currentText)) {\n\t\t\t\t\taddedText = finalText.slice(currentText.length);\n\t\t\t\t} else if (!currentText.endsWith(finalText)) {\n\t\t\t\t\tconst needsGap = !/\\n\\n$/.test(currentText) && !/^\\n/.test(finalText);\n\t\t\t\t\taddedText = (needsGap ? \"\\n\\n\" : \"\") + finalText;\n\t\t\t\t}\n\n\t\t\t\tif (addedText) {\n\t\t\t\t\tconst last = res.at(-1);\n\t\t\t\t\tif (last?.type === \"text\") {\n\t\t\t\t\t\tlast.content += addedText;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tres.push({ type: \"text\" as const, content: addedText });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// If content remains unmatched (e.g., persisted stream markers), append the remainder\n\t\t// Skip when a FinalAnswer already provided the authoritative text.\n\t\tif (!sawFinalAnswer && message.content && contentCursor < message.content.length) {\n\t\t\tconst remaining = message.content.slice(contentCursor);\n\t\t\tif (remaining.length > 0) {\n\t\t\t\tconst last = res.at(-1);\n\t\t\t\tif (last?.type === \"text\") last.content += remaining;\n\t\t\t\telse res.push({ type: \"text\" as const, content: remaining });\n\t\t\t}\n\t\t} else if (!res.some((b) => b.type === \"text\") && message.content) {\n\t\t\t// Fallback: no text produced at all\n\t\t\tres.push({ type: \"text\" as const, content: message.content });\n\t\t}\n\n\t\treturn res;\n\t});\n\n\t$effect(() => {\n\t\tif (isCopied) {\n\t\t\tsetTimeout(() => {\n\t\t\t\tisCopied = false;\n\t\t\t}, 1000);\n\t\t}\n\t});\n\n\tlet editMode = $derived(editMsdgId === message.id);\n\t$effect(() => {\n\t\tif (editMode) {\n\t\t\ttick();\n\t\t\tif (editContentEl) {\n\t\t\t\teditContentEl.value = message.content;\n\t\t\t\teditContentEl?.focus();\n\t\t\t}\n\t\t}\n\t});\n</script>\n\n{#if message.from === \"assistant\"}\n\t<div\n\t\tbind:offsetWidth={messageWidth}\n\t\tclass=\"group relative -mb-4 flex w-fit max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&\n\t\tmessageInfoWidth >= messageWidth\n\t\t\t? 'mb-1'\n\t\t\t: ''}\"\n\t\tdata-message-id={message.id}\n\t\tdata-message-role=\"assistant\"\n\t\trole=\"presentation\"\n\t\tonclick={() => (isTapped = !isTapped)}\n\t\tonkeydown={() => (isTapped = !isTapped)}\n\t>\n\t\t<MessageAvatar\n\t\t\tclassNames=\"mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden\"\n\t\t\tanimating={isLast && loading}\n\t\t/>\n\t\t<div\n\t\t\tclass=\"relative flex min-w-[60px] flex-col gap-2 break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300\"\n\t\t>\n\t\t\t{#if message.files?.length}\n\t\t\t\t<div class=\"flex h-fit flex-wrap gap-x-5 gap-y-2\">\n\t\t\t\t\t{#each message.files as file (file.value)}\n\t\t\t\t\t\t<UploadedFile {file} canClose={false} />\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->\n\t\t\t<div bind:this={contentEl} oncopy={handleCopy} onclick={handleContentClick}>\n\t\t\t\t{#if isLast && loading && blocks.length === 0}\n\t\t\t\t\t<IconLoading classNames=\"loading inline ml-2 first:ml-0\" />\n\t\t\t\t{/if}\n\t\t\t\t{#each blocks as block, blockIndex (block.type === \"tool\" ? `${block.uuid}-${blockIndex}` : `text-${blockIndex}`)}\n\t\t\t\t\t{@const nextBlock = blocks[blockIndex + 1]}\n\t\t\t\t\t{@const nextBlockHasThink =\n\t\t\t\t\t\tnextBlock?.type === \"text\" && THINK_BLOCK_TEST_REGEX.test(nextBlock.content)}\n\t\t\t\t\t{@const nextIsLinkable = nextBlock?.type === \"tool\" || nextBlockHasThink}\n\t\t\t\t\t{#if block.type === \"tool\"}\n\t\t\t\t\t\t<div data-exclude-from-copy class=\"has-[+.prose]:mb-3 [.prose+&]:mt-4\">\n\t\t\t\t\t\t\t<ToolUpdate tool={block.updates} {loading} hasNext={nextIsLinkable} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else if block.type === \"text\"}\n\t\t\t\t\t\t{#if isLast && loading && block.content.length === 0}\n\t\t\t\t\t\t\t<IconLoading classNames=\"loading inline ml-2 first:ml-0\" />\n\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t{#if hasClientThink}\n\t\t\t\t\t\t\t{@const parts = block.content.split(THINK_BLOCK_REGEX)}\n\t\t\t\t\t\t\t{#each parts as part, partIndex}\n\t\t\t\t\t\t\t\t{@const remainingParts = parts.slice(partIndex + 1)}\n\t\t\t\t\t\t\t\t{@const hasMoreLinkable =\n\t\t\t\t\t\t\t\t\tremainingParts.some((p) => p && THINK_BLOCK_TEST_REGEX.test(p)) || nextIsLinkable}\n\t\t\t\t\t\t\t\t{#if part && part.startsWith(\"<think>\")}\n\t\t\t\t\t\t\t\t\t{@const isClosed = part.endsWith(\"</think>\")}\n\t\t\t\t\t\t\t\t\t{@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}\n\n\t\t\t\t\t\t\t\t\t<OpenReasoningResults\n\t\t\t\t\t\t\t\t\t\tcontent={thinkContent}\n\t\t\t\t\t\t\t\t\t\tloading={isLast && loading && !isClosed}\n\t\t\t\t\t\t\t\t\t\thasNext={hasMoreLinkable}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{:else if part && part.trim().length > 0}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclass=\"prose max-w-none dark:prose-invert prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:cursor-pointer prose-img:rounded-lg dark:prose-pre:bg-gray-900\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<MarkdownRenderer content={part} loading={isLast && loading} />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"prose max-w-none dark:prose-invert prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:cursor-pointer prose-img:rounded-lg dark:prose-pre:bg-gray-900\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<MarkdownRenderer content={block.content} loading={isLast && loading} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t{/if}\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t</div>\n\n\t\t{#if message.routerMetadata || (!loading && message.content)}\n\t\t\t<div\n\t\t\t\tclass=\"absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth\n\t\t\t\t\t? 'left-1 pl-1 lg:pl-7'\n\t\t\t\t\t: 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5\"\n\t\t\t\tbind:offsetWidth={messageInfoWidth}\n\t\t\t>\n\t\t\t\t{#if message.routerMetadata && (message.routerMetadata.route || message.routerMetadata.model || message.routerMetadata.provider) && (!isLast || !loading)}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"mr-2 flex items-center gap-1.5 truncate whitespace-nowrap text-[.65rem] text-gray-400 dark:text-gray-400 sm:text-xs\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if message.routerMetadata.route && message.routerMetadata.model}\n\t\t\t\t\t\t\t<span class=\"truncate rounded bg-gray-100 px-1 font-mono dark:bg-gray-800 sm:py-px\">\n\t\t\t\t\t\t\t\t{message.routerMetadata.route}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span class=\"text-gray-500\">with</span>\n\t\t\t\t\t\t\t{#if publicConfig.isHuggingChat}\n\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\thref=\"/chat/settings/{message.routerMetadata.model}\"\n\t\t\t\t\t\t\t\t\tclass=\"flex items-center gap-1 truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 sm:py-px\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{message.routerMetadata.model.split(\"/\").pop()}\n\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclass=\"truncate rounded bg-gray-100 px-1.5 font-mono dark:bg-gray-800 sm:py-px\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{message.routerMetadata.model.split(\"/\").pop()}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t{#if message.routerMetadata.provider}\n\t\t\t\t\t\t\t{@const hubOrg = PROVIDERS_HUB_ORGS[message.routerMetadata.provider]}\n\t\t\t\t\t\t\t<span class=\"text-gray-500 max-sm:hidden\">via</span>\n\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\thref=\"https://huggingface.co/{hubOrg}\"\n\t\t\t\t\t\t\t\tclass=\"flex items-center gap-1 truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 max-sm:hidden sm:py-px\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\talt=\"{message.routerMetadata.provider} logo\"\n\t\t\t\t\t\t\t\t\tclass=\"size-2.5 flex-none rounded-sm\"\n\t\t\t\t\t\t\t\t\tonerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = \"none\")}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{message.routerMetadata.provider}\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t\t{#if !isLast || !loading}\n\t\t\t\t\t<CopyToClipBoardBtn\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tisCopied = true;\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassNames=\"btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300\"\n\t\t\t\t\t\tvalue={contentWithoutThink}\n\t\t\t\t\t\ticonClassNames=\"text-xs\"\n\t\t\t\t\t/>\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"btn rounded-sm p-1 text-xs text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300\"\n\t\t\t\t\t\ttitle=\"Retry\"\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\tonretry?.({ id: message.id });\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<CarbonRotate360 />\n\t\t\t\t\t</button>\n\t\t\t\t\t{#if alternatives.length > 1 && editMsdgId === null}\n\t\t\t\t\t\t<Alternatives\n\t\t\t\t\t\t\t{message}\n\t\t\t\t\t\t\t{alternatives}\n\t\t\t\t\t\t\t{loading}\n\t\t\t\t\t\t\tonshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n\t{#if lightboxSrc}\n\t\t<ImageLightbox src={lightboxSrc} onclose={() => (lightboxSrc = null)} />\n\t{/if}\n{/if}\n{#if message.from === \"user\"}\n\t<div\n\t\tclass=\"group relative {alternatives.length > 1 && editMsdgId === null\n\t\t\t? 'mb-7'\n\t\t\t: ''} w-full items-start justify-start gap-4\"\n\t\tdata-message-id={message.id}\n\t\tdata-message-type=\"user\"\n\t\trole=\"presentation\"\n\t\tonclick={() => (isTapped = !isTapped)}\n\t\tonkeydown={() => (isTapped = !isTapped)}\n\t>\n\t\t<div class=\"flex w-full flex-col gap-2\">\n\t\t\t{#if message.files?.length}\n\t\t\t\t<div class=\"flex w-fit gap-4 px-5\">\n\t\t\t\t\t{#each message.files as file}\n\t\t\t\t\t\t<UploadedFile {file} canClose={false} />\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<div class=\"flex w-full flex-row flex-nowrap\">\n\t\t\t\t{#if !editMode}\n\t\t\t\t\t<p\n\t\t\t\t\t\tclass=\"disabled w-full appearance-none whitespace-break-spaces text-wrap break-words bg-inherit px-5 py-3.5 text-gray-500 dark:text-gray-400\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{message.content.trim()}\n\t\t\t\t\t</p>\n\t\t\t\t{:else}\n\t\t\t\t\t<form\n\t\t\t\t\t\tclass=\"mt-3 flex w-full flex-col\"\n\t\t\t\t\t\tbind:this={editFormEl}\n\t\t\t\t\t\tonsubmit={(e) => {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tonretry?.({ content: editContentEl?.value, id: message.id });\n\t\t\t\t\t\t\teditMsdgId = null;\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\tclass=\"w-full whitespace-break-spaces break-words rounded-xl bg-gray-100 px-5 py-3.5 text-gray-500 *:h-max focus:outline-none dark:bg-gray-800 dark:text-gray-400\"\n\t\t\t\t\t\t\trows=\"5\"\n\t\t\t\t\t\t\tbind:this={editContentEl}\n\t\t\t\t\t\t\tvalue={message.content.trim()}\n\t\t\t\t\t\t\tonkeydown={handleKeyDown}\n\t\t\t\t\t\t\trequired\n\t\t\t\t\t\t></textarea>\n\t\t\t\t\t\t<div class=\"flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2\">\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\tclass=\"btn rounded-lg px-3 py-1.5 text-sm\n                                {loading\n\t\t\t\t\t\t\t\t\t? 'bg-gray-300 text-gray-400 dark:bg-gray-700 dark:text-gray-600'\n\t\t\t\t\t\t\t\t\t: 'bg-gray-200 text-gray-600 hover:text-gray-800   focus:ring-0 dark:bg-gray-800 dark:text-gray-300 dark:hover:text-gray-200'}\n\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\tdisabled={loading}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tSend\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclass=\"btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300\"\n\t\t\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\t\t\teditMsdgId = null;\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</form>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t\t<div class=\"absolute -bottom-4 ml-3.5 flex w-full gap-1.5\">\n\t\t\t\t{#if alternatives.length > 1 && editMsdgId === null}\n\t\t\t\t\t<Alternatives\n\t\t\t\t\t\t{message}\n\t\t\t\t\t\t{alternatives}\n\t\t\t\t\t\t{loading}\n\t\t\t\t\t\tonshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\t\t\t\t{#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2\"\n\t\t\t\t\t\ttitle=\"Edit\"\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\tif (requireAuthUser()) return;\n\t\t\t\t\t\t\teditMsdgId = message.id;\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<CarbonPen />\n\t\t\t\t\t\tEdit\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n{/if}\n\n<style>\n\t@keyframes loading {\n\t\tto {\n\t\t\tstroke-dashoffset: 122.9;\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/chat/ChatWindow.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Message, MessageFile } from \"$lib/types/Message\";\n\timport { onDestroy } from \"svelte\";\n\n\timport IconOmni from \"$lib/components/icons/IconOmni.svelte\";\n\timport IconCheap from \"$lib/components/icons/IconCheap.svelte\";\n\timport IconFast from \"$lib/components/icons/IconFast.svelte\";\n\timport CarbonCaretDown from \"~icons/carbon/caret-down\";\n\timport { PROVIDERS_HUB_ORGS } from \"@huggingface/inference\";\n\timport CarbonDirectionRight from \"~icons/carbon/direction-right-01\";\n\timport IconArrowUp from \"~icons/lucide/arrow-up\";\n\timport IconMic from \"~icons/lucide/mic\";\n\n\timport ChatInput from \"./ChatInput.svelte\";\n\timport VoiceRecorder from \"./VoiceRecorder.svelte\";\n\timport StopGeneratingBtn from \"../StopGeneratingBtn.svelte\";\n\timport type { Model } from \"$lib/types/Model\";\n\timport FileDropzone from \"./FileDropzone.svelte\";\n\timport RetryBtn from \"../RetryBtn.svelte\";\n\timport file2base64 from \"$lib/utils/file2base64\";\n\timport { base } from \"$app/paths\";\n\timport ChatMessage from \"./ChatMessage.svelte\";\n\timport ScrollToBottomBtn from \"../ScrollToBottomBtn.svelte\";\n\timport ScrollToPreviousBtn from \"../ScrollToPreviousBtn.svelte\";\n\timport { browser } from \"$app/environment\";\n\timport { snapScrollToBottom } from \"$lib/actions/snapScrollToBottom\";\n\timport SystemPromptModal from \"../SystemPromptModal.svelte\";\n\timport ShareConversationModal from \"../ShareConversationModal.svelte\";\n\timport ChatIntroduction from \"./ChatIntroduction.svelte\";\n\timport UploadedFile from \"./UploadedFile.svelte\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport { error } from \"$lib/stores/errors\";\n\timport ModelSwitch from \"./ModelSwitch.svelte\";\n\timport { routerExamples } from \"$lib/constants/routerExamples\";\n\timport { mcpExamples } from \"$lib/constants/mcpExamples\";\n\timport type { RouterFollowUp, RouterExample } from \"$lib/constants/routerExamples\";\n\timport { allBaseServersEnabled, mcpServersLoaded } from \"$lib/stores/mcpServers\";\n\timport { shareModal } from \"$lib/stores/shareModal\";\n\timport { pendingChatInput } from \"$lib/stores/pendingChatInput\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\n\timport { fly } from \"svelte/transition\";\n\timport { cubicInOut } from \"svelte/easing\";\n\n\timport { isVirtualKeyboard } from \"$lib/utils/isVirtualKeyboard\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport { tap, error as hapticError } from \"$lib/utils/haptics\";\n\timport { page } from \"$app/state\";\n\timport {\n\t\tisMessageToolCallUpdate,\n\t\tisMessageToolErrorUpdate,\n\t\tisMessageToolResultUpdate,\n\t} from \"$lib/utils/messageUpdates\";\n\timport type { ToolFront } from \"$lib/types/Tool\";\n\n\tinterface Props {\n\t\tmessages?: Message[];\n\t\tmessagesAlternatives?: Message[\"id\"][][];\n\t\tloading?: boolean;\n\t\tpending?: boolean;\n\t\tshared?: boolean;\n\t\tcurrentModel: Model;\n\t\tmodels: Model[];\n\t\tpreprompt?: string | undefined;\n\t\tfiles?: File[];\n\t\tonmessage?: (content: string) => void;\n\t\tonstop?: () => void;\n\t\tonretry?: (payload: { id: Message[\"id\"]; content?: string }) => void;\n\t\tonshowAlternateMsg?: (payload: { id: Message[\"id\"] }) => void;\n\t\tdraft?: string;\n\t}\n\n\tlet {\n\t\tmessages = [],\n\t\tmessagesAlternatives = [],\n\t\tloading = false,\n\t\tpending = false,\n\t\tshared = false,\n\t\tcurrentModel,\n\t\tmodels,\n\t\tpreprompt = undefined,\n\t\tfiles = $bindable([]),\n\t\tdraft = $bindable(\"\"),\n\t\tonmessage,\n\t\tonstop,\n\t\tonretry,\n\t\tonshowAlternateMsg,\n\t}: Props = $props();\n\n\tlet isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));\n\n\tlet shareModalOpen = $state(false);\n\tlet editMsdgId: Message[\"id\"] | null = $state(null);\n\tlet pastedLongContent = $state(false);\n\n\t// Voice recording state\n\tlet isRecording = $state(false);\n\tlet isTranscribing = $state(false);\n\tlet transcriptionEnabled = $derived(\n\t\t!!(page.data as { transcriptionEnabled?: boolean }).transcriptionEnabled\n\t);\n\tlet isTouchDevice = $derived(browser && navigator.maxTouchPoints > 0);\n\n\tconst handleSubmit = () => {\n\t\tif (requireAuthUser() || loading || !draft) return;\n\t\ttap();\n\t\tonmessage?.(draft);\n\t\tdraft = \"\";\n\t};\n\n\tlet lastTarget: EventTarget | null = null;\n\n\tlet onDrag = $state(false);\n\n\tconst onDragEnter = (e: DragEvent) => {\n\t\tlastTarget = e.target;\n\t\tonDrag = true;\n\t};\n\tconst onDragLeave = (e: DragEvent) => {\n\t\tif (e.target === lastTarget) {\n\t\t\tonDrag = false;\n\t\t}\n\t};\n\n\tconst onPaste = (e: ClipboardEvent) => {\n\t\tconst textContent = e.clipboardData?.getData(\"text\");\n\n\t\tif (!$settings.directPaste && textContent && textContent.length >= 3984) {\n\t\t\te.preventDefault();\n\t\t\tpastedLongContent = true;\n\t\t\tsetTimeout(() => {\n\t\t\t\tpastedLongContent = false;\n\t\t\t}, 1000);\n\t\t\tconst pastedFile = new File([textContent], \"Pasted Content\", {\n\t\t\t\ttype: \"application/vnd.chatui.clipboard\",\n\t\t\t});\n\n\t\t\tfiles = [...files, pastedFile];\n\t\t}\n\n\t\tif (!e.clipboardData) {\n\t\t\treturn;\n\t\t}\n\n\t\t// paste of files\n\t\tconst pastedFiles = Array.from(e.clipboardData.files);\n\t\tif (pastedFiles.length !== 0) {\n\t\t\te.preventDefault();\n\n\t\t\t// filter based on activeMimeTypes, including wildcards\n\t\t\tconst filteredFiles = pastedFiles.filter((file) => {\n\t\t\t\treturn activeMimeTypes.some((mimeType: string) => {\n\t\t\t\t\tconst [type, subtype] = mimeType.split(\"/\");\n\t\t\t\t\tconst [fileType, fileSubtype] = file.type.split(\"/\");\n\t\t\t\t\treturn (\n\t\t\t\t\t\t(type === \"*\" || fileType === type) && (subtype === \"*\" || fileSubtype === subtype)\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tfiles = [...files, ...filteredFiles];\n\t\t}\n\t};\n\n\tlet lastMessage = $derived(browser && (messages.at(-1) as Message));\n\t// Scroll signal includes tool updates and thinking blocks to trigger scroll on all content changes\n\tlet scrollSignal = $derived.by(() => {\n\t\tconst last = messages.at(-1) as Message | undefined;\n\t\tif (!last) return `${messages.length}:0`;\n\n\t\t// Count tool updates to trigger scroll when new tools are called or complete\n\t\tconst toolUpdateCount = last.updates?.length ?? 0;\n\n\t\t// Include content length, tool count, and message count in signal\n\t\treturn `${last.id}:${last.content.length}:${messages.length}:${toolUpdateCount}`;\n\t});\n\tlet streamingAssistantMessage = $derived(\n\t\t(() => {\n\t\t\tfor (let i = messages.length - 1; i >= 0; i -= 1) {\n\t\t\t\tconst candidate = messages[i];\n\t\t\t\tif (candidate.from === \"assistant\") {\n\t\t\t\t\treturn candidate;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn undefined;\n\t\t})()\n\t);\n\tlet streamingRouterMetadata = $derived(streamingAssistantMessage?.routerMetadata ?? null);\n\tlet streamingRouterModelName = $derived(\n\t\tstreamingRouterMetadata?.model\n\t\t\t? (streamingRouterMetadata.model.split(\"/\").pop() ?? streamingRouterMetadata.model)\n\t\t\t: \"\"\n\t);\n\n\tlet lastIsError = $derived(\n\t\t!loading &&\n\t\t\t(streamingAssistantMessage?.updates?.findIndex(\n\t\t\t\t(u) => u.type === \"status\" && u.status === \"error\"\n\t\t\t) ?? -1) !== -1\n\t);\n\n\t// Expose currently running tool call name (if any) from the streaming assistant message\n\tconst availableTools: ToolFront[] = $derived.by(\n\t\t() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []\n\t);\n\tlet streamingToolCallName = $derived.by(() => {\n\t\tconst updates = streamingAssistantMessage?.updates ?? [];\n\t\tif (!updates.length) return null;\n\t\tconst done = new Set<string>();\n\t\tfor (const u of updates) {\n\t\t\tif (isMessageToolResultUpdate(u) || isMessageToolErrorUpdate(u)) done.add(u.uuid);\n\t\t}\n\t\tfor (let i = updates.length - 1; i >= 0; i -= 1) {\n\t\t\tconst u = updates[i];\n\t\t\tif (isMessageToolCallUpdate(u) && !done.has(u.uuid)) {\n\t\t\t\treturn u.call.name;\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t});\n\tlet showRouterDetails = $state(false);\n\tlet routerDetailsTimeout: ReturnType<typeof setTimeout> | undefined;\n\n\t$effect(() => {\n\t\tif (!currentModel.isRouter || !loading) {\n\t\t\tshowRouterDetails = false;\n\t\t\tif (routerDetailsTimeout) {\n\t\t\t\tclearTimeout(routerDetailsTimeout);\n\t\t\t\trouterDetailsTimeout = undefined;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (routerDetailsTimeout) {\n\t\t\tclearTimeout(routerDetailsTimeout);\n\t\t}\n\n\t\tshowRouterDetails = false;\n\t\trouterDetailsTimeout = setTimeout(() => {\n\t\t\tshowRouterDetails = true;\n\t\t}, 500);\n\t});\n\n\tlet sources = $derived(\n\t\tfiles?.map<Promise<MessageFile>>((file) =>\n\t\t\tfile2base64(file).then((value) => ({\n\t\t\t\ttype: \"base64\",\n\t\t\t\tvalue,\n\t\t\t\tmime: file.type,\n\t\t\t\tname: file.name,\n\t\t\t}))\n\t\t)\n\t);\n\n\tconst unsubscribeShareModal = shareModal.subscribe((value) => {\n\t\tshareModalOpen = value;\n\t});\n\n\tonDestroy(() => {\n\t\tunsubscribeShareModal();\n\t\tshareModal.close();\n\t\tif (routerDetailsTimeout) {\n\t\t\tclearTimeout(routerDetailsTimeout);\n\t\t}\n\t});\n\n\tlet chatContainer: HTMLElement | undefined = $state();\n\n\t// Force scroll to bottom when user sends a new message\n\t// Pattern: user message + empty assistant message are added together\n\tlet prevMessageCount = $state(0);\n\tlet forceReattach = $state(0);\n\t$effect(() => {\n\t\tif (messages.length > prevMessageCount) {\n\t\t\tconst last = messages.at(-1);\n\t\t\tconst secondLast = messages.at(-2);\n\t\t\tconst userJustSentMessage =\n\t\t\t\tmessages.length === prevMessageCount + 2 &&\n\t\t\t\tsecondLast?.from === \"user\" &&\n\t\t\t\tlast?.from === \"assistant\" &&\n\t\t\t\tlast?.content === \"\";\n\n\t\t\tif (userJustSentMessage) {\n\t\t\t\tforceReattach++;\n\t\t\t}\n\t\t}\n\t\tprevMessageCount = messages.length;\n\t});\n\n\t// Combined scroll dependency for the action\n\tlet scrollDependency = $derived({ signal: scrollSignal, forceReattach });\n\n\tconst settings = useSettingsStore();\n\tlet hideRouterExamples = $derived($settings.hidePromptExamples?.[currentModel.id] ?? false);\n\n\t// Respect per‑model multimodal toggle from settings (force enable)\n\tlet modelIsMultimodalOverride = $derived($settings.multimodalOverrides?.[currentModel.id]);\n\tlet modelIsMultimodal = $derived((modelIsMultimodalOverride ?? currentModel.multimodal) === true);\n\n\t// Determine tool support for the current model (server-provided capability with user override)\n\tlet modelSupportsTools = $derived(\n\t\t($settings.toolsOverrides?.[currentModel.id] ??\n\t\t\t(currentModel as unknown as { supportsTools?: boolean }).supportsTools) === true\n\t);\n\n\t// Get provider override for the current model (HuggingChat only)\n\tlet providerOverride = $derived($settings.providerOverrides?.[currentModel.id]);\n\tlet hasProviderOverride = $derived(\n\t\tproviderOverride && providerOverride !== \"auto\" && !currentModel.isRouter\n\t);\n\n\t// Always allow common text-like files; add images only when model is multimodal\n\timport { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from \"$lib/constants/mime\";\n\n\tlet activeMimeTypes = $derived(\n\t\tArray.from(\n\t\t\tnew Set([\n\t\t\t\t...TEXT_MIME_ALLOWLIST,\n\t\t\t\t...(modelIsMultimodal\n\t\t\t\t\t? (currentModel.multimodalAcceptedMimetypes ?? [...IMAGE_MIME_ALLOWLIST_DEFAULT])\n\t\t\t\t\t: []),\n\t\t\t])\n\t\t)\n\t);\n\tlet isFileUploadEnabled = $derived(activeMimeTypes.length > 0);\n\tlet focused = $state(false);\n\n\tlet activeRouterExamplePrompt = $state<string | null>(null);\n\t// Use MCP examples when all base servers are enabled, otherwise use router examples\n\tlet activeExamples = $derived<RouterExample[]>(\n\t\t$allBaseServersEnabled ? mcpExamples : routerExamples\n\t);\n\tlet routerFollowUps = $derived<RouterFollowUp[]>(\n\t\tactiveRouterExamplePrompt\n\t\t\t? (activeExamples.find((ex) => ex.prompt === activeRouterExamplePrompt)?.followUps ?? [])\n\t\t\t: []\n\t);\n\tlet routerUserMessages = $derived(messages.filter((msg) => msg.from === \"user\"));\n\tlet shouldShowRouterFollowUps = $derived(\n\t\t!draft.length &&\n\t\t\tactiveRouterExamplePrompt &&\n\t\t\trouterFollowUps.length > 0 &&\n\t\t\trouterUserMessages.length === 1 &&\n\t\t\t(currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) &&\n\t\t\t!hideRouterExamples &&\n\t\t\t!loading\n\t);\n\n\t$effect(() => {\n\t\tif (\n\t\t\t!(currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) ||\n\t\t\t!messages.length\n\t\t) {\n\t\t\tactiveRouterExamplePrompt = null;\n\t\t\treturn;\n\t\t}\n\n\t\tconst firstUserMessage = messages.find((msg) => msg.from === \"user\");\n\t\tif (!firstUserMessage) {\n\t\t\tactiveRouterExamplePrompt = null;\n\t\t\treturn;\n\t\t}\n\n\t\tconst match = activeExamples.find((ex) => ex.prompt.trim() === firstUserMessage.content.trim());\n\t\tactiveRouterExamplePrompt = match ? match.prompt : null;\n\t});\n\n\t$effect(() => {\n\t\tif ($pendingChatInput) {\n\t\t\tdraft = $pendingChatInput;\n\t\t\tpendingChatInput.set(undefined);\n\t\t}\n\t});\n\n\tfunction triggerPrompt(prompt: string) {\n\t\tif (requireAuthUser() || loading) return;\n\t\tdraft = prompt;\n\t\thandleSubmit();\n\t}\n\n\tasync function startExample(example: RouterExample) {\n\t\tif (requireAuthUser()) return;\n\t\tactiveRouterExamplePrompt = example.prompt;\n\n\t\tif (browser && example.attachments?.length) {\n\t\t\tconst loadedFiles: File[] = [];\n\t\t\tfor (const attachment of example.attachments) {\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(`${base}/${attachment.src}`);\n\t\t\t\t\tif (!response.ok) continue;\n\n\t\t\t\t\tconst blob = await response.blob();\n\t\t\t\t\tconst name = attachment.src.split(\"/\").pop() ?? \"attachment\";\n\t\t\t\t\tloadedFiles.push(\n\t\t\t\t\t\tnew File([blob], name, { type: blob.type || \"application/octet-stream\" })\n\t\t\t\t\t);\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconsole.error(\"Error loading attachment:\", err);\n\t\t\t\t}\n\t\t\t}\n\t\t\tfiles = loadedFiles;\n\t\t}\n\n\t\ttriggerPrompt(example.prompt);\n\t}\n\n\tfunction startFollowUp(followUp: RouterFollowUp) {\n\t\ttriggerPrompt(followUp.prompt);\n\t}\n\n\tasync function handleRecordingConfirm(audioBlob: Blob) {\n\t\tisRecording = false;\n\t\tisTranscribing = true;\n\n\t\ttry {\n\t\t\tconst response = await fetch(`${base}/api/transcribe`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"Content-Type\": audioBlob.type },\n\t\t\t\tbody: audioBlob,\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(await response.text());\n\t\t\t}\n\n\t\t\tconst { text } = await response.json();\n\t\t\tconst trimmedText = text?.trim();\n\t\t\tif (trimmedText) {\n\t\t\t\t// Append transcribed text to draft\n\t\t\t\tdraft = draft.trim() ? `${draft.trim()} ${trimmedText}` : trimmedText;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Transcription error:\", err);\n\t\t\t$error = \"Transcription failed. Please try again.\";\n\t\t} finally {\n\t\t\tisTranscribing = false;\n\t\t}\n\t}\n\n\tasync function handleRecordingSend(audioBlob: Blob) {\n\t\tisRecording = false;\n\t\tisTranscribing = true;\n\n\t\ttry {\n\t\t\tconst response = await fetch(`${base}/api/transcribe`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"Content-Type\": audioBlob.type },\n\t\t\t\tbody: audioBlob,\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(await response.text());\n\t\t\t}\n\n\t\t\tconst { text } = await response.json();\n\t\t\tconst trimmedText = text?.trim();\n\t\t\tif (trimmedText) {\n\t\t\t\t// Set draft and send immediately\n\t\t\t\tdraft = draft.trim() ? `${draft.trim()} ${trimmedText}` : trimmedText;\n\t\t\t\thandleSubmit();\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Transcription error:\", err);\n\t\t\t$error = \"Transcription failed. Please try again.\";\n\t\t} finally {\n\t\t\tisTranscribing = false;\n\t\t}\n\t}\n\n\tfunction handleRecordingError(message: string) {\n\t\tconsole.error(\"Recording error:\", message);\n\t\tisRecording = false;\n\t\t$error = message;\n\t}\n</script>\n\n<svelte:window\n\tondragenter={onDragEnter}\n\tondragleave={onDragLeave}\n\tondragover={(e) => {\n\t\te.preventDefault();\n\t}}\n\tondrop={(e) => {\n\t\te.preventDefault();\n\t\tonDrag = false;\n\t}}\n/>\n\n<div class=\"relative z-[-1] min-h-0 min-w-0\">\n\t{#if shareModalOpen}\n\t\t<ShareConversationModal open={shareModalOpen} onclose={() => shareModal.close()} />\n\t{/if}\n\t<div\n\t\tclass=\"scrollbar-custom h-full overflow-y-auto\"\n\t\tuse:snapScrollToBottom={scrollDependency}\n\t\tbind:this={chatContainer}\n\t>\n\t\t<div\n\t\t\tclass=\"mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl xl:pt-10\"\n\t\t>\n\t\t\t{#if preprompt && preprompt != currentModel.preprompt}\n\t\t\t\t<SystemPromptModal preprompt={preprompt ?? \"\"} />\n\t\t\t{/if}\n\n\t\t\t{#if messages.length > 0}\n\t\t\t\t<div class=\"flex h-max flex-col gap-8 pb-52\">\n\t\t\t\t\t{#each messages as message, idx (message.id)}\n\t\t\t\t\t\t<ChatMessage\n\t\t\t\t\t\t\t{loading}\n\t\t\t\t\t\t\t{message}\n\t\t\t\t\t\t\talternatives={messagesAlternatives.find((a) => a.includes(message.id)) ?? []}\n\t\t\t\t\t\t\tisAuthor={!shared}\n\t\t\t\t\t\t\treadOnly={isReadOnly}\n\t\t\t\t\t\t\tisLast={idx === messages.length - 1}\n\t\t\t\t\t\t\tbind:editMsdgId\n\t\t\t\t\t\t\tonretry={(payload) => onretry?.(payload)}\n\t\t\t\t\t\t\tonshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t{/each}\n\t\t\t\t\t{#if isReadOnly}\n\t\t\t\t\t\t<ModelSwitch {models} {currentModel} />\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t{:else if pending}\n\t\t\t\t<ChatMessage\n\t\t\t\t\tloading={true}\n\t\t\t\t\tmessage={{\n\t\t\t\t\t\tid: \"0-0-0-0-0\",\n\t\t\t\t\t\tcontent: \"\",\n\t\t\t\t\t\tfrom: \"assistant\",\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}}\n\t\t\t\t\tisAuthor={!shared}\n\t\t\t\t\treadOnly={isReadOnly}\n\t\t\t\t/>\n\t\t\t{:else}\n\t\t\t\t<ChatIntroduction\n\t\t\t\t\t{currentModel}\n\t\t\t\t\tonmessage={(content) => {\n\t\t\t\t\t\tonmessage?.(content);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<ScrollToPreviousBtn class=\"fixed bottom-48 right-4 lg:right-10\" scrollNode={chatContainer} />\n\n\t\t<ScrollToBottomBtn class=\"fixed bottom-36 right-4 lg:right-10\" scrollNode={chatContainer} />\n\t</div>\n\n\t<div\n\t\tclass=\"pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full\n\t\t\tmax-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white\n\t\t\tvia-white/100 to-white/0 px-3.5 pt-2 dark:border-gray-800\n\t\t\tdark:from-gray-900 dark:via-gray-900/100\n\t\t\tdark:to-gray-900/0 max-sm:py-0 sm:px-5 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto\"\n\t>\n\t\t{#if !draft.length && !messages.length && !sources.length && !loading && (currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) && activeExamples.length && !hideRouterExamples && !lastIsError && $mcpServersLoaded}\n\t\t\t<div\n\t\t\t\tclass=\"no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500\"\n\t\t\t>\n\t\t\t\t{#each activeExamples as ex}\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"flex items-center rounded-lg bg-gray-100/90 px-2 py-0.5 text-center text-sm backdrop-blur hover:text-gray-500 dark:bg-gray-700/50 dark:hover:text-gray-400\"\n\t\t\t\t\t\tonclick={() => startExample(ex)}>{ex.title}</button\n\t\t\t\t\t>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\t\t{#if shouldShowRouterFollowUps && !lastIsError}\n\t\t\t<div\n\t\t\t\tclass=\"no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500\"\n\t\t\t>\n\t\t\t\t<!-- <span class=\" text-gray-500 dark:text-gray-400\">Follow ups</span> -->\n\t\t\t\t{#each routerFollowUps as followUp}\n\t\t\t\t\t<button\n\t\t\t\t\t\tclass=\"flex items-center gap-1 rounded-lg bg-gray-100/90 px-2 py-0.5 text-center text-sm backdrop-blur hover:text-gray-500 dark:bg-gray-700/50 dark:hover:text-gray-400\"\n\t\t\t\t\t\tonclick={() => startFollowUp(followUp)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<CarbonDirectionRight class=\"scale-y-[-1] text-xs\" />\n\t\t\t\t\t\t{followUp.title}</button\n\t\t\t\t\t>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\t\t{#if sources?.length && !loading}\n\t\t\t<div\n\t\t\t\tin:fly|local={sources.length === 1 ? { y: -20, easing: cubicInOut } : undefined}\n\t\t\t\tclass=\"flex flex-row flex-wrap justify-center gap-2.5 rounded-xl pb-3\"\n\t\t\t>\n\t\t\t\t{#each sources as source, index}\n\t\t\t\t\t{#await source then src}\n\t\t\t\t\t\t<UploadedFile\n\t\t\t\t\t\t\tfile={src}\n\t\t\t\t\t\t\tonclose={() => {\n\t\t\t\t\t\t\t\tfiles = files.filter((_, i) => i !== index);\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t{/await}\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<div class=\"w-full\">\n\t\t\t<div class=\"flex w-full *:mb-3\">\n\t\t\t\t{#if !loading && lastIsError}\n\t\t\t\t\t<RetryBtn\n\t\t\t\t\t\tclassNames=\"ml-auto\"\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (lastMessage && lastMessage.ancestors) {\n\t\t\t\t\t\t\t\tonretry?.({\n\t\t\t\t\t\t\t\t\tid: lastMessage.id,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t\t<form\n\t\t\t\ttabindex=\"-1\"\n\t\t\t\taria-label={isFileUploadEnabled ? \"file dropzone\" : undefined}\n\t\t\t\tonsubmit={(e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\thandleSubmit();\n\t\t\t\t}}\n\t\t\t\tclass={{\n\t\t\t\t\t\"relative flex w-full max-w-4xl flex-1 items-center rounded-xl border bg-gray-100 dark:border-gray-700 dark:bg-gray-800\": true,\n\t\t\t\t\t\"opacity-30\": isReadOnly,\n\t\t\t\t\t\"max-sm:mb-4\": focused && isVirtualKeyboard(),\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{#if isRecording || isTranscribing}\n\t\t\t\t\t<VoiceRecorder\n\t\t\t\t\t\t{isTranscribing}\n\t\t\t\t\t\t{isTouchDevice}\n\t\t\t\t\t\toncancel={() => {\n\t\t\t\t\t\t\tisRecording = false;\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonconfirm={handleRecordingConfirm}\n\t\t\t\t\t\tonsend={handleRecordingSend}\n\t\t\t\t\t\tonerror={handleRecordingError}\n\t\t\t\t\t/>\n\t\t\t\t{:else if onDrag && isFileUploadEnabled}\n\t\t\t\t\t<FileDropzone bind:files bind:onDrag mimeTypes={activeMimeTypes} />\n\t\t\t\t{:else}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"flex w-full flex-1 rounded-xl border-none bg-transparent\"\n\t\t\t\t\t\tclass:paste-glow={pastedLongContent}\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if lastIsError}\n\t\t\t\t\t\t\t<ChatInput value=\"Sorry, something went wrong. Please try again.\" disabled={true} />\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<ChatInput\n\t\t\t\t\t\t\t\tplaceholder={isReadOnly ? \"This conversation is read-only.\" : \"Ask anything\"}\n\t\t\t\t\t\t\t\t{loading}\n\t\t\t\t\t\t\t\tbind:value={draft}\n\t\t\t\t\t\t\t\tbind:files\n\t\t\t\t\t\t\t\tmimeTypes={activeMimeTypes}\n\t\t\t\t\t\t\t\tonsubmit={handleSubmit}\n\t\t\t\t\t\t\t\t{onPaste}\n\t\t\t\t\t\t\t\tdisabled={isReadOnly || lastIsError}\n\t\t\t\t\t\t\t\t{modelIsMultimodal}\n\t\t\t\t\t\t\t\t{modelSupportsTools}\n\t\t\t\t\t\t\t\tbind:focused\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t{#if loading}\n\t\t\t\t\t\t\t<StopGeneratingBtn\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\thapticError();\n\t\t\t\t\t\t\t\t\tonstop?.();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tshowBorder={true}\n\t\t\t\t\t\t\t\tclassNames=\"absolute bottom-2 right-2 size-8 sm:size-7 self-end rounded-full border bg-white text-black shadow transition-none dark:border-transparent dark:bg-gray-600 dark:text-white\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t{#if transcriptionEnabled}\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tclass=\"btn absolute bottom-2 right-10 mr-1.5 size-8 self-end rounded-full border bg-white/50 text-gray-500 transition-none hover:bg-gray-50 hover:text-gray-700 dark:border-transparent dark:bg-gray-600/50 dark:text-gray-300 dark:hover:bg-gray-500 dark:hover:text-white sm:right-9 sm:size-7\"\n\t\t\t\t\t\t\t\t\tdisabled={isReadOnly}\n\t\t\t\t\t\t\t\t\tonclick={() => {\n\t\t\t\t\t\t\t\t\t\tisRecording = true;\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\taria-label=\"Start voice recording\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<IconMic class=\"size-4\" />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclass=\"btn absolute bottom-2 right-2 size-8 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black sm:size-7 {!draft ||\n\t\t\t\t\t\t\t\tisReadOnly\n\t\t\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t\t\t: '!bg-black !text-white dark:!bg-white dark:!text-black'}\"\n\t\t\t\t\t\t\t\tdisabled={!draft || isReadOnly}\n\t\t\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\t\t\taria-label=\"Send message\"\n\t\t\t\t\t\t\t\tname=\"submit\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IconArrowUp />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</form>\n\t\t\t<div\n\t\t\t\tclass={{\n\t\t\t\t\t\"mt-1.5 flex h-5 items-center self-stretch whitespace-nowrap px-0.5 text-xs text-gray-400/90 max-md:mb-2 max-sm:gap-2\": true,\n\t\t\t\t\t\"max-sm:hidden\": focused && isVirtualKeyboard(),\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{#if models.find((m) => m.id === currentModel.id)}\n\t\t\t\t\t{#if loading && streamingToolCallName}\n\t\t\t\t\t\t<span class=\"inline-flex items-center gap-1 whitespace-nowrap text-xs\">\n\t\t\t\t\t\t\t<LucideHammer class=\"size-3\" />\n\t\t\t\t\t\t\tCalling tool\n\t\t\t\t\t\t\t<span class=\"loading-dots font-medium\">\n\t\t\t\t\t\t\t\t{availableTools.find((t) => t.name === streamingToolCallName)?.displayName ??\n\t\t\t\t\t\t\t\t\tstreamingToolCallName}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t{:else if !currentModel.isRouter || !loading}\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref=\"{base}/settings/{currentModel.id}\"\n\t\t\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\t\t\tif (requireAuthUser()) {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclass=\"inline-flex items-center gap-1 hover:underline\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{#if currentModel.isRouter}\n\t\t\t\t\t\t\t\t<IconOmni />\n\t\t\t\t\t\t\t\t{currentModel.displayName}\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\tModel: {currentModel.displayName}\n\t\t\t\t\t\t\t\t{#if hasProviderOverride}\n\t\t\t\t\t\t\t\t\t{@const hubOrg =\n\t\t\t\t\t\t\t\t\t\tPROVIDERS_HUB_ORGS[providerOverride as keyof typeof PROVIDERS_HUB_ORGS]}\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"inline-flex shrink-0 items-center rounded p-0.5 {providerOverride ===\n\t\t\t\t\t\t\t\t\t\t'fastest'\n\t\t\t\t\t\t\t\t\t\t\t? 'bg-green-100 text-green-600 dark:bg-green-800/20 dark:text-green-500'\n\t\t\t\t\t\t\t\t\t\t\t: providerOverride === 'cheapest'\n\t\t\t\t\t\t\t\t\t\t\t\t? 'bg-blue-100 text-blue-600 dark:bg-blue-800/20 dark:text-blue-500'\n\t\t\t\t\t\t\t\t\t\t\t\t: ''}\"\n\t\t\t\t\t\t\t\t\t\ttitle=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{#if providerOverride === \"fastest\"}\n\t\t\t\t\t\t\t\t\t\t\t<IconFast classNames=\"text-sm\" />\n\t\t\t\t\t\t\t\t\t\t{:else if providerOverride === \"cheapest\"}\n\t\t\t\t\t\t\t\t\t\t\t<IconCheap classNames=\"text-sm\" />\n\t\t\t\t\t\t\t\t\t\t{:else if hubOrg}\n\t\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\t\t\t\talt={providerOverride}\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"size-3 flex-none rounded-sm\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t<CarbonCaretDown class=\"-ml-0.5 text-xxs\" />\n\t\t\t\t\t\t</a>\n\t\t\t\t\t{:else if showRouterDetails && streamingRouterMetadata?.route}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"mr-2 flex items-center gap-1.5 whitespace-nowrap text-[.70rem] text-xs leading-none text-gray-400 dark:text-gray-400\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconOmni classNames=\"text-xs animate-pulse\" />\n\n\t\t\t\t\t\t\t<span class=\"router-badge-text router-shimmer\">\n\t\t\t\t\t\t\t\t{streamingRouterMetadata.route}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<span class=\"text-gray-500\">with</span>\n\n\t\t\t\t\t\t\t<span class=\"router-badge-text\">\n\t\t\t\t\t\t\t\t{streamingRouterModelName}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"loading-dots relative inline-flex items-center text-gray-400 dark:text-gray-400\"\n\t\t\t\t\t\t\taria-label=\"Routing…\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconOmni classNames=\"text-xs animate-pulse mr-1\" /> Routing\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t{:else}\n\t\t\t\t\t<span class=\"inline-flex items-center line-through dark:border-gray-700\">\n\t\t\t\t\t\t{currentModel.id}\n\t\t\t\t\t</span>\n\t\t\t\t{/if}\n\t\t\t\t{#if !messages.length && !loading}\n\t\t\t\t\t<span class=\"max-sm:hidden\">Generated content may be inaccurate or false.</span>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>\n\n<style lang=\"postcss\">\n\t.paste-glow {\n\t\tanimation: glow 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;\n\t\twill-change: box-shadow;\n\t}\n\n\t@keyframes glow {\n\t\t0% {\n\t\t\tbox-shadow: 0 0 0 0 rgba(59, 130, 246, 0.8);\n\t\t}\n\t\t50% {\n\t\t\tbox-shadow: 0 0 20px 4px rgba(59, 130, 246, 0.6);\n\t\t}\n\t\t100% {\n\t\t\tbox-shadow: 0 0 0 0 rgba(59, 130, 246, 0);\n\t\t}\n\t}\n\n\t.router-badge-text {\n\t\tdisplay: inline-block;\n\t\tposition: relative;\n\t\tcolor: inherit;\n\t}\n\n\t.router-shimmer {\n\t\tdisplay: inline-block;\n\t\tbackground-image: linear-gradient(\n\t\t\t90deg,\n\t\t\trgba(156, 163, 175, 1) 0%,\n\t\t\trgba(156, 163, 175, 0.6) 10%,\n\t\t\trgba(156, 163, 175, 0.6) 50%,\n\t\t\trgba(156, 163, 175, 0.6) 90%,\n\t\t\trgba(156, 163, 175, 1) 100%\n\t\t);\n\t\tbackground-size: 220% 100%;\n\t\tanimation: router-shimmer 2.8s linear infinite;\n\t\tbackground-clip: text;\n\t\t-webkit-background-clip: text;\n\t\tcolor: transparent;\n\t\t-webkit-text-fill-color: transparent;\n\t}\n\n\t:global(.dark) .router-shimmer {\n\t\tbackground-image: linear-gradient(\n\t\t\t90deg,\n\t\t\trgba(255, 255, 255, 0.15) 0%,\n\t\t\trgba(255, 255, 255, 0.7) 50%,\n\t\t\trgba(255, 255, 255, 0.15) 100%\n\t\t);\n\t}\n\n\t@keyframes router-shimmer {\n\t\t0% {\n\t\t\tbackground-position: 200% 0;\n\t\t}\n\t\t100% {\n\t\t\tbackground-position: -200% 0;\n\t\t}\n\t}\n\n\t.loading-dots::after {\n\t\tcontent: \"\";\n\t\tanimation: dots-content 0.9s steps(1, end) infinite;\n\t}\n\t@keyframes dots-content {\n\t\t0% {\n\t\t\tcontent: \"\";\n\t\t}\n\t\t33% {\n\t\t\tcontent: \".\";\n\t\t}\n\t\t66% {\n\t\t\tcontent: \"..\";\n\t\t}\n\t\t88% {\n\t\t\tcontent: \"...\";\n\t\t}\n\t}\n</style>\n"
  },
  {
    "path": "src/lib/components/chat/FileDropzone.svelte",
    "content": "<script lang=\"ts\">\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\timport CarbonImage from \"~icons/carbon/image\";\n\n\tinterface Props {\n\t\t// import EosIconsLoading from \"~icons/eos-icons/loading\";\n\t\tfiles: File[];\n\t\tmimeTypes?: string[];\n\t\tonDrag?: boolean;\n\t\tonDragInner?: boolean;\n\t}\n\n\tlet {\n\t\tfiles = $bindable(),\n\t\tmimeTypes = [],\n\t\tonDrag = $bindable(false),\n\t\tonDragInner = $bindable(false),\n\t}: Props = $props();\n\n\tasync function dropHandle(event: DragEvent) {\n\t\tevent.preventDefault();\n\t\tif (!requireAuthUser() && event.dataTransfer && event.dataTransfer.items) {\n\t\t\t// Use DataTransferItemList interface to access the file(s)\n\t\t\tif (files.length > 0) {\n\t\t\t\tfiles = [];\n\t\t\t}\n\t\t\tif (event.dataTransfer.items[0].kind === \"file\") {\n\t\t\t\tfor (let i = 0; i < event.dataTransfer.items.length; i++) {\n\t\t\t\t\tconst file = event.dataTransfer.items[i].getAsFile();\n\n\t\t\t\t\tif (file) {\n\t\t\t\t\t\t// check if the file matches the mimeTypes\n\t\t\t\t\t\t// else abort\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t!mimeTypes.some((mimeType: string) => {\n\t\t\t\t\t\t\t\tconst [type, subtype] = mimeType.split(\"/\");\n\t\t\t\t\t\t\t\tconst [fileType, fileSubtype] = file.type.split(\"/\");\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t(type === \"*\" || type === fileType) &&\n\t\t\t\t\t\t\t\t\t(subtype === \"*\" || subtype === fileSubtype)\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tsetErrorMsg(\n\t\t\t\t\t\t\t\t`Some file type not supported. Only allowed: ${mimeTypes.join(\n\t\t\t\t\t\t\t\t\t\", \"\n\t\t\t\t\t\t\t\t)}. Uploaded document is of type ${file.type}`\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tfiles = [];\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// if file is bigger than 10MB abort\n\t\t\t\t\t\tif (file.size > 10 * 1024 * 1024) {\n\t\t\t\t\t\t\tsetErrorMsg(\"Some file is too big. (10MB max)\");\n\t\t\t\t\t\t\tfiles = [];\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// add the file to the files array\n\t\t\t\t\t\tfiles = [...files, file];\n\n\t\t\t\t\t\t// Tools removed: no settings update for document parser\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tonDrag = false;\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction setErrorMsg(errorMsg: string) {\n\t\tonDrag = false;\n\t\talert(errorMsg);\n\t}\n</script>\n\n<div\n\tid=\"dropzone\"\n\trole=\"form\"\n\tondrop={dropHandle}\n\tondragenter={() => (onDragInner = true)}\n\tondragleave={() => (onDragInner = false)}\n\tondragover={(e) => {\n\t\te.preventDefault();\n\t}}\n\tclass=\"relative flex h-28 w-full max-w-4xl flex-col items-center justify-center gap-1 rounded-xl border-2 border-dotted {onDragInner\n\t\t? 'border-blue-200 !bg-blue-600/10 text-blue-600 *:pointer-events-none dark:border-blue-600 dark:bg-blue-600/20 dark:text-blue-600'\n\t\t: 'bg-gray-100 text-gray-500 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-400'}\"\n>\n\t<CarbonImage class=\"text-xl\" />\n\t<p>Drop File to add to chat</p>\n</div>\n"
  },
  {
    "path": "src/lib/components/chat/ImageLightbox.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from \"svelte\";\n\timport Portal from \"../Portal.svelte\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\n\tinterface Props {\n\t\tsrc: string;\n\t\tonclose: () => void;\n\t}\n\n\tlet { src, onclose }: Props = $props();\n\n\tfunction handleKeydown(e: KeyboardEvent) {\n\t\tif (e.key === \"Escape\") {\n\t\t\te.preventDefault();\n\t\t\te.stopPropagation();\n\t\t\tonclose();\n\t\t}\n\t}\n\n\tfunction handleOverlayClick(e: MouseEvent) {\n\t\t// Close when clicking the overlay (not the image)\n\t\tif (e.target === e.currentTarget) {\n\t\t\tonclose();\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\t// Prevent body scroll while lightbox is open\n\t\tconst originalOverflow = document.body.style.overflow;\n\t\tdocument.body.style.overflow = \"hidden\";\n\n\t\treturn () => {\n\t\t\tdocument.body.style.overflow = originalOverflow;\n\t\t};\n\t});\n</script>\n\n<svelte:window onkeydown={handleKeydown} />\n\n<Portal>\n\t<!-- svelte-ignore a11y_click_events_have_key_events -->\n\t<!-- svelte-ignore a11y_no_static_element_interactions -->\n\t<div\n\t\tclass=\"fixed inset-0 z-50 grid place-items-center bg-black/90 backdrop-blur-sm\"\n\t\tonclick={handleOverlayClick}\n\t>\n\t\t<!-- Close button -->\n\t\t<button\n\t\t\tclass=\"absolute right-3 top-3 grid size-8 place-items-center rounded-full border border-white/25 bg-white/20 text-gray-300 hover:bg-white/30 sm:right-6 sm:top-6\"\n\t\t\tonclick={onclose}\n\t\t\taria-label=\"Close\"\n\t\t>\n\t\t\t<CarbonClose />\n\t\t</button>\n\n\t\t<!-- Image with moon-landing's resize strategy -->\n\t\t<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n\t\t<img\n\t\t\t{src}\n\t\t\talt=\"\"\n\t\t\tclass=\"h-auto max-h-[calc(100vh-160px)] w-auto max-w-full\"\n\t\t\tonclick={(e) => e.stopPropagation()}\n\t\t/>\n\t</div>\n</Portal>\n"
  },
  {
    "path": "src/lib/components/chat/MarkdownBlock.svelte",
    "content": "<script lang=\"ts\">\n\timport type { Token } from \"$lib/utils/marked\";\n\timport CodeBlock from \"../CodeBlock.svelte\";\n\n\tinterface Props {\n\t\ttokens: Token[];\n\t\tloading?: boolean;\n\t}\n\n\tlet { tokens, loading = false }: Props = $props();\n\n\t// Derive rendered tokens for memoization\n\tconst renderedTokens = $derived(tokens);\n</script>\n\n{#each renderedTokens as token}\n\t{#if token.type === \"text\"}\n\t\t<!-- eslint-disable-next-line svelte/no-at-html-tags -->\n\t\t{@html token.html}\n\t{:else if token.type === \"code\"}\n\t\t<CodeBlock code={token.code} rawCode={token.rawCode} loading={loading && !token.isClosed} />\n\t{/if}\n{/each}\n"
  },
  {
    "path": "src/lib/components/chat/MarkdownRenderer.svelte",
    "content": "<script lang=\"ts\">\n\timport { processBlocks, processBlocksSync, type BlockToken } from \"$lib/utils/marked\";\n\timport MarkdownWorker from \"$lib/workers/markdownWorker?worker\";\n\timport MarkdownBlock from \"./MarkdownBlock.svelte\";\n\timport { browser } from \"$app/environment\";\n\n\timport { onMount, onDestroy } from \"svelte\";\n\timport { updateDebouncer } from \"$lib/utils/updates\";\n\n\tinterface Props {\n\t\tcontent: string;\n\t\tsources?: { title?: string; link: string }[];\n\t\tloading?: boolean;\n\t}\n\n\tlet { content, sources = [], loading = false }: Props = $props();\n\n\t// Sync-computed blocks used as fallback and for SSR (where effects don't run)\n\tlet syncBlocks = $derived(processBlocksSync(content, sources));\n\tlet workerBlocks: BlockToken[] | null = $state(null);\n\tlet blocks = $derived(workerBlocks ?? syncBlocks);\n\n\tlet worker: Worker | null = null;\n\tlet latestRequestId = 0;\n\n\tfunction handleBlocks(result: BlockToken[], requestId: number) {\n\t\tif (requestId !== latestRequestId) return;\n\t\tworkerBlocks = result;\n\t\tupdateDebouncer.endRender();\n\t}\n\n\t$effect(() => {\n\t\tif (!browser) return;\n\n\t\tconst requestId = ++latestRequestId;\n\n\t\tif (worker) {\n\t\t\tupdateDebouncer.startRender();\n\t\t\tworker.postMessage({ type: \"process\", content, sources, requestId });\n\t\t\treturn;\n\t\t}\n\n\t\t(async () => {\n\t\t\tupdateDebouncer.startRender();\n\t\t\tconst processed = await processBlocks(content, sources);\n\t\t\thandleBlocks(processed, requestId);\n\t\t})();\n\t});\n\n\tonMount(() => {\n\t\tif (typeof Worker !== \"undefined\") {\n\t\t\tworker = new MarkdownWorker();\n\t\t\tworker.onmessage = (event: MessageEvent) => {\n\t\t\t\tconst data = event.data as { type?: string; blocks?: BlockToken[]; requestId?: number };\n\t\t\t\tif (data?.type !== \"processed\" || !data.blocks || data.requestId === undefined) return;\n\t\t\t\thandleBlocks(data.blocks, data.requestId);\n\t\t\t};\n\t\t}\n\t});\n\n\tonDestroy(() => {\n\t\tworker?.terminate();\n\t\tworker = null;\n\t});\n</script>\n\n{#each blocks as block, index (loading && index === blocks.length - 1 ? `stream-${index}` : block.id)}\n\t<MarkdownBlock tokens={block.tokens} {loading} />\n{/each}\n"
  },
  {
    "path": "src/lib/components/chat/MarkdownRenderer.svelte.test.ts",
    "content": "import MarkdownRenderer from \"./MarkdownRenderer.svelte\";\nimport { render } from \"vitest-browser-svelte\";\nimport { page } from \"@vitest/browser/context\";\n\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"MarkdownRenderer\", () => {\n\tit(\"renders\", () => {\n\t\trender(MarkdownRenderer, { content: \"Hello, world!\" });\n\t\texpect(page.getByText(\"Hello, world!\")).toBeInTheDocument();\n\t});\n\tit(\"renders headings\", () => {\n\t\trender(MarkdownRenderer, { content: \"# Hello, world!\" });\n\t\texpect(page.getByRole(\"heading\", { level: 1 })).toBeInTheDocument();\n\t});\n\tit(\"renders links\", () => {\n\t\trender(MarkdownRenderer, { content: \"[Hello, world!](https://example.com)\" });\n\t\tconst link = page.getByRole(\"link\", { name: \"Hello, world!\" });\n\t\texpect(link).toBeInTheDocument();\n\t\texpect(link).toHaveAttribute(\"href\", \"https://example.com\");\n\t\texpect(link).toHaveAttribute(\"target\", \"_blank\");\n\t\texpect(link).toHaveAttribute(\"rel\", \"noreferrer\");\n\t});\n\tit(\"renders inline codespans\", () => {\n\t\trender(MarkdownRenderer, { content: \"`foobar`\" });\n\t\texpect(page.getByRole(\"code\")).toHaveTextContent(\"foobar\");\n\t});\n\tit(\"renders block codes\", () => {\n\t\trender(MarkdownRenderer, { content: \"```foobar```\" });\n\t\texpect(page.getByRole(\"code\")).toHaveTextContent(\"foobar\");\n\t});\n\tit(\"doesnt render raw html directly\", () => {\n\t\trender(MarkdownRenderer, { content: \"<button>Click me</button>\" });\n\t\texpect(page.getByRole(\"button\").elements).toHaveLength(0);\n\t\t// htmlparser2 escapes disallowed tags\n\t\texpect(page.getByRole(\"paragraph\")).toHaveTextContent(\"<button>Click me</button>\");\n\t});\n\tit(\"renders latex\", () => {\n\t\tconst { baseElement } = render(MarkdownRenderer, { content: \"$(oo)^2$\" });\n\t\texpect(baseElement.querySelectorAll(\".katex\")).toHaveLength(1);\n\t});\n\tit(\"does not render latex in code blocks\", () => {\n\t\tconst { baseElement } = render(MarkdownRenderer, { content: \"```\\n$(oo)^2$\\n```\" });\n\t\texpect(baseElement.querySelectorAll(\".katex\")).toHaveLength(0);\n\t});\n\tit(\"does not render latex in inline codes\", () => {\n\t\tconst { baseElement } = render(MarkdownRenderer, { content: \"`$oo` and `$bar`\" });\n\t\texpect(baseElement.querySelectorAll(\".katex\")).toHaveLength(0);\n\t});\n\tit(\"does not render latex across multiple lines\", () => {\n\t\tconst { baseElement } = render(MarkdownRenderer, { content: \"* $oo \\n* $aa\" });\n\t\texpect(baseElement.querySelectorAll(\".katex\")).toHaveLength(0);\n\t});\n\tit(\"renders latex with some < and > symbols\", () => {\n\t\tconst { baseElement } = render(MarkdownRenderer, { content: \"$foo < bar > baz$\" });\n\t\texpect(baseElement.querySelectorAll(\".katex\")).toHaveLength(1);\n\t});\n});\n"
  },
  {
    "path": "src/lib/components/chat/MessageAvatar.svelte",
    "content": "<script lang=\"ts\">\n\timport { onDestroy } from \"svelte\";\n\n\tlet { animating = false, classNames = \"\" } = $props();\n\n\tlet blobAnim: SVGAnimateElement | undefined = $state();\n\tlet svgEl: SVGSVGElement | undefined = $state();\n\n\t// Only trigger begin/end on transitions, and pause when not animating\n\tlet prevAnimating: boolean | undefined = undefined;\n\tlet prevBlobAnim: SVGAnimateElement | undefined = undefined;\n\n\t$effect(() => {\n\t\tif (!blobAnim) return;\n\t\tconst blobChanged = blobAnim !== prevBlobAnim;\n\t\tconst animChanged = animating !== prevAnimating;\n\t\tif (!(blobChanged || animChanged)) return;\n\n\t\tif (animating) {\n\t\t\t// Resume animations and start once\n\t\t\tsvgEl?.unpauseAnimations?.();\n\t\t\tblobAnim.beginElement();\n\t\t} else {\n\t\t\t// Stop current run and pause so it cannot restart from queued begins\n\t\t\tblobAnim.endElement();\n\t\t\tsvgEl?.pauseAnimations?.();\n\t\t}\n\t\tprevAnimating = animating;\n\t\tprevBlobAnim = blobAnim;\n\t});\n\n\tonDestroy(() => {\n\t\tblobAnim?.endElement();\n\t\tsvgEl?.pauseAnimations?.();\n\t});\n</script>\n\n<svg\n\tbind:this={svgEl}\n\tclass={classNames}\n\tid=\"ball\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 12 12\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n\taria-label=\"Ball mask\"\n>\n\t<g clip-path=\"url(#a)\">\n\t\t<!-- circular mask -->\n\t\t<path d=\"M12 6A6 6 0 1 0 0 6a6 6 0 0 0 12 0Z\" fill=\"#fff\" />\n\t\t<mask id=\"b\" style=\"mask-type:luminance\" x=\"0\" y=\"0\" width=\"12\" height=\"12\">\n\t\t\t<path d=\"M12 6A6 6 0 1 0 0 6a6 6 0 0 0 12 0Z\" fill=\"#fff\" />\n\t\t</mask>\n\n\t\t<!-- the blurred black shape inside the circular mask -->\n\t\t<g filter=\"url(#c)\" mask=\"url(#b)\">\n\t\t\t<!-- BASE state (normalized to absolute L commands) -->\n\t\t\t<path id=\"blob\" fill=\"#000\" d=\"M11 1 L8 -4 L3 -8 L-6 6 L3 12 L7 11 L6 2 L11 1 Z\">\n\t\t\t\t<!-- MORPH: base -> mid -> far -> mid -> base -->\n\t\t\t\t<animate\n\t\t\t\t\tbind:this={blobAnim}\n\t\t\t\t\tattributeName=\"d\"\n\t\t\t\t\tbegin=\"indefinite\"\n\t\t\t\t\tend=\"indefinite\"\n\t\t\t\t\tdur=\"3.2s\"\n\t\t\t\t\trepeatCount=\"indefinite\"\n\t\t\t\t\tfill=\"remove\"\n\t\t\t\t\tcalcMode=\"spline\"\n\t\t\t\t\tkeyTimes=\"0; .33; .66; .9; 1\"\n\t\t\t\t\tkeySplines=\"\n            .4 0 .2 1;\n            .4 0 .2 1;\n            .4 0 .2 1;\n            .4 0 .2 1\"\n\t\t\t\t\tvalues=\"\n            M11 1 L8 -4 L3 -8 L-6 6 L3 12 L7 11 L6 2 L11 1 Z;\n            M11 1 L8 -4 L3 -8 L-6 6 L3 12 L5 9  L7 4  L11 1 Z;\n            M11 1 L8 -4 L3 -8 L-6 6 L3 12 L3 6  L5 1  L11 1 Z;\n            M11 1 L8 -4 L3 -8 L-6 6 L3 12 L5 9  L7 4  L11 1 Z;\n            M11 1 L8 -4 L3 -8 L-6 6 L3 12 L7 11 L6 2 L11 1 Z\"\n\t\t\t\t/>\n\t\t\t</path>\n\t\t</g>\n\t</g>\n\n\t<defs>\n\t\t<clipPath id=\"a\"><path fill=\"#fff\" d=\"M0 0h12v12H0z\" /></clipPath>\n\t\t<filter\n\t\t\tid=\"c\"\n\t\t\tx=\"-9.4\"\n\t\t\ty=\"-10.8\"\n\t\t\twidth=\"23.8\"\n\t\t\theight=\"26\"\n\t\t\tfilterUnits=\"userSpaceOnUse\"\n\t\t\tcolor-interpolation-filters=\"sRGB\"\n\t\t>\n\t\t\t<feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\" />\n\t\t\t<feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n\t\t\t<feGaussianBlur stdDeviation=\"1.6\" />\n\t\t</filter>\n\t</defs>\n</svg>\n"
  },
  {
    "path": "src/lib/components/chat/ModelSwitch.svelte",
    "content": "<script lang=\"ts\">\n\timport { invalidateAll } from \"$app/navigation\";\n\timport { page } from \"$app/state\";\n\timport { base } from \"$app/paths\";\n\timport type { Model } from \"$lib/types/Model\";\n\n\tinterface Props {\n\t\tmodels: Model[];\n\t\tcurrentModel: Model;\n\t}\n\n\tlet { models, currentModel }: Props = $props();\n\n\tlet selectedModelId = $state(\"\");\n\n\t$effect.pre(() => {\n\t\tselectedModelId = models.map((m) => m.id).includes(currentModel.id)\n\t\t\t? currentModel.id\n\t\t\t: models[0].id;\n\t});\n\n\tasync function handleModelChange() {\n\t\tif (!page.params.id) return;\n\n\t\ttry {\n\t\t\tconst response = await fetch(`${base}/conversation/${page.params.id}`, {\n\t\t\t\tmethod: \"PATCH\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({ model: selectedModelId }),\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(\"Failed to update model\");\n\t\t\t}\n\n\t\t\tawait invalidateAll();\n\t\t} catch (error) {\n\t\t\tconsole.error(error);\n\t\t}\n\t}\n</script>\n\n<div\n\tclass=\"mx-auto mt-0 flex w-fit flex-col items-center justify-center gap-2 rounded-lg border border-gray-200 bg-gray-500/20 p-4 dark:border-gray-800\"\n>\n\t<span>\n\t\tThis model is no longer available. Switch to a new one to continue this conversation:\n\t</span>\n\t<div class=\"flex items-center space-x-2\">\n\t\t<select\n\t\t\tbind:value={selectedModelId}\n\t\t\tclass=\"rounded-md bg-gray-100 px-2 py-1 dark:bg-gray-900 max-sm:max-w-32\"\n\t\t>\n\t\t\t{#each models as model}\n\t\t\t\t<option value={model.id}>{model.name}</option>\n\t\t\t{/each}\n\t\t</select>\n\t\t<button\n\t\t\tonclick={handleModelChange}\n\t\t\tdisabled={selectedModelId === currentModel.id}\n\t\t\tclass=\"rounded-md bg-gray-100 px-2 py-1 dark:bg-gray-900\"\n\t\t>\n\t\t\tAccept\n\t\t</button>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/chat/OpenReasoningResults.svelte",
    "content": "<script lang=\"ts\">\n\timport MarkdownRenderer from \"./MarkdownRenderer.svelte\";\n\timport BlockWrapper from \"./BlockWrapper.svelte\";\n\n\tinterface Props {\n\t\tcontent: string;\n\t\tloading?: boolean;\n\t\thasNext?: boolean;\n\t}\n\n\tlet { content, loading = false, hasNext = false }: Props = $props();\n\tlet isOpen = $state(false);\n\tlet wasLoading = $state(false);\n\tlet initialized = $state(false);\n\n\t// Track loading transitions to auto-expand/collapse\n\t$effect(() => {\n\t\t// Auto-expand on first render if already loading\n\t\tif (!initialized) {\n\t\t\tinitialized = true;\n\t\t\tif (loading) {\n\t\t\t\tisOpen = true;\n\t\t\t\twasLoading = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tif (loading && !wasLoading) {\n\t\t\t// Loading started - auto-expand\n\t\t\tisOpen = true;\n\t\t} else if (!loading && wasLoading) {\n\t\t\t// Loading finished - auto-collapse\n\t\t\tisOpen = false;\n\t\t}\n\t\twasLoading = loading;\n\t});\n</script>\n\n{#snippet icon()}\n\t<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 32 32\">\n\t\t<path\n\t\t\tclass=\"stroke-gray-500 dark:stroke-gray-400\"\n\t\t\tstyle=\"stroke-width: 1.9; fill: none; stroke-linecap: round; stroke-linejoin: round;\"\n\t\t\td=\"M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18\"\n\t\t/>\n\t</svg>\n{/snippet}\n\n<BlockWrapper\n\t{icon}\n\t{hasNext}\n\ticonBg=\"bg-gray-100 dark:bg-gray-700\"\n\ticonRing=\"ring-gray-200 dark:ring-gray-600\"\n>\n\t<!-- Collapsed view (clickable to expand) -->\n\t<button\n\t\ttype=\"button\"\n\t\tclass=\"group/text w-full cursor-pointer text-left\"\n\t\tonclick={() => (isOpen = !isOpen)}\n\t>\n\t\t{#if isOpen}\n\t\t\t<!-- Expanded: show full content -->\n\t\t\t<div\n\t\t\t\tclass=\"prose prose-sm max-w-none text-sm leading-relaxed text-gray-500 dark:prose-invert dark:text-gray-400\"\n\t\t\t>\n\t\t\t\t<MarkdownRenderer {content} {loading} />\n\t\t\t</div>\n\t\t{:else}\n\t\t\t<!-- Collapsed: 2-line preview (plain text, strip markdown) -->\n\t\t\t<div\n\t\t\t\tclass=\"line-clamp-2 max-h-[3.25em] text-sm leading-relaxed text-gray-500 dark:text-gray-400\"\n\t\t\t\tclass:animate-pulse={loading}\n\t\t\t>\n\t\t\t\t{content\n\t\t\t\t\t.replace(/[#*`~[\\]]/g, \"\")\n\t\t\t\t\t.replace(/\\n+/g, \" \")\n\t\t\t\t\t.trim()}\n\t\t\t</div>\n\t\t{/if}\n\t</button>\n</BlockWrapper>\n"
  },
  {
    "path": "src/lib/components/chat/ToolUpdate.svelte",
    "content": "<script lang=\"ts\">\n\timport { MessageToolUpdateType, type MessageToolUpdate } from \"$lib/types/MessageUpdate\";\n\timport {\n\t\tisMessageToolCallUpdate,\n\t\tisMessageToolErrorUpdate,\n\t\tisMessageToolProgressUpdate,\n\t\tisMessageToolResultUpdate,\n\t} from \"$lib/utils/messageUpdates\";\n\timport { formatToolProgressLabel } from \"$lib/utils/toolProgress\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\timport LucideCheck from \"~icons/lucide/check\";\n\timport { ToolResultStatus, type ToolFront } from \"$lib/types/Tool\";\n\timport { page } from \"$app/state\";\n\timport CarbonChevronRight from \"~icons/carbon/chevron-right\";\n\timport BlockWrapper from \"./BlockWrapper.svelte\";\n\n\tinterface Props {\n\t\ttool: MessageToolUpdate[];\n\t\tloading?: boolean;\n\t\thasNext?: boolean;\n\t}\n\n\tlet { tool, loading = false, hasNext = false }: Props = $props();\n\n\tlet isOpen = $state(false);\n\n\tlet toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);\n\tlet toolError = $derived(tool.some(isMessageToolErrorUpdate));\n\tlet toolDone = $derived(tool.some(isMessageToolResultUpdate));\n\tlet isExecuting = $derived(!toolDone && !toolError && loading);\n\tlet toolSuccess = $derived(toolDone && !toolError);\n\tlet toolProgress = $derived.by(() => {\n\t\tfor (let i = tool.length - 1; i >= 0; i -= 1) {\n\t\t\tconst update = tool[i];\n\t\t\tif (isMessageToolProgressUpdate(update)) return update;\n\t\t}\n\t\treturn undefined;\n\t});\n\tlet progressLabel = $derived.by(() => formatToolProgressLabel(toolProgress));\n\n\tconst availableTools: ToolFront[] = $derived.by(\n\t\t() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []\n\t);\n\n\ttype ToolOutput = Record<string, unknown>;\n\ttype McpImageContent = {\n\t\ttype: \"image\";\n\t\tdata: string;\n\t\tmimeType: string;\n\t};\n\n\tconst formatValue = (value: unknown): string => {\n\t\tif (value == null) return \"\";\n\t\tif (typeof value === \"object\") {\n\t\t\ttry {\n\t\t\t\treturn JSON.stringify(value, null, 2);\n\t\t\t} catch {\n\t\t\t\treturn String(value);\n\t\t\t}\n\t\t}\n\t\treturn String(value);\n\t};\n\n\tconst getOutputText = (output: ToolOutput): string | undefined => {\n\t\tconst maybeText = output[\"text\"];\n\t\tif (typeof maybeText !== \"string\") return undefined;\n\t\treturn maybeText;\n\t};\n\n\tconst isImageBlock = (value: unknown): value is McpImageContent => {\n\t\tif (typeof value !== \"object\" || value === null) return false;\n\t\tconst obj = value as Record<string, unknown>;\n\t\treturn (\n\t\t\tobj[\"type\"] === \"image\" &&\n\t\t\ttypeof obj[\"data\"] === \"string\" &&\n\t\t\ttypeof obj[\"mimeType\"] === \"string\"\n\t\t);\n\t};\n\n\tconst getImageBlocks = (output: ToolOutput): McpImageContent[] => {\n\t\tconst blocks = output[\"content\"];\n\t\tif (!Array.isArray(blocks)) return [];\n\t\treturn blocks.filter(isImageBlock);\n\t};\n\n\tconst getMetadataEntries = (output: ToolOutput): Array<[string, unknown]> => {\n\t\treturn Object.entries(output).filter(\n\t\t\t([key, value]) => value != null && key !== \"content\" && key !== \"text\"\n\t\t);\n\t};\n\n\tinterface ParsedToolOutput {\n\t\ttext?: string;\n\t\timages: McpImageContent[];\n\t\tmetadata: Array<[string, unknown]>;\n\t}\n\n\tconst parseToolOutputs = (outputs: ToolOutput[]): ParsedToolOutput[] =>\n\t\toutputs.map((output) => ({\n\t\t\ttext: getOutputText(output),\n\t\t\timages: getImageBlocks(output),\n\t\t\tmetadata: getMetadataEntries(output),\n\t\t}));\n\n\t// Icon styling based on state\n\tlet iconBg = $derived(\n\t\ttoolError ? \"bg-red-100 dark:bg-red-900/40\" : \"bg-purple-100 dark:bg-purple-900/40\"\n\t);\n\n\tlet iconRing = $derived(\n\t\ttoolError ? \"ring-red-200 dark:ring-red-500/30\" : \"ring-purple-200 dark:ring-purple-500/30\"\n\t);\n</script>\n\n{#snippet icon()}\n\t{#if toolSuccess}\n\t\t<LucideCheck class=\"size-3.5 text-purple-600 dark:text-purple-400\" />\n\t{:else}\n\t\t<LucideHammer\n\t\t\tclass=\"size-3.5 {toolError\n\t\t\t\t? 'text-red-500 dark:text-red-400'\n\t\t\t\t: 'text-purple-600 dark:text-purple-400'}\"\n\t\t/>\n\t{/if}\n{/snippet}\n\n{#if toolFnName}\n\t<BlockWrapper {icon} {iconBg} {iconRing} {hasNext} loading={isExecuting}>\n\t\t<!-- Header row -->\n\t\t<div class=\"flex w-full select-none items-center gap-2\">\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"flex flex-1 cursor-pointer flex-col items-start gap-1 text-left\"\n\t\t\t\tonclick={() => (isOpen = !isOpen)}\n\t\t\t>\n\t\t\t\t<span\n\t\t\t\t\tclass=\"text-sm font-medium {isExecuting\n\t\t\t\t\t\t? 'text-purple-700 dark:text-purple-300'\n\t\t\t\t\t\t: toolError\n\t\t\t\t\t\t\t? 'text-red-600 dark:text-red-400'\n\t\t\t\t\t\t\t: 'text-gray-700 dark:text-gray-300'}\"\n\t\t\t\t>\n\t\t\t\t\t{toolError ? \"Error calling\" : toolDone ? \"Called\" : \"Calling\"} tool\n\t\t\t\t\t<code\n\t\t\t\t\t\tclass=\"rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs text-gray-500 opacity-90 dark:bg-gray-800 dark:text-gray-400\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{availableTools.find((entry) => entry.name === toolFnName)?.displayName ?? toolFnName}\n\t\t\t\t\t</code>\n\t\t\t\t</span>\n\t\t\t\t{#if isExecuting && toolProgress}\n\t\t\t\t\t<span class=\"text-xs text-gray-500 dark:text-gray-400\">{progressLabel}</span>\n\t\t\t\t{/if}\n\t\t\t</button>\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tclass=\"cursor-pointer\"\n\t\t\t\tonclick={() => (isOpen = !isOpen)}\n\t\t\t\taria-label={isOpen ? \"Collapse\" : \"Expand\"}\n\t\t\t>\n\t\t\t\t<CarbonChevronRight\n\t\t\t\t\tclass=\"size-4 text-gray-400 transition-transform duration-200 {isOpen ? 'rotate-90' : ''}\"\n\t\t\t\t/>\n\t\t\t</button>\n\t\t</div>\n\n\t\t<!-- Expandable content -->\n\t\t{#if isOpen}\n\t\t\t<div class=\"mt-2 space-y-3\">\n\t\t\t\t{#each tool as update, i (`${update.subtype}-${i}`)}\n\t\t\t\t\t{#if update.subtype === MessageToolUpdateType.Call}\n\t\t\t\t\t\t<div class=\"space-y-1\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tInput\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<pre class=\"whitespace-pre-wrap break-all font-mono text-xs\">{formatValue(\n\t\t\t\t\t\t\t\t\t\tupdate.call.parameters\n\t\t\t\t\t\t\t\t\t)}</pre>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else if update.subtype === MessageToolUpdateType.Error}\n\t\t\t\t\t\t<div class=\"space-y-1\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tError\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<pre class=\"whitespace-pre-wrap break-all font-mono text-xs\">{update.message}</pre>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}\n\t\t\t\t\t\t<div class=\"space-y-1\">\n\t\t\t\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclass=\"text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tOutput\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\t\t\t\twidth=\"12\"\n\t\t\t\t\t\t\t\t\theight=\"12\"\n\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\tstroke-width=\"2\"\n\t\t\t\t\t\t\t\t\tstroke-linecap=\"round\"\n\t\t\t\t\t\t\t\t\tstroke-linejoin=\"round\"\n\t\t\t\t\t\t\t\t\tclass=\"text-emerald-500\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n\t\t\t\t\t\t\t\t\t<path d=\"m9 12 2 2 4-4\"></path>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"scrollbar-custom rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#each parseToolOutputs(update.result.outputs) as parsedOutput}\n\t\t\t\t\t\t\t\t\t<div class=\"space-y-2\">\n\t\t\t\t\t\t\t\t\t\t{#if parsedOutput.text}\n\t\t\t\t\t\t\t\t\t\t\t<pre\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"scrollbar-custom max-h-60 overflow-y-auto whitespace-pre-wrap break-all font-mono text-xs\">{parsedOutput.text}</pre>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t\t\t\t\t{#if parsedOutput.images.length > 0}\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"flex flex-wrap gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t{#each parsedOutput.images as image, imageIndex}\n\t\t\t\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\t\t\t\talt={`Tool result image ${imageIndex + 1}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"max-h-60 cursor-pointer rounded border border-gray-200 dark:border-gray-700\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsrc={`data:${image.mimeType};base64,${image.data}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\n\t\t\t\t\t\t\t\t\t\t{#if parsedOutput.metadata.length > 0}\n\t\t\t\t\t\t\t\t\t\t\t<pre class=\"whitespace-pre-wrap break-all font-mono text-xs\">{formatValue(\n\t\t\t\t\t\t\t\t\t\t\t\t\tObject.fromEntries(parsedOutput.metadata)\n\t\t\t\t\t\t\t\t\t\t\t\t)}</pre>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}\n\t\t\t\t\t\t<div class=\"space-y-1\">\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tError\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<pre class=\"whitespace-pre-wrap break-all font-mono text-xs\">{update.result\n\t\t\t\t\t\t\t\t\t\t.message}</pre>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\t</BlockWrapper>\n{/if}\n"
  },
  {
    "path": "src/lib/components/chat/UploadedFile.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from \"$app/state\";\n\timport type { MessageFile } from \"$lib/types/Message\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport CarbonDocumentBlank from \"~icons/carbon/document-blank\";\n\timport CarbonDownload from \"~icons/carbon/download\";\n\timport CarbonDocument from \"~icons/carbon/document\";\n\timport Modal from \"../Modal.svelte\";\n\timport AudioPlayer from \"../players/AudioPlayer.svelte\";\n\timport EosIconsLoading from \"~icons/eos-icons/loading\";\n\timport { base } from \"$app/paths\";\n\timport { TEXT_MIME_ALLOWLIST } from \"$lib/constants/mime\";\n\n\tinterface Props {\n\t\tfile: MessageFile;\n\t\tcanClose?: boolean;\n\t\tonclose?: () => void;\n\t}\n\n\tlet { file, canClose = true, onclose }: Props = $props();\n\n\tlet showModal = $state(false);\n\n\t// Capture URL once at component creation to prevent reactive updates during navigation\n\tlet urlNotTrailing = page.url.pathname.replace(/\\/$/, \"\");\n\n\tfunction truncateMiddle(text: string, maxLength: number): string {\n\t\tif (text.length <= maxLength) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst halfLength = Math.floor((maxLength - 1) / 2);\n\t\tconst start = text.substring(0, halfLength);\n\t\tconst end = text.substring(text.length - halfLength);\n\n\t\treturn `${start}…${end}`;\n\t}\n\n\tconst isImage = (mime: string) =>\n\t\tmime.startsWith(\"image/\") || mime === \"webp\" || mime === \"jpeg\" || mime === \"png\";\n\n\tconst isAudio = (mime: string) =>\n\t\tmime.startsWith(\"audio/\") || mime === \"mp3\" || mime === \"wav\" || mime === \"x-wav\";\n\tconst isVideo = (mime: string) =>\n\t\tmime.startsWith(\"video/\") || mime === \"mp4\" || mime === \"x-mpeg\";\n\n\tfunction matchesAllowed(contentType: string, allowed: readonly string[]): boolean {\n\t\tconst ct = contentType.split(\";\")[0]?.trim().toLowerCase();\n\t\tif (!ct) return false;\n\t\tconst [ctType, ctSubtype] = ct.split(\"/\");\n\t\tfor (const a of allowed) {\n\t\t\tconst [aType, aSubtype] = a.toLowerCase().split(\"/\");\n\t\t\tconst typeOk = aType === \"*\" || aType === ctType;\n\t\t\tconst subOk = aSubtype === \"*\" || aSubtype === ctSubtype;\n\t\t\tif (typeOk && subOk) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tconst isPlainText = (mime: string) =>\n\t\tmime === \"application/vnd.chatui.clipboard\" || matchesAllowed(mime, TEXT_MIME_ALLOWLIST);\n\n\tlet isClickable = $derived(isImage(file.mime) || isPlainText(file.mime));\n</script>\n\n{#if showModal && isClickable}\n\t<!-- show the image file full screen, click outside to exit -->\n\t<Modal width=\"xl:max-w-[75dvw]\" onclose={() => (showModal = false)}>\n\t\t{#if isImage(file.mime)}\n\t\t\t{#if file.type === \"hash\"}\n\t\t\t\t<img\n\t\t\t\t\tsrc={urlNotTrailing + \"/output/\" + file.value}\n\t\t\t\t\talt=\"input from user\"\n\t\t\t\t\tclass=\"aspect-auto\"\n\t\t\t\t/>\n\t\t\t{:else}\n\t\t\t\t<!-- handle the case where this is a base64 encoded image -->\n\t\t\t\t<img\n\t\t\t\t\tsrc={`data:${file.mime};base64,${file.value}`}\n\t\t\t\t\talt=\"input from user\"\n\t\t\t\t\tclass=\"aspect-auto\"\n\t\t\t\t/>\n\t\t\t{/if}\n\t\t{:else if isPlainText(file.mime)}\n\t\t\t<div class=\"relative flex h-full w-full flex-col gap-2 p-4\">\n\t\t\t\t<div class=\"flex items-center gap-1\">\n\t\t\t\t\t<CarbonDocument />\n\t\t\t\t\t<h3 class=\"text-lg font-semibold\">{file.name}</h3>\n\t\t\t\t</div>\n\t\t\t\t{#if file.mime === \"application/vnd.chatui.clipboard\"}\n\t\t\t\t\t<p class=\"text-sm text-gray-500\">\n\t\t\t\t\t\tIf you prefer to inject clipboard content directly in the chat, you can disable this\n\t\t\t\t\t\tfeature in the\n\t\t\t\t\t\t<a href={`${base}/settings`} class=\"underline\">settings page</a>.\n\t\t\t\t\t</p>\n\t\t\t\t{/if}\n\t\t\t\t<button\n\t\t\t\t\tclass=\"absolute right-4 top-4 text-xl text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white\"\n\t\t\t\t\tonclick={() => (showModal = false)}\n\t\t\t\t>\n\t\t\t\t\t<CarbonClose class=\"text-xl\" />\n\t\t\t\t</button>\n\t\t\t\t{#if file.type === \"hash\"}\n\t\t\t\t\t{#await fetch(urlNotTrailing + \"/output/\" + file.value).then((res) => res.text())}\n\t\t\t\t\t\t<div class=\"flex h-full w-full items-center justify-center\">\n\t\t\t\t\t\t\t<EosIconsLoading class=\"text-xl\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:then result}\n\t\t\t\t\t\t<pre\n\t\t\t\t\t\t\tclass=\"w-full whitespace-pre-wrap break-words pt-0 text-xs\"\n\t\t\t\t\t\t\tclass:font-sans={file.mime === \"text/plain\" ||\n\t\t\t\t\t\t\t\tfile.mime === \"application/vnd.chatui.clipboard\"}\n\t\t\t\t\t\t\tclass:font-mono={file.mime !== \"text/plain\" &&\n\t\t\t\t\t\t\t\tfile.mime !== \"application/vnd.chatui.clipboard\"}>{result}</pre>\n\t\t\t\t\t{/await}\n\t\t\t\t{:else}\n\t\t\t\t\t<pre\n\t\t\t\t\t\tclass=\"w-full whitespace-pre-wrap break-words pt-0 text-xs\"\n\t\t\t\t\t\tclass:font-sans={file.mime === \"text/plain\" ||\n\t\t\t\t\t\t\tfile.mime === \"application/vnd.chatui.clipboard\"}\n\t\t\t\t\t\tclass:font-mono={file.mime !== \"text/plain\" &&\n\t\t\t\t\t\t\tfile.mime !== \"application/vnd.chatui.clipboard\"}>{atob(file.value)}</pre>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\t</Modal>\n{/if}\n\n<div\n\tonclick={() => isClickable && (showModal = true)}\n\tonkeydown={(e) => {\n\t\tif (!isClickable) {\n\t\t\treturn;\n\t\t}\n\t\tif (e.key === \"Enter\" || e.key === \" \") {\n\t\t\tshowModal = true;\n\t\t}\n\t}}\n\tclass:clickable={isClickable}\n\trole=\"button\"\n\ttabindex=\"0\"\n>\n\t<div class=\"group relative flex items-center rounded-xl shadow-sm\">\n\t\t{#if isImage(file.mime)}\n\t\t\t<div class=\"h-36 overflow-hidden rounded-xl\">\n\t\t\t\t<img\n\t\t\t\t\tsrc={file.type === \"base64\"\n\t\t\t\t\t\t? `data:${file.mime};base64,${file.value}`\n\t\t\t\t\t\t: urlNotTrailing + \"/output/\" + file.value}\n\t\t\t\t\talt={file.name}\n\t\t\t\t\tclass=\"h-36 bg-gray-200 object-cover dark:bg-gray-800\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t{:else if isAudio(file.mime)}\n\t\t\t<AudioPlayer\n\t\t\t\tsrc={file.type === \"base64\"\n\t\t\t\t\t? `data:${file.mime};base64,${file.value}`\n\t\t\t\t\t: urlNotTrailing + \"/output/\" + file.value}\n\t\t\t\tname={truncateMiddle(file.name, 28)}\n\t\t\t/>\n\t\t{:else if isVideo(file.mime)}\n\t\t\t<div\n\t\t\t\tclass=\"border-1 w-72 overflow-clip rounded-xl border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900\"\n\t\t\t>\n\t\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t\t<video\n\t\t\t\t\tsrc={file.type === \"base64\"\n\t\t\t\t\t\t? `data:${file.mime};base64,${file.value}`\n\t\t\t\t\t\t: urlNotTrailing + \"/output/\" + file.value}\n\t\t\t\t\tcontrols\n\t\t\t\t></video>\n\t\t\t</div>\n\t\t{:else if isPlainText(file.mime)}\n\t\t\t<div\n\t\t\t\tclass=\"flex h-14 w-64 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900 2xl:w-72\"\n\t\t\t\tclass:file-hoverable={isClickable}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclass=\"grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonDocument class=\"text-base text-gray-700 dark:text-gray-300\" />\n\t\t\t\t</div>\n\t\t\t\t<dl class=\"flex flex-col items-start truncate leading-tight\">\n\t\t\t\t\t<dd class=\"text-sm\">\n\t\t\t\t\t\t{truncateMiddle(file.name, 28)}\n\t\t\t\t\t</dd>\n\t\t\t\t\t{#if file.mime === \"application/vnd.chatui.clipboard\"}\n\t\t\t\t\t\t<dt class=\"text-xs text-gray-400\">Clipboard source</dt>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<dt class=\"text-xs text-gray-400\">{file.mime}</dt>\n\t\t\t\t\t{/if}\n\t\t\t\t</dl>\n\t\t\t</div>\n\t\t{:else if file.mime === \"application/octet-stream\"}\n\t\t\t<div\n\t\t\t\tclass=\"flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900\"\n\t\t\t\tclass:file-hoverable={isClickable}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclass=\"grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonDocumentBlank class=\"text-base text-gray-700 dark:text-gray-300\" />\n\t\t\t\t</div>\n\t\t\t\t<dl class=\"flex flex-grow flex-col truncate leading-tight\">\n\t\t\t\t\t<dd class=\"text-sm\">\n\t\t\t\t\t\t{truncateMiddle(file.name, 28)}\n\t\t\t\t\t</dd>\n\t\t\t\t\t<dt class=\"text-xs text-gray-400\">File type could not be determined</dt>\n\t\t\t\t</dl>\n\t\t\t\t<a\n\t\t\t\t\thref={file.type === \"base64\"\n\t\t\t\t\t\t? `data:application/octet-stream;base64,${file.value}`\n\t\t\t\t\t\t: urlNotTrailing + \"/output/\" + file.value}\n\t\t\t\t\tdownload={file.name}\n\t\t\t\t\tclass=\"ml-auto flex-none\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonDownload class=\"text-base text-gray-700 dark:text-gray-300\" />\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t{:else}\n\t\t\t<div\n\t\t\t\tclass=\"flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900\"\n\t\t\t\tclass:file-hoverable={isClickable}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclass=\"grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonDocumentBlank class=\"text-base text-gray-700 dark:text-gray-300\" />\n\t\t\t\t</div>\n\t\t\t\t<dl class=\"flex flex-col items-start truncate leading-tight\">\n\t\t\t\t\t<dd class=\"text-sm\">\n\t\t\t\t\t\t{truncateMiddle(file.name, 28)}\n\t\t\t\t\t</dd>\n\t\t\t\t\t<dt class=\"text-xs text-gray-400\">{file.mime}</dt>\n\t\t\t\t</dl>\n\t\t\t</div>\n\t\t{/if}\n\t\t<!-- add a button on top that removes the image -->\n\t\t{#if canClose}\n\t\t\t<button\n\t\t\t\tclass=\"absolute -right-2 -top-2 z-10 grid size-6 place-items-center rounded-full border bg-black group-hover:visible dark:border-gray-700\"\n\t\t\t\tclass:invisible={navigator.maxTouchPoints === 0}\n\t\t\t\tonclick={(e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t\tonclose?.();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<CarbonClose class=\" text-xs  text-white\" />\n\t\t\t</button>\n\t\t{/if}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/chat/UrlFetchModal.svelte",
    "content": "<script lang=\"ts\">\n\timport Modal from \"../Modal.svelte\";\n\timport { base } from \"$app/paths\";\n\timport { tick } from \"svelte\";\n\timport { pickSafeMime } from \"$lib/utils/mime\";\n\n\tinterface Props {\n\t\topen?: boolean;\n\t\tacceptMimeTypes?: string[]; // optional client-side validation\n\t\tonclose?: () => void;\n\t\tonfiles?: (files: File[]) => void;\n\t}\n\n\tlet { open = $bindable(false), acceptMimeTypes = [], onclose, onfiles }: Props = $props();\n\n\tlet urlValue = $state(\"\");\n\tlet loading = $state(false);\n\tlet errorMsg = $state(\"\");\n\tlet inputEl: HTMLInputElement | undefined = $state();\n\n\tasync function focusInputSoon() {\n\t\t// Wait for modal and content to mount, then focus and select\n\t\tawait tick();\n\t\tawait tick();\n\t\tsetTimeout(() => {\n\t\t\tinputEl?.focus();\n\t\t\tinputEl?.select();\n\t\t}, 0);\n\t}\n\n\t$effect(() => {\n\t\tif (open) {\n\t\t\t// reset state when opening\n\t\t\turlValue = \"\";\n\t\t\terrorMsg = \"\";\n\t\t\tvoid focusInputSoon();\n\t\t}\n\t});\n\n\tfunction isHttpsUrl(url: string) {\n\t\ttry {\n\t\t\tconst u = new URL(url);\n\t\t\treturn u.protocol === \"https:\";\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tfunction matchesAllowed(contentType: string, allowed: string[]): boolean {\n\t\tconst ct = contentType.split(\";\")[0]?.trim().toLowerCase();\n\t\tif (!ct) return false;\n\t\tconst [ctType, ctSubtype] = ct.split(\"/\");\n\t\tfor (const a of allowed) {\n\t\t\tconst [aType, aSubtype] = a.toLowerCase().split(\"/\");\n\t\t\tconst typeOk = aType === \"*\" || aType === ctType;\n\t\t\tconst subOk = aSubtype === \"*\" || aSubtype === ctSubtype;\n\t\t\tif (typeOk && subOk) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tfunction close() {\n\t\topen = false;\n\t\tonclose?.();\n\t}\n\n\tasync function handleSubmit() {\n\t\terrorMsg = \"\";\n\t\tconst trimmed = urlValue.trim();\n\t\tif (!isHttpsUrl(trimmed)) {\n\t\t\terrorMsg = \"Enter a valid HTTPS URL.\";\n\t\t\treturn;\n\t\t}\n\t\tloading = true;\n\t\ttry {\n\t\t\t// Use server proxy directly for one URL to validate size/types before creating File\n\t\t\tconst params = new URLSearchParams({ url: trimmed });\n\t\t\tif (acceptMimeTypes.length > 0) params.set(\"accept\", acceptMimeTypes.join(\",\"));\n\t\t\tconst proxyUrl = `${base}/api/fetch-url?${params}`;\n\t\t\tconst res = await fetch(proxyUrl);\n\t\t\tif (!res.ok) {\n\t\t\t\tconst txt = await res.text();\n\t\t\t\tthrow new Error(txt || `Failed to fetch (${res.status})`);\n\t\t\t}\n\t\t\tconst forwardedType = res.headers.get(\"x-forwarded-content-type\");\n\t\t\tconst blob = await res.blob();\n\t\t\tconst mimeType = pickSafeMime(forwardedType, blob.type, trimmed);\n\t\t\t// Optional client-side mime filter (same wildcard semantics as dropzone)\n\t\t\tif (acceptMimeTypes.length > 0 && mimeType && !matchesAllowed(mimeType, acceptMimeTypes)) {\n\t\t\t\tthrow new Error(\"File type not allowed.\");\n\t\t\t}\n\t\t\tconst disp = res.headers.get(\"content-disposition\");\n\t\t\tconst filename = (() => {\n\t\t\t\tconst filenameStar = disp?.match(/filename\\*=UTF-8''([^;]+)/i)?.[1];\n\t\t\t\tif (filenameStar) {\n\t\t\t\t\tconst cleaned = filenameStar.trim().replace(/['\"]/g, \"\");\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn decodeURIComponent(cleaned);\n\t\t\t\t\t} catch {\n\t\t\t\t\t\treturn cleaned;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst filenameMatch = disp?.match(/filename=\"?([^\";]+)\"?/i)?.[1];\n\t\t\t\tif (filenameMatch) return filenameMatch.trim();\n\t\t\t\ttry {\n\t\t\t\t\tconst u = new URL(trimmed);\n\t\t\t\t\tconst last = u.pathname.split(\"/\").pop() || \"attachment\";\n\t\t\t\t\treturn decodeURIComponent(last);\n\t\t\t\t} catch {\n\t\t\t\t\treturn \"attachment\";\n\t\t\t\t}\n\t\t\t})();\n\t\t\tconst file = new File([blob], filename, { type: mimeType });\n\t\t\tonfiles?.([file]);\n\t\t\tclose();\n\t\t} catch (e) {\n\t\t\terrorMsg = e instanceof Error ? e.message : \"Failed to fetch URL\";\n\t\t} finally {\n\t\t\tloading = false;\n\t\t}\n\t}\n</script>\n\n{#if open}\n\t<Modal onclose={close} width=\"w-[90dvh] md:w-[480px]\">\n\t\t{#snippet children()}\n\t\t\t<form\n\t\t\t\tclass=\"flex w-full flex-col gap-5 p-6\"\n\t\t\t\tonsubmit={(e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\thandleSubmit();\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div class=\"flex items-start justify-between\">\n\t\t\t\t\t<h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200\">Add from URL</h2>\n\t\t\t\t\t<button type=\"button\" class=\"group\" onclick={close} aria-label=\"Close\">\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\t\t\t\t\tviewBox=\"0 0 32 32\"\n\t\t\t\t\t\t\tclass=\"size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\td=\"M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z\"\n\t\t\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"flex flex-col gap-2\">\n\t\t\t\t\t<label class=\"text-sm text-gray-600 dark:text-gray-400\" for=\"fetch-url-input\"\n\t\t\t\t\t\t>Enter URL</label\n\t\t\t\t\t>\n\t\t\t\t\t<input\n\t\t\t\t\t\tid=\"fetch-url-input\"\n\t\t\t\t\t\tbind:this={inputEl}\n\t\t\t\t\t\tbind:value={urlValue}\n\t\t\t\t\t\ttype=\"url\"\n\t\t\t\t\t\tplaceholder=\"https://example.com/file.txt\"\n\t\t\t\t\t\tclass=\"w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-[15px] text-gray-800 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:ring-gray-700\"\n\t\t\t\t\t\taria-invalid={errorMsg ? \"true\" : \"false\"}\n\t\t\t\t\t\tonkeydown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\thandleSubmit();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{#if errorMsg}\n\t\t\t\t\t<p class=\"-mt-1 text-sm text-red-600 dark:text-red-400\">{errorMsg}</p>\n\t\t\t\t{/if}\n\t\t\t\t<p class=\"-mt-2 text-xs text-gray-500 dark:text-gray-400\">Only HTTPS. Max 10MB.</p>\n\n\t\t\t\t<div class=\"flex items-center justify-end gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600\"\n\t\t\t\t\t\tonclick={close}\n\t\t\t\t\t>\n\t\t\t\t\t\tCancel\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tclass=\"inline-flex items-center rounded-xl border border-gray-900 bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white\"\n\t\t\t\t\t\tdisabled={loading || urlValue.trim() === \"\"}\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if loading}Fetching…{:else}Add{/if}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t{/snippet}\n\t</Modal>\n{/if}\n\n<style lang=\"postcss\">\n\t:global(input) {\n\t\tfont-family: inherit;\n\t}\n\t/* Uses app-level colors and rounded/blur styles via utility classes */\n\t/* The Modal itself provides consistent container + scrollbar-custom styling */\n</style>\n"
  },
  {
    "path": "src/lib/components/chat/VoiceRecorder.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount, onDestroy } from \"svelte\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport CarbonCheckmark from \"~icons/carbon/checkmark\";\n\timport IconArrowUp from \"~icons/lucide/arrow-up\";\n\timport EosIconsLoading from \"~icons/eos-icons/loading\";\n\timport IconLoading from \"$lib/components/icons/IconLoading.svelte\";\n\timport AudioWaveform from \"$lib/components/voice/AudioWaveform.svelte\";\n\n\tinterface Props {\n\t\tisTranscribing: boolean;\n\t\tisTouchDevice: boolean;\n\t\toncancel: () => void;\n\t\tonconfirm: (audioBlob: Blob) => void;\n\t\tonsend: (audioBlob: Blob) => void;\n\t\tonerror: (message: string) => void;\n\t}\n\n\tlet { isTranscribing, isTouchDevice, oncancel, onconfirm, onsend, onerror }: Props = $props();\n\n\tlet mediaRecorder: MediaRecorder | null = $state(null);\n\tlet audioChunks: Blob[] = $state([]);\n\tlet analyser: AnalyserNode | null = $state(null);\n\tlet frequencyData: Uint8Array = $state(new Uint8Array(32));\n\tlet animationFrameId: number | null = $state(null);\n\tlet audioContext: AudioContext | null = $state(null);\n\tlet mediaStream: MediaStream | null = $state(null);\n\n\tfunction startVisualization() {\n\t\tfunction update() {\n\t\t\tif (analyser) {\n\t\t\t\tconst data = new Uint8Array(analyser.frequencyBinCount);\n\t\t\t\tanalyser.getByteFrequencyData(data);\n\t\t\t\t// Create new array to trigger Svelte reactivity\n\t\t\t\tfrequencyData = data;\n\t\t\t}\n\t\t\tanimationFrameId = requestAnimationFrame(update);\n\t\t}\n\t\tupdate();\n\t}\n\n\tfunction stopVisualization() {\n\t\tif (animationFrameId !== null) {\n\t\t\tcancelAnimationFrame(animationFrameId);\n\t\t\tanimationFrameId = null;\n\t\t}\n\t}\n\n\tasync function startRecording() {\n\t\ttry {\n\t\t\tconst stream = await navigator.mediaDevices.getUserMedia({\n\t\t\t\taudio: {\n\t\t\t\t\tchannelCount: 1,\n\t\t\t\t\tsampleRate: 16000, // Whisper prefers 16kHz\n\t\t\t\t\techoCancellation: true,\n\t\t\t\t\tnoiseSuppression: true,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tmediaStream = stream;\n\n\t\t\t// Set up audio context for visualization\n\t\t\taudioContext = new AudioContext();\n\t\t\tconst source = audioContext.createMediaStreamSource(stream);\n\t\t\tanalyser = audioContext.createAnalyser();\n\t\t\tanalyser.fftSize = 64; // Small for performance, gives 32 frequency bins\n\t\t\tanalyser.smoothingTimeConstant = 0.4;\n\t\t\tsource.connect(analyser);\n\t\t\tfrequencyData = new Uint8Array(analyser.frequencyBinCount);\n\n\t\t\t// Start MediaRecorder\n\t\t\t// Use webm/opus for broad browser support\n\t\t\tconst mimeType = MediaRecorder.isTypeSupported(\"audio/webm;codecs=opus\")\n\t\t\t\t? \"audio/webm;codecs=opus\"\n\t\t\t\t: \"audio/webm\";\n\n\t\t\tmediaRecorder = new MediaRecorder(stream, { mimeType });\n\t\t\taudioChunks = [];\n\n\t\t\tmediaRecorder.ondataavailable = (e) => {\n\t\t\t\tif (e.data.size > 0) {\n\t\t\t\t\taudioChunks = [...audioChunks, e.data];\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tmediaRecorder.start(100); // Collect data every 100ms\n\t\t\tstartVisualization();\n\t\t} catch (err) {\n\t\t\tif (err instanceof DOMException) {\n\t\t\t\tif (err.name === \"NotAllowedError\") {\n\t\t\t\t\tonerror(\"Microphone access denied. Please allow in browser settings.\");\n\t\t\t\t} else if (err.name === \"NotFoundError\") {\n\t\t\t\t\tonerror(\"No microphone found.\");\n\t\t\t\t} else {\n\t\t\t\t\tonerror(`Microphone error: ${err.message}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tonerror(\"Could not access microphone.\");\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction stopRecording(): Promise<Blob | null> {\n\t\treturn new Promise((resolve) => {\n\t\t\tstopVisualization();\n\n\t\t\t// Stop all audio tracks\n\t\t\tif (mediaStream) {\n\t\t\t\tmediaStream.getTracks().forEach((track) => track.stop());\n\t\t\t\tmediaStream = null;\n\t\t\t}\n\n\t\t\t// Close audio context\n\t\t\tif (audioContext) {\n\t\t\t\taudioContext.close();\n\t\t\t\taudioContext = null;\n\t\t\t}\n\t\t\tanalyser = null;\n\n\t\t\tif (!mediaRecorder || mediaRecorder.state === \"inactive\") {\n\t\t\t\tmediaRecorder = null;\n\t\t\t\tresolve(\n\t\t\t\t\taudioChunks.length > 0\n\t\t\t\t\t\t? new Blob(audioChunks, { type: audioChunks[0]?.type || \"audio/webm\" })\n\t\t\t\t\t\t: null\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Wait for final data before resolving\n\t\t\tmediaRecorder.onstop = () => {\n\t\t\t\tconst mimeType = audioChunks[0]?.type || \"audio/webm\";\n\t\t\t\tconst blob = audioChunks.length > 0 ? new Blob(audioChunks, { type: mimeType }) : null;\n\t\t\t\tmediaRecorder = null;\n\t\t\t\tresolve(blob);\n\t\t\t};\n\n\t\t\tmediaRecorder.stop();\n\t\t});\n\t}\n\n\tasync function handleCancel() {\n\t\tawait stopRecording();\n\t\toncancel();\n\t}\n\n\tasync function handleConfirm() {\n\t\tconst audioBlob = await stopRecording();\n\t\tif (audioBlob && audioBlob.size > 0) {\n\t\t\tif (isTouchDevice) {\n\t\t\t\tonsend(audioBlob);\n\t\t\t} else {\n\t\t\t\tonconfirm(audioBlob);\n\t\t\t}\n\t\t} else {\n\t\t\tonerror(\"No audio recorded. Please try again.\");\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\tstartRecording();\n\t});\n\n\tonDestroy(() => {\n\t\t// Fire and forget - cleanup happens but we don't wait\n\t\tstopRecording();\n\t});\n</script>\n\n<div class=\"flex h-full w-full items-center justify-between px-3 py-1.5\">\n\t<!-- Cancel button -->\n\t<button\n\t\ttype=\"button\"\n\t\tclass=\"btn grid size-8 place-items-center rounded-full border bg-white text-black shadow transition-none hover:bg-gray-100 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500 sm:size-7\"\n\t\tonclick={handleCancel}\n\t\taria-label=\"Cancel recording\"\n\t>\n\t\t<CarbonClose class=\"size-4\" />\n\t</button>\n\n\t<!-- Waveform / Loading -->\n\t<div class=\"flex h-12 flex-1 items-center overflow-hidden pl-2.5 pr-1.5\">\n\t\t{#if isTranscribing}\n\t\t\t<div class=\"flex h-full w-full items-center justify-center\">\n\t\t\t\t<IconLoading classNames=\"text-gray-400\" />\n\t\t\t</div>\n\t\t{:else}\n\t\t\t<AudioWaveform {frequencyData} minHeight={4} maxHeight={40} />\n\t\t{/if}\n\t</div>\n\n\t<!-- Confirm/Send button -->\n\t<button\n\t\ttype=\"button\"\n\t\tclass=\"btn grid size-8 place-items-center rounded-full border shadow transition-none disabled:opacity-50 sm:size-7 {isTouchDevice\n\t\t\t? 'border-transparent bg-black text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200'\n\t\t\t: 'bg-white text-black hover:bg-gray-100 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500'}\"\n\t\tonclick={handleConfirm}\n\t\tdisabled={isTranscribing}\n\t\taria-label={isTranscribing\n\t\t\t? \"Transcribing...\"\n\t\t\t: isTouchDevice\n\t\t\t\t? \"Send message\"\n\t\t\t\t: \"Confirm and transcribe\"}\n\t>\n\t\t{#if isTranscribing}\n\t\t\t<EosIconsLoading class=\"size-4\" />\n\t\t{:else if isTouchDevice}\n\t\t\t<IconArrowUp class=\"size-4\" />\n\t\t{:else}\n\t\t\t<CarbonCheckmark class=\"size-4\" />\n\t\t{/if}\n\t</button>\n</div>\n"
  },
  {
    "path": "src/lib/components/icons/IconBurger.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tfill=\"none\"\n\tviewBox=\"0 0 16 16\"\n\t><path\n\t\td=\"M8.795 10.418a.84.84 0 1 1 0 1.681H1.907a.84.84 0 0 1 0-1.681h6.888ZM14.093 3.9a.841.841 0 0 1 0 1.682H1.907a.84.84 0 0 1 0-1.682h12.186Z\"\n\t\tfill=\"currentColor\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconCheap.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 12 12\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M6 7.778a.856.856 0 0 1-.628-.261.858.858 0 0 1-.26-.628c-.001-.245.086-.454.26-.628A.861.861 0 0 1 6 6c.244 0 .453.087.628.261a.852.852 0 0 1 .26.628.867.867 0 0 1-.26.628.844.844 0 0 1-.628.26Zm-2.056-4h4.112l.566-1.134a.423.423 0 0 0-.017-.433A.42.42 0 0 0 8.222 2H3.778c-.17 0-.298.07-.383.211a.424.424 0 0 0-.017.433l.566 1.134ZM4.4 10h3.2c.667 0 1.233-.231 1.7-.694.467-.463.7-1.032.7-1.706 0-.281-.048-.556-.144-.822a2.404 2.404 0 0 0-.412-.722L8.29 4.666H3.71l-1.155 1.39a2.404 2.404 0 0 0-.412.722C2.048 7.044 2 7.318 2 7.6c0 .674.232 1.243.695 1.706.463.463 1.031.694 1.705.694Z\"\n\t\tfill=\"currentColor\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconChevron.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 15 6\"\n\tclass={classNames}\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M1.67236 1L7.67236 7L13.6724 1\"\n\t\tstroke=\"currentColor\"\n\t\tstroke-width=\"2\"\n\t\tstroke-linecap=\"round\"\n\t\tstroke-linejoin=\"round\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconDazzled.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tclass={classNames}\n\tfill=\"none\"\n\tviewBox=\"0 0 26 23\"\n>\n\t<path\n\t\tfill=\"url(#gr)\"\n\t\td=\"M.93 10.65A10.17 10.17 0 0 1 11.11.48h4.67a9.45 9.45 0 0 1 0 18.89H4.53L1.62 22.2a.38.38 0 0 1-.69-.28V10.65Z\"\n\t/>\n\t<path\n\t\tfill=\"#000\"\n\t\tfill-rule=\"evenodd\"\n\t\td=\"M11.52 7.4a1.86 1.86 0 1 1-3.72 0 1.86 1.86 0 0 1 3.72 0Zm7.57 0a1.86 1.86 0 1 1-3.73 0 1.86 1.86 0 0 1 3.73 0ZM8.9 12.9a.55.55 0 0 0-.11.35.76.76 0 0 1-1.51 0c0-.95.67-1.94 1.76-1.94 1.09 0 1.76 1 1.76 1.94H9.3a.55.55 0 0 0-.12-.35c-.06-.07-.1-.08-.13-.08s-.08 0-.14.08Zm4.04 0a.55.55 0 0 0-.12.35h-1.51c0-.95.68-1.94 1.76-1.94 1.1 0 1.77 1 1.77 1.94h-1.51a.55.55 0 0 0-.12-.35c-.06-.07-.11-.08-.14-.08-.02 0-.07 0-.13.08Zm-1.89.79c-.02 0-.07-.01-.13-.08a.55.55 0 0 1-.12-.36h-1.5c0 .95.67 1.95 1.75 1.95 1.1 0 1.77-1 1.77-1.95h-1.51c0 .16-.06.28-.12.36-.06.07-.11.08-.14.08Zm4.04 0c-.03 0-.08-.01-.14-.08a.55.55 0 0 1-.12-.36h-1.5c0 .95.67 1.95 1.76 1.95 1.08 0 1.76-1 1.76-1.95h-1.51c0 .16-.06.28-.12.36-.06.07-.11.08-.13.08Zm1.76-.44c0-.16.05-.28.12-.35.06-.07.1-.08.13-.08s.08 0 .14.08c.06.07.11.2.11.35a.76.76 0 0 0 1.51 0c0-.95-.67-1.94-1.76-1.94-1.09 0-1.76 1-1.76 1.94h1.5Z\"\n\t\tclip-rule=\"evenodd\"\n\t/>\n\t<defs>\n\t\t<radialGradient\n\t\t\tid=\"gr\"\n\t\t\tcx=\"0\"\n\t\t\tcy=\"0\"\n\t\t\tr=\"1\"\n\t\t\tgradientTransform=\"matrix(0 31.37 -34.85 0 13.08 -9.02)\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t>\n\t\t\t<stop stop-color=\"#FFD21E\" />\n\t\t\t<stop offset=\"1\" stop-color=\"red\" />\n\t\t</radialGradient>\n\t</defs>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconFast.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 12 12\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M6 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 .8A3.2 3.2 0 0 1 9.2 6c0 .96-.4 1.8-1.08 2.4-.56-.52-1.32-.8-2.12-.8s-1.52.28-2.12.8A3.15 3.15 0 0 1 2.8 6 3.2 3.2 0 0 1 6 2.8Zm-.8.8a.4.4 0 1 0 0 .8.4.4 0 0 0 0-.8Zm1.6 0a.4.4 0 1 0 0 .8.4.4 0 0 0 0-.8Zm1.236 1.176c-.052 0-.1.012-.156.024l-1.28.528-.108.044a.807.807 0 0 0-1.053.059.796.796 0 0 0-.008 1.13.796.796 0 0 0 .869.179.81.81 0 0 0 .5-.628l.092-.04 1.288-.52.008-.004a.4.4 0 0 0-.152-.772ZM4 4.8a.4.4 0 1 0 0 .8.4.4 0 0 0 0-.8Z\"\n\t\tfill=\"currentColor\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconLoading.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<div class={\"inline-flex h-8 flex-none items-center gap-1 \" + classNames}>\n\t<div\n\t\tclass=\"h-1 w-1 flex-none animate-bounce rounded-full bg-gray-500 dark:bg-gray-400\"\n\t\tstyle=\"animation-delay: 0.25s;\"\n\t></div>\n\t<div\n\t\tclass=\"h-1 w-1 flex-none animate-bounce rounded-full bg-gray-500 dark:bg-gray-400\"\n\t\tstyle=\"animation-delay: 0.5s;\"\n\t></div>\n\t<div\n\t\tclass=\"h-1 w-1 flex-none animate-bounce rounded-full bg-gray-500 dark:bg-gray-400\"\n\t\tstyle=\"animation-delay: 0.75s;\"\n\t></div>\n</div>\n"
  },
  {
    "path": "src/lib/components/icons/IconMCP.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 24 24\"\n>\n\t<g\n\t\tfill=\"none\"\n\t\tstroke=\"currentColor\"\n\t\tstroke-linecap=\"round\"\n\t\tstroke-linejoin=\"round\"\n\t\tstroke-width=\"1.5\"\n\t>\n\t\t<path\n\t\t\td=\"m3.5 11.75l8.172-8.171a2.828 2.828 0 1 1 4 4m0 0L9.5 13.75m6.172-6.171a2.828 2.828 0 0 1 4 4l-6.965 6.964a1 1 0 0 0 0 1.414L14 21.25\"\n\t\t/>\n\t\t<path d=\"m17.5 9.75l-6.172 6.171a2.829 2.829 0 0 1-4-4L13.5 5.749\" />\n\t</g>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconMoon.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\twidth=\"50\"\n\theight=\"50\"\n\tclass={classNames}\n\tviewBox=\"0 0 50 50\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M25.054 43.02C20.02 43.02 15.762 41.278 12.28 37.794C8.79695 34.31 7.05496 30.052 7.05396 25.02C7.05396 20.42 8.55396 16.428 11.554 13.044C14.554 9.66 18.387 7.685 23.054 7.12C23.487 7.053 23.87 7.112 24.204 7.296C24.537 7.48 24.804 7.721 25.004 8.02C25.204 8.318 25.312 8.668 25.33 9.07C25.347 9.471 25.222 9.854 24.954 10.22C24.3955 11.0688 23.9655 11.9955 23.678 12.97C23.3906 13.961 23.2477 14.9882 23.254 16.02C23.254 19.02 24.304 21.57 26.404 23.67C28.504 25.77 31.054 26.82 34.054 26.82C35.087 26.82 36.112 26.67 37.13 26.37C38.096 26.0936 39.0145 25.6721 39.854 25.12C40.22 24.886 40.596 24.778 40.98 24.796C41.364 24.813 41.705 24.904 42.004 25.07C42.337 25.236 42.596 25.486 42.78 25.82C42.964 26.153 43.022 26.553 42.954 27.02C42.487 31.62 40.529 35.436 37.08 38.47C33.63 41.503 29.622 43.02 25.054 43.02Z\"\n\t\tfill=\"#D2D5DB\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconNew.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tfill=\"none\"\n\tviewBox=\"0 0 16 16\"\n\t><path\n\t\td=\"M7.258 1.856c.333 0 .66.024.979.07-.558.319-.972.86-1.123 1.503A5.254 5.254 0 1 0 9.32 13.513l.275-.127c.334-.17.712-.229 1.08-.17l.158.031.01.003 1.343.36-.359-1.345a1.77 1.77 0 0 1 .137-1.247 5.23 5.23 0 0 0 .538-2.041 2.356 2.356 0 0 0 1.544-1 6.808 6.808 0 0 1-.676 3.742v.001c-.034.066-.031.116-.025.14l.36 1.345a1.572 1.572 0 0 1-1.823 1.945l-.1-.024-1.334-.357a.2.2 0 0 0-.14.018l-.012.005A6.825 6.825 0 1 1 7.259 1.856Zm4.837-1.36c.434 0 .785.352.785.786v1.905h1.9a.785.785 0 0 1 0 1.57h-1.9v1.9a.786.786 0 1 1-1.57 0v-1.9H9.404a.785.785 0 0 1 0-1.57h1.906V1.282c0-.434.352-.787.785-.787Z\"\n\t\tfill=\"currentColor\"\n\t/></svg\n>\n"
  },
  {
    "path": "src/lib/components/icons/IconOmni.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\tclass=\"{classNames} hidden dark:inline\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 17 17\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M5.97736 12.1813C6.25011 12.516 6.57428 12.8946 6.98029 13.2741C5.89251 13.8066 4.44063 14.1305 2.34747 14.1306V12.7272C4.02144 12.7272 5.15855 12.5026 5.97736 12.1813ZM10.0789 6.00458C10.3483 6.3067 10.6247 6.56949 10.9725 6.79364C11.5911 7.19216 12.4914 7.49774 14.0526 7.49774V8.90204C12.4915 8.90204 11.5911 9.20765 10.9725 9.60614C10.6249 9.83013 10.3481 10.0924 10.0789 10.3942C9.78258 10.1597 9.52333 9.87047 9.21271 9.48798C9.18183 9.44996 9.14961 9.40984 9.11603 9.36786C9.42491 9.03403 9.77986 8.70638 10.2127 8.42743C10.3378 8.34683 10.4686 8.27118 10.6053 8.19989C10.4686 8.12858 10.3378 8.05297 10.2127 7.97235C9.77958 7.69322 9.42506 7.365 9.11603 7.03094C9.1494 6.98922 9.18201 6.9496 9.21271 6.9118C9.52349 6.52912 9.78237 6.2392 10.0789 6.00458ZM2.34747 2.26923C4.44032 2.26927 5.89256 2.59232 6.98029 3.12469C6.57429 3.50414 6.25012 3.8828 5.97736 4.21747C5.15858 3.89631 4.02115 3.67356 2.34747 3.67352V2.26923Z\"\n\t\tfill=\"url(#paint0_linear_3699_582)\"\n\t/>\n\t<path\n\t\td=\"M14.052 3.67331C12.0512 3.67337 10.8161 3.98917 9.97647 4.41441C9.14382 4.83623 8.63688 5.39533 8.12318 6.02791C7.62178 6.64535 7.06413 7.40735 6.18741 7.97235C6.06225 8.053 5.93137 8.12889 5.79462 8.20022C5.93144 8.27158 6.06219 8.34739 6.18741 8.42808C7.06422 8.99314 7.62174 9.75505 8.12318 10.3725C8.6369 11.0051 9.14374 11.5642 9.97647 11.986C10.8161 12.4113 12.0512 12.7271 14.052 12.7271V14.1312C11.9098 14.1311 10.4387 13.7932 9.34279 13.2382C8.24007 12.6797 7.58149 11.9313 7.03377 11.2569C6.47365 10.5671 6.07238 10.0218 5.42786 9.60647C4.80925 9.20786 3.90875 8.90226 2.34735 8.90226V7.49818C3.90859 7.49818 4.80926 7.19251 5.42786 6.79397C6.07232 6.37865 6.47373 5.83323 7.03377 5.14358C7.58147 4.46911 8.24014 3.72078 9.34279 3.16224C10.4387 2.60722 11.9098 2.26929 14.052 2.26923V3.67331Z\"\n\t\tfill=\"url(#paint1_linear_3699_582)\"\n\t/>\n\t<defs>\n\t\t<linearGradient\n\t\t\tid=\"paint0_linear_3699_582\"\n\t\t\tx1=\"10.2846\"\n\t\t\ty1=\"8.06294\"\n\t\t\tx2=\"-0.714687\"\n\t\t\ty2=\"8.06294\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t>\n\t\t\t<stop stop-color=\"white\" />\n\t\t\t<stop offset=\"1\" stop-color=\"white\" stop-opacity=\"0\" />\n\t\t</linearGradient>\n\t\t<linearGradient\n\t\t\tid=\"paint1_linear_3699_582\"\n\t\t\tx1=\"1.34749\"\n\t\t\ty1=\"8.06326\"\n\t\t\tx2=\"14.273\"\n\t\t\ty2=\"8.06326\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t>\n\t\t\t<stop stop-color=\"white\" stop-opacity=\"0\" />\n\t\t\t<stop offset=\"1\" stop-color=\"white\" />\n\t\t</linearGradient>\n\t</defs>\n</svg>\n<svg\n\tclass=\"{classNames} inline dark:hidden\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 17 17\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n>\n\t<path\n\t\td=\"M5.97723 12.3813C6.24999 12.716 6.57417 13.0946 6.98016 13.4741C5.89247 14.0066 4.44119 14.3305 2.34833 14.3306V12.9272C4.02198 12.9272 5.1585 12.7025 5.97723 12.3813ZM10.0788 6.20459C10.3481 6.50673 10.6245 6.76948 10.9724 6.99365C11.5909 7.39219 12.4912 7.69774 14.0524 7.69775V9.10205C12.4913 9.10207 11.5909 9.40765 10.9724 9.80615C10.6248 10.0302 10.348 10.2924 10.0788 10.5942C9.78249 10.3597 9.52319 10.0704 9.21259 9.68799C9.18171 9.64997 9.14949 9.60986 9.11591 9.56787C9.42481 9.23402 9.77972 8.9064 10.2126 8.62744C10.3377 8.54682 10.4685 8.47121 10.6052 8.3999C10.4685 8.32859 10.3377 8.25299 10.2126 8.17236C9.77943 7.89322 9.42495 7.56504 9.11591 7.23096C9.1493 7.18921 9.18187 7.14963 9.21259 7.11182C9.52337 6.72913 9.78226 6.43921 10.0788 6.20459ZM2.34833 2.46924C4.44088 2.46933 5.89252 2.7924 6.98016 3.32471C6.57418 3.70415 6.25 4.08282 5.97723 4.41748C5.15853 4.09637 4.0217 3.87361 2.34833 3.87354V2.46924Z\"\n\t\tfill=\"url(#paint0_linear_3699_575)\"\n\t/>\n\t<path\n\t\td=\"M14.052 3.87332C12.0512 3.87338 10.8161 4.18918 9.97647 4.61442C9.14382 5.03624 8.63688 5.59534 8.12318 6.22792C7.62178 6.84536 7.06413 7.60736 6.18741 8.17236C6.06225 8.25301 5.93137 8.3289 5.79462 8.40023C5.93144 8.47159 6.06219 8.5474 6.18741 8.62809C7.06422 9.19316 7.62174 9.95506 8.12318 10.5725C8.6369 11.2051 9.14374 11.7642 9.97647 12.186C10.8161 12.6113 12.0512 12.9271 14.052 12.9271V14.3312C11.9098 14.3312 10.4387 13.9932 9.34279 13.4382C8.24007 12.8797 7.58149 12.1313 7.03377 11.4569C6.47365 10.7671 6.07238 10.2218 5.42786 9.80648C4.80925 9.40788 3.90875 9.10227 2.34735 9.10227V7.69819C3.90859 7.69819 4.80926 7.39252 5.42786 6.99398C6.07232 6.57866 6.47373 6.03324 7.03377 5.34359C7.58147 4.66913 8.24014 3.92079 9.34279 3.36225C10.4387 2.80724 11.9098 2.4693 14.052 2.46924V3.87332Z\"\n\t\tfill=\"url(#paint1_linear_3699_575)\"\n\t/>\n\t<defs>\n\t\t<linearGradient\n\t\t\tid=\"paint0_linear_3699_575\"\n\t\t\tx1=\"10.2848\"\n\t\t\ty1=\"8.26295\"\n\t\t\tx2=\"-0.713577\"\n\t\t\ty2=\"8.26295\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t>\n\t\t\t<stop />\n\t\t\t<stop offset=\"1\" stop-opacity=\"0\" />\n\t\t</linearGradient>\n\t\t<linearGradient\n\t\t\tid=\"paint1_linear_3699_575\"\n\t\t\tx1=\"1.34749\"\n\t\t\ty1=\"8.26327\"\n\t\t\tx2=\"14.273\"\n\t\t\ty2=\"8.26327\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t>\n\t\t\t<stop stop-opacity=\"0\" />\n\t\t\t<stop offset=\"1\" />\n\t\t</linearGradient>\n\t</defs>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconPaperclip.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\tclass={classNames}\n\txmlns=\"http://www.w3.org/2000/svg\"\n\taria-hidden=\"true\"\n\tfocusable=\"false\"\n\trole=\"img\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tfill=\"currentColor\"\n\tpreserveAspectRatio=\"xMidYMid meet\"\n\tviewBox=\"0 0 32 32\"\n\t><path\n\t\td=\"M19.02 5.57a5.77 5.77 0 1 1 8.56 7.74L16.6 25.45l-.02.01v.01A7.87 7.87 0 0 1 4.92 14.9L12.95 6A1.18 1.18 0 0 1 14.7 7.6l-8.03 8.87a5.51 5.51 0 1 0 8.19 7.4l10.97-12.14a3.41 3.41 0 1 0-5.06-4.58l-9.32 10.3a1.27 1.27 0 1 0 1.88 1.7l6.28-6.94a1.18 1.18 0 0 1 1.75 1.59l-6.28 6.94a3.63 3.63 0 0 1-5.41-4.83l.02-.02 9.33-10.32Z\"\n\t\tfill=\"currentColor\"\n\t/></svg\n>\n"
  },
  {
    "path": "src/lib/components/icons/IconPro.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n\n\t// I've no idea wht a fixed id doesnt work...\n\tconst gradientId = `gradient-${Math.random().toString(36).slice(2, 9)}`;\n</script>\n\n<svg\n\tclass=\"text-gray-500 {classNames}\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n\txmlns:xlink=\"http://www.w3.org/1999/xlink\"\n\trole=\"img\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 12 12\"\n\t><defs\n\t\t><linearGradient\n\t\t\tid={gradientId}\n\t\t\tx1=\"3.371\"\n\t\t\ty1=\"3.43\"\n\t\t\tx2=\"8.141\"\n\t\t\ty2=\"8.9\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t\t><stop stop-color=\"#FF0789\" /><stop offset=\".63\" stop-color=\"#21DE75\" /><stop\n\t\t\t\toffset=\"1\"\n\t\t\t\tstop-color=\"#FF8D00\"\n\t\t\t/></linearGradient\n\t\t></defs\n\t><path\n\t\td=\"M6.481 1.26c0 1.55.67 2.58 1.5 3.24.86.68 1.9 1 2.58 1.07v.86a5.3 5.3 0 0 0-2.57 1.07 3.95 3.95 0 0 0-1.51 3.24h-.96c0-1.55-.67-2.58-1.5-3.24a5.3 5.3 0 0 0-2.58-1.07v-.86a5.3 5.3 0 0 0 2.57-1.07 3.95 3.95 0 0 0 1.51-3.24h.96Z\"\n\t\tfill=\"url(#{gradientId})\"\n\t/></svg\n>\n"
  },
  {
    "path": "src/lib/components/icons/IconShare.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tclass={classNames}\n\twidth=\"1em\"\n\theight=\"1em\"\n\tfill=\"none\"\n\tviewBox=\"0 0 12 12\"\n>\n\t<path\n\t\td=\"M10.4646 6.85139C10.7605 6.85139 11 7.09093 11 7.38679V7.78965C11 8.35479 11.0013 8.82459 10.9581 9.20053C10.9136 9.58762 10.8165 9.94247 10.5745 10.2495C10.478 10.3719 10.3672 10.4826 10.2448 10.5791C9.93774 10.8212 9.58211 10.9183 9.19497 10.9628C8.81915 11.006 8.34979 11.0055 7.78496 11.0055H4.21503C3.6502 11.0055 3.18083 11.006 2.80502 10.9628C2.41788 10.9183 2.06224 10.8212 1.75515 10.5791C1.63274 10.4826 1.52198 10.3718 1.42554 10.2495C1.18354 9.94248 1.08635 9.58761 1.04186 9.20053C0.998661 8.82458 1 8.35479 1 7.78965V7.38679C1.00003 7.09093 1.23954 6.85139 1.53541 6.85139C1.83128 6.85139 2.07078 7.09093 2.07081 7.38679V7.78965C2.07081 8.38023 2.07202 8.77788 2.10656 9.07845C2.13978 9.36728 2.19822 9.49857 2.26701 9.58595C2.31143 9.64228 2.3625 9.69333 2.41873 9.73767C2.50614 9.80657 2.63774 9.86487 2.9271 9.89812C3.2276 9.93264 3.62467 9.93387 4.21503 9.93387H7.78496C8.37532 9.93387 8.77238 9.93264 9.07289 9.89812C9.36227 9.86487 9.49384 9.80658 9.58126 9.73767C9.63752 9.69329 9.68862 9.64222 9.73298 9.58595C9.80176 9.49856 9.86021 9.3673 9.89343 9.07845C9.92796 8.77788 9.92918 8.38023 9.92918 7.78965V7.38679C9.92921 7.09093 10.1687 6.85139 10.4646 6.85139ZM6.01046 1.00034C6.15239 1.0004 6.2885 1.05697 6.3889 1.15729L9.36849 4.13601C9.57767 4.34519 9.57759 4.68454 9.36849 4.89377C9.15925 5.10283 8.8199 5.10294 8.61073 4.89377L6.54586 2.8289V8.02945C6.54586 8.32526 6.30624 8.56559 6.01046 8.56572C5.71472 8.56555 5.47418 8.32523 5.47418 8.02945V2.8289L3.40931 4.89377C3.20011 5.10268 2.86157 5.10279 2.65243 4.89377C2.44341 4.68459 2.44341 4.34519 2.65243 4.13601L5.63114 1.15729C5.73154 1.0569 5.86848 1.00042 6.01046 1.00034Z\"\n\t\tfill=\"currentColor\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/IconSun.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\twidth=\"1em\"\n\theight=\"1em\"\n\tviewBox=\"0 0 49 49\"\n\tfill=\"none\"\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tclass={classNames}\n>\n\t<mask\n\t\tid=\"a\"\n\t\tstyle=\"mask-type:alpha\"\n\t\tmaskUnits=\"userSpaceOnUse\"\n\t\tx=\"2\"\n\t\ty=\"2\"\n\t\twidth=\"45\"\n\t\theight=\"45\"\n\t>\n\t\t<path\n\t\t\td=\"M24.501 39c.438 0 .784.143 1.071.43.288.287.43.633.428 1.068V44.5c0 .44-.144.787-.43 1.073-.285.285-.63.428-1.069.427H24.5c-.44 0-.786-.143-1.07-.428-.25-.25-.391-.548-.423-.913L23 44.5V40.5c0-.44.143-.786.429-1.07.286-.285.632-.429 1.072-.43Zm11.26-4.673c.43.013.787.157 1.093.435l2.147 2.096.005.006.006.005c.287.263.426.588.414 1.017a1.66 1.66 0 0 1-.433 1.112c-.305.305-.66.453-1.093.453-.433 0-.764-.147-1.032-.439l-.005-.006-.006-.005-2.093-2.144a1.537 1.537 0 0 1-.414-1.08c0-.426.136-.755.404-1.024l.117-.117c.245-.22.533-.32.89-.31Zm-22.537.023c.425 0 .755.137 1.023.404l.117.117c.22.246.322.534.312.891-.013.43-.157.787-.439 1.094L12.143 39l-.006.005-.005.006c-.264.288-.589.426-1.018.414a1.66 1.66 0 0 1-1.113-.433 1.476 1.476 0 0 1-.451-1.092c0-.434.147-.765.438-1.033l.006-.004.005-.006 2.146-2.096a1.537 1.537 0 0 1 1.079-.412ZM24.5 15c2.632 0 4.863.924 6.723 2.78 1.858 1.857 2.78 4.087 2.777 6.72-.004 2.633-.927 4.865-2.78 6.723-1.852 1.857-4.082 2.78-6.719 2.777H24.5c-2.634 0-4.866-.922-6.72-2.779-1.856-1.857-2.779-4.087-2.78-6.72-.001-2.634.921-4.865 2.78-6.721C19.636 15.923 21.867 15 24.5 15Zm-20 8h4c.44 0 .786.144 1.072.43.286.286.429.631.428 1.07-.001.439-.145.786-.43 1.074-.284.285-.629.428-1.068.426H4.5c-.44 0-.786-.143-1.07-.428-.285-.287-.429-.633-.43-1.073-.001-.438.142-.783.429-1.069.288-.287.634-.43 1.071-.43Zm36 0h4c.44 0 .786.144 1.072.43.286.286.429.631.428 1.07-.001.439-.145.786-.43 1.074-.284.285-.629.428-1.068.426H40.5c-.44 0-.786-.143-1.07-.428-.285-.287-.429-.633-.43-1.073-.001-.438.142-.783.429-1.069.288-.287.634-.43 1.071-.43ZM11.1 9.55c.433 0 .764.147 1.032.439l.005.006.006.004 2.092 2.144c.281.308.415.654.415 1.058 0 .405-.135.752-.419 1.061a1.216 1.216 0 0 1-.99.414 1.635 1.635 0 0 1-1.098-.44l-2.144-2.093-.005-.006-.006-.005-.101-.102c-.22-.245-.323-.54-.313-.915a1.66 1.66 0 0 1 .435-1.116c.305-.302.659-.449 1.09-.449Zm26.786.025c.445.012.808.157 1.112.431.305.306.452.66.452 1.094 0 .434-.147.765-.438 1.032l-.006.005-.005.006-2.145 2.093c-.308.28-.653.415-1.056.415-.406 0-.752-.136-.062-.42a1.216 1.216 0 0 1-.414-.99c.013-.428.157-.787.44-1.098l2.093-2.144.006-.004.005-.006c.264-.288.589-.426 1.018-.414ZM24.5 3c.438 0 .784.143 1.071.43.288.287.43.633.428 1.068V8.5c0 .44-.144.787-.43 1.073-.285.285-.63.428-1.069.427H24.5c-.44 0-.786-.143-1.07-.428-.25-.25-.391-.548-.423-.912L23 8.5v-4c0-.44.143-.786.429-1.07.286-.285.632-.429 1.072-.43Z\"\n\t\t\tfill=\"#E9E9E9\"\n\t\t\tstroke=\"#000\"\n\t\t/>\n\t</mask>\n\t<g mask=\"url(#a)\">\n\t\t<path\n\t\t\td=\"M24.5 34.5c-2.767 0-5.125-.975-7.074-2.926-1.95-1.95-2.925-4.308-2.926-7.074-.001-2.765.974-5.123 2.926-7.074 1.952-1.95 4.31-2.926 7.074-2.926 2.764 0 5.123.976 7.076 2.926 1.953 1.951 2.928 4.309 2.924 7.074-.004 2.766-.98 5.124-2.926 7.076-1.947 1.952-4.305 2.927-7.074 2.924Zm-20-8c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424-.001-.565.19-1.04.576-1.424.385-.384.86-.576 1.424-.576h4c.567 0 1.042.192 1.426.576.384.384.575.859.574 1.424-.001.566-.193 1.041-.576 1.426-.383.386-.857.577-1.424.574h-4Zm36 0c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424-.001-.565.19-1.04.576-1.424.385-.384.86-.576 1.424-.576h4c.567 0 1.042.192 1.426.576.384.384.575.859.574 1.424-.001.566-.193 1.041-.576 1.426-.383.386-.857.577-1.424.574h-4Zm-16-16c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424v-4c0-.566.192-1.041.576-1.424.384-.382.859-.574 1.424-.576a1.93 1.93 0 0 1 1.426.576c.385.386.577.86.574 1.424v4c0 .567-.192 1.042-.576 1.426-.384.384-.859.576-1.424.574Zm0 36c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424v-4c0-.566.192-1.041.576-1.424.384-.382.859-.574 1.424-.576a1.93 1.93 0 0 1 1.426.576c.385.386.577.86.574 1.424v4c0 .567-.192 1.042-.576 1.426-.384.384-.859.576-1.424.574ZM11.8 14.6l-2.15-2.1c-.4-.366-.592-.833-.576-1.4a2.16 2.16 0 0 1 .576-1.45c.4-.4.883-.6 1.45-.6s1.033.2 1.4.6l2.1 2.15c.367.4.55.867.55 1.4 0 .534-.183 1-.55 1.4-.367.4-.825.592-1.374.576A2.137 2.137 0 0 1 11.8 14.6Zm24.7 24.75-2.1-2.15c-.367-.4-.55-.874-.55-1.424 0-.549.183-1.008.55-1.376a1.71 1.71 0 0 1 1.376-.574 2.14 2.14 0 0 1 1.424.574l2.15 2.1c.4.367.592.834.576 1.4a2.16 2.16 0 0 1-.576 1.45c-.4.4-.883.6-1.45.6s-1.033-.2-1.4-.6ZM34.4 14.6a1.714 1.714 0 0 1-.576-1.374c.016-.549.208-1.024.576-1.426l2.1-2.15c.367-.4.833-.592 1.4-.576a2.16 2.16 0 0 1 1.45.576c.4.4.6.884.6 1.45 0 .567-.2 1.034-.6 1.4l-2.15 2.1c-.4.367-.867.55-1.4.55-.533 0-1-.183-1.4-.55ZM9.65 39.35c-.4-.4-.6-.883-.6-1.45 0-.566.2-1.033.6-1.4l2.15-2.1c.4-.366.875-.55 1.424-.55.55 0 1.008.184 1.376.55.4.367.592.826.576 1.376A2.124 2.124 0 0 1 14.6 37.2l-2.1 2.15c-.367.4-.833.592-1.4.576a2.16 2.16 0 0 1-1.45-.576Z\"\n\t\t\tfill=\"#fff\"\n\t\t/>\n\t\t<path\n\t\t\td=\"M24.5 34.5c-2.767 0-5.125-.975-7.074-2.926-1.95-1.95-2.925-4.308-2.926-7.074-.001-2.765.974-5.123 2.926-7.074 1.952-1.95 4.31-2.926 7.074-2.926 2.764 0 5.123.976 7.076 2.926 1.953 1.951 2.928 4.309 2.924 7.074-.004 2.766-.98 5.124-2.926 7.076-1.947 1.952-4.305 2.927-7.074 2.924Zm-20-8c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424-.001-.565.19-1.04.576-1.424.385-.384.86-.576 1.424-.576h4c.567 0 1.042.192 1.426.576.384.384.575.859.574 1.424-.001.566-.193 1.041-.576 1.426-.383.386-.857.577-1.424.574h-4Zm36 0c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424-.001-.565.19-1.04.576-1.424.385-.384.86-.576 1.424-.576h4c.567 0 1.042.192 1.426.576.384.384.575.859.574 1.424-.001.566-.193 1.041-.576 1.426-.383.386-.857.577-1.424.574h-4Zm-16-16c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424v-4c0-.566.192-1.041.576-1.424.384-.382.859-.574 1.424-.576a1.93 1.93 0 0 1 1.426.576c.385.386.577.86.574 1.424v4c0 .567-.192 1.042-.576 1.426-.384.384-.859.576-1.424.574Zm0 36c-.567 0-1.041-.192-1.424-.576-.383-.384-.575-.858-.576-1.424v-4c0-.566.192-1.041.576-1.424.384-.382.859-.574 1.424-.576a1.93 1.93 0 0 1 1.426.576c.385.386.577.86.574 1.424v4c0 .567-.192 1.042-.576 1.426-.384.384-.859.576-1.424.574ZM11.8 14.6l-2.15-2.1c-.4-.366-.592-.833-.576-1.4a2.16 2.16 0 0 1 .576-1.45c.4-.4.883-.6 1.45-.6s1.033.2 1.4.6l2.1 2.15c.367.4.55.867.55 1.4 0 .534-.183 1-.55 1.4-.367.4-.825.592-1.374.576A2.137 2.137 0 0 1 11.8 14.6Zm24.7 24.75-2.1-2.15c-.367-.4-.55-.874-.55-1.424 0-.549.183-1.008.55-1.376a1.71 1.71 0 0 1 1.376-.574 2.14 2.14 0 0 1 1.424.574l2.15 2.1c.4.367.592.834.576 1.4a2.16 2.16 0 0 1-.576 1.45c-.4.4-.883.6-1.45.6s-1.033-.2-1.4-.6ZM34.4 14.6a1.714 1.714 0 0 1-.576-1.374c.016-.549.208-1.024.576-1.426l2.1-2.15c.367-.4.833-.592 1.4-.576a2.16 2.16 0 0 1 1.45.576c.4.4.6.884.6 1.45 0 .567-.2 1.034-.6 1.4l-2.15 2.1c-.4.367-.867.55-1.4.55-.533 0-1-.183-1.4-.55ZM9.65 39.35c-.4-.4-.6-.883-.6-1.45 0-.566.2-1.033.6-1.4l2.15-2.1c.4-.366.875-.55 1.424-.55.55 0 1.008.184 1.376.55.4.367.592.826.576 1.376A2.124 2.124 0 0 1 14.6 37.2l-2.1 2.15c-.367.4-.833.592-1.4.576a2.16 2.16 0 0 1-1.45-.576Z\"\n\t\t\tfill=\"url(#b)\"\n\t\t/>\n\t\t<g filter=\"url(#c)\">\n\t\t\t<path\n\t\t\t\td=\"M24.306 41.34c0 .522.178.962.532 1.317.354.355.793.532 1.317.532.12 0 .234-.01.345-.028v1.34c0 .566-.192 1.042-.576 1.426-.384.383-.859.575-1.424.574-.567 0-1.041-.192-1.424-.576-.382-.384-.575-.859-.576-1.424v-4c0-.567.192-1.042.576-1.425.34-.338.75-.527 1.23-.567v2.83Zm10.63-7.343c.084.214.208.412.374.593l1.942 1.988c.339.37.771.555 1.295.555.402 0 .759-.111 1.07-.33.218.311.32.677.309 1.097a2.161 2.161 0 0 1-.575 1.45c-.4.4-.884.6-1.45.6-.567 0-1.034-.2-1.401-.6l-2.1-2.15c-.366-.4-.55-.875-.55-1.424 0-.549.184-1.008.55-1.376.16-.175.34-.308.536-.403Zm-23.14.546c-.369.339-.554.77-.554 1.295 0 .524.185.97.555 1.34.37.34.817.518 1.34.533.525.015.957-.163 1.296-.532l.56-.575a2.161 2.161 0 0 1-.392.596L12.5 39.35c-.367.4-.833.593-1.4.577a2.162 2.162 0 0 1-1.45-.577c-.4-.4-.6-.883-.6-1.45 0-.566.2-1.032.6-1.4l2.15-2.1a2.12 2.12 0 0 1 .49-.339l-.493.482ZM5.84 22.5a2.07 2.07 0 0 0-.028.344 1.8 1.8 0 0 0 .532 1.317c.354.355.793.532 1.317.532h2.831c-.04.481-.229.892-.567 1.233-.383.385-.857.576-1.424.574h-4c-.567 0-1.041-.192-1.424-.576-.382-.384-.575-.859-.576-1.424-.001-.566.19-1.04.576-1.425.385-.384.86-.575 1.424-.575h1.339Zm34.401.015a1.962 1.962 0 0 0-.076.562c.001.523.179.961.532 1.316.354.356.794.534 1.318.534h3.698c.28 0 .536-.052.768-.154a1.916 1.916 0 0 1-.556 1.153c-.383.385-.857.576-1.424.574h-4c-.566 0-1.041-.192-1.424-.576-.382-.384-.575-.859-.576-1.424-.001-.566.19-1.04.576-1.425a1.907 1.907 0 0 1 1.164-.56ZM11.1 9.052c.236 0 .455.036.656.105-.328.366-.5.805-.514 1.32-.015.523.163.955.533 1.294l1.988 1.942c.32.293.69.464 1.111.515-.076.13-.165.255-.273.373-.367.4-.825.593-1.374.577A2.138 2.138 0 0 1 11.8 14.6l-2.15-2.1c-.4-.367-.592-.834-.576-1.4.016-.567.208-1.05.576-1.45.4-.4.884-.6 1.45-.6Zm24.835 1.563c-.34.371-.518.811-.533 1.32-.014.507.163.931.533 1.27.37.339.801.508 1.294.508.494 0 .925-.169 1.295-.508l1.197-1.17c-.095.17-.217.326-.37.467l-2.15 2.1c-.4.366-.868.55-1.401.55s-1-.184-1.4-.55a1.714 1.714 0 0 1-.576-1.375c.016-.549.208-1.024.576-1.425l2.1-2.15c.25-.273.546-.447.889-.526l-1.454 1.49ZM24.5 2.501c.31 0 .592.058.848.174a1.805 1.805 0 0 0-.51.358c-.355.354-.532.793-.532 1.317V8.05c0 .523.178.962.532 1.317.308.31.681.484 1.119.524l-.033.037c-.384.383-.859.575-1.424.574-.567 0-1.041-.192-1.424-.576-.382-.384-.575-.859-.576-1.424v-4c0-.567.192-1.042.576-1.425.384-.382.859-.574 1.424-.575Z\"\n\t\t\t\tfill=\"#89969F\"\n\t\t\t/>\n\t\t</g>\n\t\t<g filter=\"url(#d)\">\n\t\t\t<path\n\t\t\t\td=\"M21.153 15.044a9.752 9.752 0 0 0-1.54 1.26c-1.805 1.803-2.706 3.984-2.705 6.541.002 2.557.903 4.738 2.705 6.542 1.803 1.804 3.984 2.705 6.542 2.705 2.561.003 4.742-.899 6.542-2.704.485-.486.902-1 1.256-1.54-.479 1.375-1.27 2.618-2.379 3.729-1.946 1.952-4.305 2.926-7.074 2.924-2.767 0-5.125-.976-7.074-2.926-1.95-1.95-2.925-4.31-2.926-7.075-.001-2.765.974-5.123 2.926-7.074 1.11-1.11 2.354-1.903 3.727-2.382Z\"\n\t\t\t\tfill=\"#30363A\"\n\t\t\t/>\n\t\t</g>\n\t</g>\n\t<defs>\n\t\t<filter\n\t\t\tid=\"c\"\n\t\t\tx=\"-3.79\"\n\t\t\ty=\"-3.789\"\n\t\t\twidth=\"56.56\"\n\t\t\theight=\"56.58\"\n\t\t\tfilterUnits=\"userSpaceOnUse\"\n\t\t\tcolor-interpolation-filters=\"sRGB\"\n\t\t>\n\t\t\t<feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\" />\n\t\t\t<feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n\t\t\t<feGaussianBlur stdDeviation=\"1.021\" result=\"effect1_foregroundBlur_3607_89\" />\n\t\t</filter>\n\t\t<filter\n\t\t\tid=\"d\"\n\t\t\tx=\"7.87\"\n\t\t\ty=\"8.414\"\n\t\t\twidth=\"32.714\"\n\t\t\theight=\"32.717\"\n\t\t\tfilterUnits=\"userSpaceOnUse\"\n\t\t\tcolor-interpolation-filters=\"sRGB\"\n\t\t>\n\t\t\t<feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\" />\n\t\t\t<feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n\t\t\t<feGaussianBlur stdDeviation=\"3.315\" result=\"effect1_foregroundBlur_3607_89\" />\n\t\t</filter>\n\t\t<radialGradient\n\t\t\tid=\"b\"\n\t\t\tcx=\"0\"\n\t\t\tcy=\"0\"\n\t\t\tr=\"1\"\n\t\t\tgradientUnits=\"userSpaceOnUse\"\n\t\t\tgradientTransform=\"matrix(-9 11 -11 -9 29.905 18.58)\"\n\t\t>\n\t\t\t<stop stop-color=\"#fff\" />\n\t\t\t<stop offset=\"1\" stop-color=\"#F8FAFC\" />\n\t\t</radialGradient>\n\t</defs>\n</svg>\n"
  },
  {
    "path": "src/lib/components/icons/Logo.svelte",
    "content": "<script lang=\"ts\">\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<img\n\twidth=\"32\"\n\theight=\"32\"\n\tclass={classNames}\n\talt=\"{publicConfig.PUBLIC_APP_NAME} logo\"\n\tsrc=\"{publicConfig.assetPath}/logo.svg\"\n/>\n"
  },
  {
    "path": "src/lib/components/icons/LogoHuggingFaceBorderless.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tclassNames?: string;\n\t}\n\n\tlet { classNames = \"\" }: Props = $props();\n</script>\n\n<svg\n\tclass={classNames}\n\txmlns=\"http://www.w3.org/2000/svg\"\n\twidth=\"1em\"\n\theight=\"1em\"\n\tfill=\"none\"\n\tviewBox=\"0 0 95 88\"\n>\n\t<path fill=\"#FFD21E\" d=\"M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z\" />\n\t<path\n\t\tfill=\"#FF9D0B\"\n\t\td=\"M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z\"\n\t/>\n\t<path\n\t\tfill=\"#3A3B45\"\n\t\td=\"M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32ZM46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z\"\n\t/>\n\t<mask id=\"a\" width=\"27\" height=\"16\" x=\"33\" y=\"41\" maskUnits=\"userSpaceOnUse\">\n\t\t<path\n\t\t\tfill=\"#fff\"\n\t\t\td=\"M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z\"\n\t\t/>\n\t</mask>\n\t<g mask=\"url(#a)\">\n\t\t<path\n\t\t\tfill=\"#F94040\"\n\t\t\td=\"M47.21 66.5a8.67 8.67 0 0 0 2.65-16.94c-.84-.26-1.73 2.6-2.65 2.6-.86 0-1.7-2.88-2.48-2.65a8.68 8.68 0 0 0 2.48 16.99Z\"\n\t\t/>\n\t</g>\n\t<path\n\t\tfill=\"#FF9D0B\"\n\t\td=\"M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z\"\n\t/>\n\t<path\n\t\tfill=\"#FFD21E\"\n\t\td=\"M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z\"\n\t/>\n\t<path\n\t\tfill=\"#FF9D0B\"\n\t\td=\"M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z\"\n\t/>\n\t<path\n\t\tfill=\"#FFD21E\"\n\t\td=\"M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z\"\n\t/>\n</svg>\n"
  },
  {
    "path": "src/lib/components/mcp/AddServerForm.svelte",
    "content": "<script lang=\"ts\">\n\timport type { KeyValuePair } from \"$lib/types/Tool\";\n\timport {\n\t\tvalidateMcpServerUrl,\n\t\tvalidateHeader,\n\t\tisSensitiveHeader,\n\t} from \"$lib/utils/mcpValidation\";\n\timport IconEye from \"~icons/carbon/view\";\n\timport IconEyeOff from \"~icons/carbon/view-off\";\n\timport IconTrash from \"~icons/carbon/trash-can\";\n\timport IconAdd from \"~icons/carbon/add\";\n\timport IconWarning from \"~icons/carbon/warning\";\n\n\tinterface Props {\n\t\tonsubmit: (server: { name: string; url: string; headers?: KeyValuePair[] }) => void;\n\t\toncancel: () => void;\n\t\tinitialName?: string;\n\t\tinitialUrl?: string;\n\t\tinitialHeaders?: KeyValuePair[];\n\t\tsubmitLabel?: string;\n\t}\n\n\tlet {\n\t\tonsubmit,\n\t\toncancel,\n\t\tinitialName = \"\",\n\t\tinitialUrl = \"\",\n\t\tinitialHeaders = [],\n\t\tsubmitLabel = \"Add Server\",\n\t}: Props = $props();\n\n\tlet name = $state(\"\");\n\tlet url = $state(\"\");\n\tlet headers = $state<KeyValuePair[]>([]);\n\n\t$effect.pre(() => {\n\t\tname = initialName;\n\t\turl = initialUrl;\n\t\theaders = initialHeaders.length > 0 ? [...initialHeaders] : [];\n\t});\n\tlet showHeaderValues = $state<Record<number, boolean>>({});\n\tlet error = $state<string | null>(null);\n\n\tfunction addHeader() {\n\t\theaders = [...headers, { key: \"\", value: \"\" }];\n\t}\n\n\tfunction removeHeader(index: number) {\n\t\theaders = headers.filter((_, i) => i !== index);\n\t\tdelete showHeaderValues[index];\n\t}\n\n\tfunction toggleHeaderVisibility(index: number) {\n\t\tshowHeaderValues = {\n\t\t\t...showHeaderValues,\n\t\t\t[index]: !showHeaderValues[index],\n\t\t};\n\t}\n\n\tfunction validate(): boolean {\n\t\tif (!name.trim()) {\n\t\t\terror = \"Server name is required\";\n\t\t\treturn false;\n\t\t}\n\n\t\tif (!url.trim()) {\n\t\t\terror = \"Server URL is required\";\n\t\t\treturn false;\n\t\t}\n\n\t\tconst urlValidation = validateMcpServerUrl(url);\n\t\tif (!urlValidation) {\n\t\t\terror = \"Invalid URL.\";\n\t\t\treturn false;\n\t\t}\n\n\t\t// Validate headers\n\t\tfor (let i = 0; i < headers.length; i++) {\n\t\t\tconst header = headers[i];\n\t\t\tif (header.key.trim() || header.value.trim()) {\n\t\t\t\tconst headerError = validateHeader(header.key, header.value);\n\t\t\t\tif (headerError) {\n\t\t\t\t\terror = `Header ${i + 1}: ${headerError}`;\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\terror = null;\n\t\treturn true;\n\t}\n\n\tfunction handleSubmit() {\n\t\tif (!validate()) return;\n\n\t\t// Filter out empty headers\n\t\tconst filteredHeaders = headers.filter((h) => h.key.trim() && h.value.trim());\n\n\t\tonsubmit({\n\t\t\tname: name.trim(),\n\t\t\turl: url.trim(),\n\t\t\theaders: filteredHeaders.length > 0 ? filteredHeaders : undefined,\n\t\t});\n\t}\n</script>\n\n<div class=\"space-y-4\">\n\t<!-- Server Name -->\n\t<div>\n\t\t<label\n\t\t\tfor=\"server-name\"\n\t\t\tclass=\"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300\"\n\t\t>\n\t\t\tServer Name <span class=\"text-red-500\">*</span>\n\t\t</label>\n\t\t<input\n\t\t\tid=\"server-name\"\n\t\t\ttype=\"text\"\n\t\t\tbind:value={name}\n\t\t\tplaceholder=\"My MCP Server\"\n\t\t\tclass=\"mt-1.5 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white\"\n\t\t/>\n\t</div>\n\n\t<!-- Server URL -->\n\t<div>\n\t\t<label for=\"server-url\" class=\"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n\t\t\tServer URL <span class=\"text-red-500\">*</span>\n\t\t</label>\n\t\t<input\n\t\t\tid=\"server-url\"\n\t\t\ttype=\"url\"\n\t\t\tbind:value={url}\n\t\t\tplaceholder=\"https://example.com/mcp\"\n\t\t\tclass=\"mt-1.5 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white\"\n\t\t/>\n\t\t<!-- <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n\t\t\tOnly HTTPS is supported (e.g., https://localhost:5101).\n\t\t</p> -->\n\t</div>\n\n\t<!-- HTTP Headers -->\n\t<details class=\"rounded-lg border border-gray-200 dark:border-gray-700\">\n\t\t<summary class=\"cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300\">\n\t\t\tHTTP Headers (Optional)\n\t\t</summary>\n\t\t<div class=\"space-y-2 border-t border-gray-200 p-4 dark:border-gray-700\">\n\t\t\t{#if headers.length === 0}\n\t\t\t\t<p class=\"text-sm text-gray-500 dark:text-gray-400\">No headers configured</p>\n\t\t\t{:else}\n\t\t\t\t{#each headers as header, i}\n\t\t\t\t\t<div class=\"flex gap-2\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tbind:value={header.key}\n\t\t\t\t\t\t\tplaceholder=\"Header name (e.g., Authorization)\"\n\t\t\t\t\t\t\tclass=\"flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div class=\"relative flex-1\">\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tbind:value={header.value}\n\t\t\t\t\t\t\t\ttype={showHeaderValues[i] ? \"text\" : \"password\"}\n\t\t\t\t\t\t\t\tplaceholder=\"Value\"\n\t\t\t\t\t\t\t\tclass=\"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pr-10 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{#if isSensitiveHeader(header.key)}\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tonclick={() => toggleHeaderVisibility(i)}\n\t\t\t\t\t\t\t\t\tclass=\"absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200\"\n\t\t\t\t\t\t\t\t\ttitle={showHeaderValues[i] ? \"Hide value\" : \"Show value\"}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{#if showHeaderValues[i]}\n\t\t\t\t\t\t\t\t\t\t<IconEyeOff class=\"size-4\" />\n\t\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t\t<IconEye class=\"size-4\" />\n\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonclick={() => removeHeader(i)}\n\t\t\t\t\t\t\tclass=\"rounded-lg bg-red-100 p-2 text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50\"\n\t\t\t\t\t\t\ttitle=\"Remove header\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconTrash class=\"size-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t{/each}\n\t\t\t{/if}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonclick={addHeader}\n\t\t\t\tclass=\"flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n\t\t\t>\n\t\t\t\t<IconAdd class=\"size-4\" />\n\t\t\t\tAdd Header\n\t\t\t</button>\n\n\t\t\t<p class=\"text-xs text-gray-500 dark:text-gray-400\">\n\t\t\t\tCommon examples:<br />\n\t\t\t\t• Bearer token:\n\t\t\t\t<code class=\"rounded bg-gray-100 px-1 dark:bg-gray-700\"\n\t\t\t\t\t>Authorization: Bearer YOUR_TOKEN</code\n\t\t\t\t><br />\n\t\t\t\t• API key:\n\t\t\t\t<code class=\"rounded bg-gray-100 px-1 dark:bg-gray-700\">X-API-Key: YOUR_KEY</code>\n\t\t\t</p>\n\t\t</div>\n\t</details>\n\n\t<!-- Security warning about custom MCP servers -->\n\t<div\n\t\tclass=\"rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900 dark:border-yellow-900/40 dark:bg-yellow-900/20 dark:text-yellow-100\"\n\t>\n\t\t<div class=\"flex items-start gap-3\">\n\t\t\t<IconWarning class=\"mt-0.5 size-4 flex-none text-amber-600 dark:text-yellow-300\" />\n\t\t\t<div class=\"text-sm leading-5\">\n\t\t\t\t<p class=\"font-medium\">Be careful with custom MCP servers.</p>\n\t\t\t\t<p class=\"mt-1 text-[13px] text-amber-800 dark:text-yellow-100/90\">\n\t\t\t\t\tThey receive your requests (including conversation context and any headers you add) and\n\t\t\t\t\tcan run powerful tools on your behalf. Only add servers you trust and review their source.\n\t\t\t\t\tNever share confidental informations.\n\t\t\t\t</p>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<!-- Error message -->\n\t{#if error}\n\t\t<div\n\t\t\tclass=\"rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20\"\n\t\t>\n\t\t\t<p class=\"text-sm text-red-800 dark:text-red-200\">{error}</p>\n\t\t</div>\n\t{/if}\n\n\t<!-- Actions -->\n\t<div class=\"flex justify-end gap-2\">\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonclick={oncancel}\n\t\t\tclass=\"rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n\t\t>\n\t\t\tCancel\n\t\t</button>\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tonclick={handleSubmit}\n\t\t\tclass=\"rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600\"\n\t\t>\n\t\t\t{submitLabel}\n\t\t</button>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/mcp/MCPServerManager.svelte",
    "content": "<script lang=\"ts\">\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\timport Modal from \"$lib/components/Modal.svelte\";\n\timport ServerCard from \"./ServerCard.svelte\";\n\timport AddServerForm from \"./AddServerForm.svelte\";\n\timport {\n\t\tallMcpServers,\n\t\tselectedServerIds,\n\t\tenabledServersCount,\n\t\taddCustomServer,\n\t\trefreshMcpServers,\n\t\thealthCheckServer,\n\t} from \"$lib/stores/mcpServers\";\n\timport type { KeyValuePair } from \"$lib/types/Tool\";\n\timport IconAddLarge from \"~icons/carbon/add-large\";\n\timport IconRefresh from \"~icons/carbon/renew\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\timport IconMCP from \"$lib/components/icons/IconMCP.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\tinterface Props {\n\t\tonclose: () => void;\n\t}\n\n\tlet { onclose }: Props = $props();\n\n\ttype View = \"list\" | \"add\";\n\tlet currentView = $state<View>(\"list\");\n\tlet isRefreshing = $state(false);\n\n\tconst baseServers = $derived($allMcpServers.filter((s) => s.type === \"base\"));\n\tconst customServers = $derived($allMcpServers.filter((s) => s.type === \"custom\"));\n\tconst enabledCount = $derived($enabledServersCount);\n\n\tfunction handleAddServer(serverData: { name: string; url: string; headers?: KeyValuePair[] }) {\n\t\taddCustomServer(serverData);\n\t\tcurrentView = \"list\";\n\t}\n\n\tfunction handleCancel() {\n\t\tcurrentView = \"list\";\n\t}\n\n\tasync function handleRefresh() {\n\t\tif (isRefreshing) return;\n\t\tisRefreshing = true;\n\t\ttry {\n\t\t\tawait refreshMcpServers();\n\t\t\t// After refreshing the list, re-run health checks for all known servers\n\t\t\tconst servers = $allMcpServers;\n\t\t\tawait Promise.allSettled(servers.map((s) => healthCheckServer(s)));\n\t\t} finally {\n\t\t\tisRefreshing = false;\n\t\t}\n\t}\n</script>\n\n<Modal width={currentView === \"list\" ? \"w-[800px]\" : \"w-[600px]\"} {onclose} closeButton>\n\t<div class=\"p-6\">\n\t\t<!-- Header -->\n\t\t<div class=\"mb-6\">\n\t\t\t<h2 class=\"mb-1 text-xl font-semibold text-gray-900 dark:text-gray-200\">\n\t\t\t\t{#if currentView === \"list\"}\n\t\t\t\t\tMCP Servers\n\t\t\t\t{:else}\n\t\t\t\t\tAdd MCP server\n\t\t\t\t{/if}\n\t\t\t</h2>\n\t\t\t<p class=\"text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\t{#if currentView === \"list\"}\n\t\t\t\t\tManage MCP servers to extend {publicConfig.PUBLIC_APP_NAME} with external tools.\n\t\t\t\t{:else}\n\t\t\t\t\tAdd a custom MCP server to {publicConfig.PUBLIC_APP_NAME}.\n\t\t\t\t{/if}\n\t\t\t</p>\n\t\t</div>\n\n\t\t<!-- Content -->\n\t\t{#if currentView === \"list\"}\n\t\t\t<div\n\t\t\t\tclass=\"mb-6 flex justify-between rounded-lg p-4 max-sm:flex-col max-sm:gap-4 sm:items-center {!enabledCount\n\t\t\t\t\t? 'bg-gray-100 dark:bg-white/5'\n\t\t\t\t\t: 'bg-blue-50 dark:bg-blue-900/10'}\"\n\t\t\t>\n\t\t\t\t<div class=\"flex items-center gap-3\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"flex size-10 items-center justify-center rounded-xl bg-blue-500/10\"\n\t\t\t\t\t\tclass:grayscale={!enabledCount}\n\t\t\t\t\t>\n\t\t\t\t\t\t<IconMCP classNames=\"size-8 text-blue-600 dark:text-blue-500\" />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<p class=\"text-sm font-semibold text-gray-900 dark:text-gray-100\">\n\t\t\t\t\t\t\t{$allMcpServers.length}\n\t\t\t\t\t\t\t{$allMcpServers.length === 1 ? \"server\" : \"servers\"} configured\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p class=\"text-xs text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t\t\t{enabledCount} enabled\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"flex gap-2\">\n\t\t\t\t\t<button\n\t\t\t\t\t\tonclick={handleRefresh}\n\t\t\t\t\t\tdisabled={isRefreshing}\n\t\t\t\t\t\tclass=\"btn gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<IconRefresh class=\"size-4 {isRefreshing ? 'animate-spin' : ''}\" />\n\t\t\t\t\t\t{isRefreshing ? \"Refreshing…\" : \"Refresh\"}\n\t\t\t\t\t</button>\n\t\t\t\t\t<button\n\t\t\t\t\t\tonclick={() => (currentView = \"add\")}\n\t\t\t\t\t\tclass=\"btn flex items-center gap-0.5 rounded-lg bg-blue-600 py-1.5 pl-2 pr-3 text-sm font-medium text-white hover:bg-blue-600\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<IconAddLarge class=\"size-4\" />\n\t\t\t\t\t\tAdd Server\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"space-y-5\">\n\t\t\t\t<!-- Base Servers -->\n\t\t\t\t{#if baseServers.length > 0}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<h3 class=\"mb-3 text-sm font-medium text-gray-700 dark:text-gray-300\">\n\t\t\t\t\t\t\tBase Servers ({baseServers.length})\n\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t<div class=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n\t\t\t\t\t\t\t{#each baseServers as server (server.id)}\n\t\t\t\t\t\t\t\t<ServerCard {server} isSelected={$selectedServerIds.has(server.id)} />\n\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t<!-- Custom Servers -->\n\t\t\t\t<div>\n\t\t\t\t\t<h3 class=\"mb-3 text-sm font-medium text-gray-700 dark:text-gray-300\">\n\t\t\t\t\t\tCustom Servers ({customServers.length})\n\t\t\t\t\t</h3>\n\t\t\t\t\t{#if customServers.length === 0}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-8 dark:border-gray-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<LucideHammer class=\"mb-3 size-12 text-gray-400\" />\n\t\t\t\t\t\t\t<p class=\"mb-1 text-sm font-medium text-gray-900 dark:text-gray-100\">\n\t\t\t\t\t\t\t\tNo custom servers yet\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<p class=\"mb-4 text-xs text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tAdd your own MCP servers with custom tools\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tonclick={() => (currentView = \"add\")}\n\t\t\t\t\t\t\t\tclass=\"flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IconAddLarge class=\"size-4\" />\n\t\t\t\t\t\t\t\tAdd Your First Server\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<div class=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n\t\t\t\t\t\t\t{#each customServers as server (server.id)}\n\t\t\t\t\t\t\t\t<ServerCard {server} isSelected={$selectedServerIds.has(server.id)} />\n\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\n\t\t\t\t<!-- Help Text -->\n\t\t\t\t<div class=\"rounded-lg bg-gray-50 p-4 dark:bg-gray-700\">\n\t\t\t\t\t<h4 class=\"mb-2 text-sm font-medium text-gray-900 dark:text-gray-100\">💡 Quick Tips</h4>\n\t\t\t\t\t<ul class=\"space-y-1 text-xs text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t\t<li>• Only connect to servers you trust</li>\n\t\t\t\t\t\t<li>• Enable servers to make their tools available in chat</li>\n\t\t\t\t\t\t<li>• Use the Health Check button to verify server connectivity</li>\n\t\t\t\t\t\t<li>• You can add HTTP headers for authentication when required</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{:else if currentView === \"add\"}\n\t\t\t<AddServerForm onsubmit={handleAddServer} oncancel={handleCancel} />\n\t\t{/if}\n\t</div>\n</Modal>\n"
  },
  {
    "path": "src/lib/components/mcp/ServerCard.svelte",
    "content": "<script lang=\"ts\">\n\timport type { MCPServer } from \"$lib/types/Tool\";\n\timport { toggleServer, healthCheckServer, deleteCustomServer } from \"$lib/stores/mcpServers\";\n\timport IconCheckmark from \"~icons/carbon/checkmark-filled\";\n\timport IconWarning from \"~icons/carbon/warning-filled\";\n\timport IconPending from \"~icons/carbon/pending-filled\";\n\timport IconRefresh from \"~icons/carbon/renew\";\n\timport IconTrash from \"~icons/carbon/trash-can\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\timport IconSettings from \"~icons/carbon/settings\";\n\timport Switch from \"$lib/components/Switch.svelte\";\n\timport { getMcpServerFaviconUrl } from \"$lib/utils/favicon\";\n\n\tinterface Props {\n\t\tserver: MCPServer;\n\t\tisSelected: boolean;\n\t}\n\n\tlet { server, isSelected }: Props = $props();\n\n\tlet isLoadingHealth = $state(false);\n\n\t// Show a quick-access link ONLY for the exact HF MCP login endpoint\n\timport { isStrictHfMcpLogin as isStrictHfMcpLoginUrl } from \"$lib/utils/hf\";\n\tconst isHfMcp = $derived.by(() => isStrictHfMcpLoginUrl(server.url));\n\n\tconst statusInfo = $derived.by(() => {\n\t\tswitch (server.status) {\n\t\t\tcase \"connected\":\n\t\t\t\treturn {\n\t\t\t\t\tlabel: \"Connected\",\n\t\t\t\t\tcolor: \"text-green-600 dark:text-green-400\",\n\t\t\t\t\tbgColor: \"bg-green-100 dark:bg-green-900/20\",\n\t\t\t\t\ticon: IconCheckmark,\n\t\t\t\t};\n\t\t\tcase \"connecting\":\n\t\t\t\treturn {\n\t\t\t\t\tlabel: \"Connecting...\",\n\t\t\t\t\tcolor: \"text-blue-600 dark:text-blue-400\",\n\t\t\t\t\tbgColor: \"bg-blue-100 dark:bg-blue-900/20\",\n\t\t\t\t\ticon: IconPending,\n\t\t\t\t};\n\t\t\tcase \"error\":\n\t\t\t\treturn {\n\t\t\t\t\tlabel: \"Error\",\n\t\t\t\t\tcolor: \"text-red-600 dark:text-red-400\",\n\t\t\t\t\tbgColor: \"bg-red-100 dark:bg-red-900/20\",\n\t\t\t\t\ticon: IconWarning,\n\t\t\t\t};\n\t\t\tcase \"disconnected\":\n\t\t\tdefault:\n\t\t\t\treturn {\n\t\t\t\t\tlabel: \"Unknown\",\n\t\t\t\t\tcolor: \"text-gray-600 dark:text-gray-400\",\n\t\t\t\t\tbgColor: \"bg-gray-100 dark:bg-gray-700\",\n\t\t\t\t\ticon: IconPending,\n\t\t\t\t};\n\t\t}\n\t});\n\n\t// Switch setter handles enable/disable (simple, idiomatic)\n\tfunction setEnabled(v: boolean) {\n\t\tif (v === isSelected) return;\n\t\ttoggleServer(server.id);\n\t\tif (v && server.status !== \"connected\") handleHealthCheck();\n\t}\n\n\tasync function handleHealthCheck() {\n\t\tisLoadingHealth = true;\n\t\ttry {\n\t\t\tawait healthCheckServer(server);\n\t\t} finally {\n\t\t\tisLoadingHealth = false;\n\t\t}\n\t}\n\n\tfunction handleDelete() {\n\t\tdeleteCustomServer(server.id);\n\t}\n</script>\n\n<div\n\tclass=\"rounded-lg border bg-gradient-to-br transition-colors {isSelected\n\t\t? 'border-blue-600/20 bg-blue-50 from-blue-500/5 to-transparent dark:border-blue-700/60 dark:bg-blue-900/10 dark:from-blue-900/20'\n\t\t: 'border-gray-200 bg-white from-black/5 dark:border-gray-700 dark:bg-gray-800 dark:from-white/5'}\"\n>\n\t<div class=\"px-4 py-3.5\">\n\t\t<!-- Header -->\n\t\t<div class=\"mb-3 flex items-start justify-between gap-3\">\n\t\t\t<div class=\"min-w-0 flex-1\">\n\t\t\t\t<div class=\"mb-0.5 flex items-center gap-2\">\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={getMcpServerFaviconUrl(server.url)}\n\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\tclass=\"size-4 flex-shrink-0 rounded\"\n\t\t\t\t\t/>\n\t\t\t\t\t<h3 class=\"truncate font-semibold text-gray-900 dark:text-gray-100\">\n\t\t\t\t\t\t{server.name}\n\t\t\t\t\t</h3>\n\t\t\t\t</div>\n\t\t\t\t<p class=\"truncate text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t{server.url}\n\t\t\t\t</p>\n\t\t\t</div>\n\n\t\t\t<!-- Enable Switch (function binding per Svelte 5 docs) -->\n\t\t\t<Switch name={`enable-${server.id}`} bind:checked={() => isSelected, setEnabled} />\n\t\t</div>\n\n\t\t<!-- Status -->\n\t\t{#if server.status}\n\t\t\t<div class=\"mb-2 flex items-center gap-2\">\n\t\t\t\t<span\n\t\t\t\t\tclass=\"inline-flex items-center gap-1 rounded-full {statusInfo.bgColor} py-0.5 pl-1.5 pr-2 text-xs font-medium {statusInfo.color}\"\n\t\t\t\t>\n\t\t\t\t\t{#if server.status === \"connected\"}\n\t\t\t\t\t\t<IconCheckmark class=\"size-3\" />\n\t\t\t\t\t{:else if server.status === \"connecting\"}\n\t\t\t\t\t\t<IconPending class=\"size-3\" />\n\t\t\t\t\t{:else if server.status === \"error\"}\n\t\t\t\t\t\t<IconWarning class=\"size-3\" />\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<IconPending class=\"size-3\" />\n\t\t\t\t\t{/if}\n\t\t\t\t\t{statusInfo.label}\n\t\t\t\t</span>\n\n\t\t\t\t{#if server.tools && server.tools.length > 0}\n\t\t\t\t\t<span class=\"inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t\t<LucideHammer class=\"size-3\" />\n\t\t\t\t\t\t{server.tools.length}\n\t\t\t\t\t\t{server.tools.length === 1 ? \"tool\" : \"tools\"}\n\t\t\t\t\t</span>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<!-- Error Message -->\n\t\t{#if server.errorMessage}\n\t\t\t<div class=\"mb-2 flex items-center gap-2\">\n\t\t\t\t<div\n\t\t\t\t\tclass=\"line-clamp-6 break-words rounded bg-red-50 px-2 py-1 text-xs text-red-800 dark:bg-red-900/20 dark:text-red-200\"\n\t\t\t\t>\n\t\t\t\t\t{server.errorMessage}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<!-- Actions -->\n\t\t<div class=\"flex flex-wrap gap-1\">\n\t\t\t<button\n\t\t\t\tonclick={handleHealthCheck}\n\t\t\t\tdisabled={isLoadingHealth}\n\t\t\t\tclass=\"flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-[.29rem] text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n\t\t\t>\n\t\t\t\t<IconRefresh class=\"size-3 {isLoadingHealth ? 'animate-spin' : ''}\" />\n\t\t\t\tHealth Check\n\t\t\t</button>\n\n\t\t\t{#if isHfMcp}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/settings/mcp\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclass=\"flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-[.29rem] text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n\t\t\t\t\taria-label=\"Open Hugging Face MCP settings\"\n\t\t\t\t>\n\t\t\t\t\t<IconSettings class=\"size-3\" />\n\t\t\t\t\tSettings\n\t\t\t\t</a>\n\t\t\t{/if}\n\n\t\t\t{#if server.type === \"custom\"}\n\t\t\t\t<button\n\t\t\t\t\tonclick={handleDelete}\n\t\t\t\t\tclass=\"flex items-center gap-1.5 rounded-lg border border-red-500/15 bg-red-50 px-2.5 py-[.29rem] text-xs font-medium text-red-600 hover:bg-red-100 dark:border-red-500/25 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50\"\n\t\t\t\t>\n\t\t\t\t\t<IconTrash class=\"size-3\" />\n\t\t\t\t\tDelete\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<!-- Tools List (Expandable) -->\n\t\t{#if server.tools && server.tools.length > 0}\n\t\t\t<details class=\"mt-3\">\n\t\t\t\t<summary class=\"cursor-pointer text-xs font-medium text-gray-700 dark:text-gray-300\">\n\t\t\t\t\tAvailable Tools ({server.tools.length})\n\t\t\t\t</summary>\n\t\t\t\t<ul class=\"mt-2 space-y-1 text-xs\">\n\t\t\t\t\t{#each server.tools as tool}\n\t\t\t\t\t\t<li class=\"text-gray-600 dark:text-gray-400\">\n\t\t\t\t\t\t\t<span class=\"font-medium text-gray-900 dark:text-gray-100\">{tool.name}</span>\n\t\t\t\t\t\t\t{#if tool.description}\n\t\t\t\t\t\t\t\t<span class=\"text-gray-500 dark:text-gray-500\">- {tool.description}</span>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</li>\n\t\t\t\t\t{/each}\n\t\t\t\t</ul>\n\t\t\t</details>\n\t\t{/if}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/players/AudioPlayer.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonPause from \"~icons/carbon/pause\";\n\timport CarbonPlay from \"~icons/carbon/play\";\n\tinterface Props {\n\t\tsrc: string;\n\t\tname: string;\n\t}\n\n\tlet { src, name }: Props = $props();\n\n\tlet time = $state(0);\n\tlet duration = $state(0);\n\tlet paused = $state(true);\n\n\tfunction format(time: number) {\n\t\tif (isNaN(time)) return \"...\";\n\n\t\tconst minutes = Math.floor(time / 60);\n\t\tconst seconds = Math.floor(time % 60);\n\n\t\treturn `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;\n\t}\n\n\tfunction seek(e: PointerEvent) {\n\t\tif (!e.currentTarget) return;\n\t\tconst { left, width } = (e.currentTarget as HTMLElement).getBoundingClientRect();\n\n\t\tlet p = (e.clientX - left) / width;\n\t\tif (p < 0) p = 0;\n\t\tif (p > 1) p = 1;\n\n\t\ttime = p * duration;\n\t}\n</script>\n\n<div\n\tclass=\"flex h-14 w-72 items-center gap-4 rounded-2xl border border-gray-200 bg-white p-2.5 text-gray-600 shadow-sm transition-all dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300\"\n>\n\t<audio\n\t\t{src}\n\t\tbind:currentTime={time}\n\t\tbind:duration\n\t\tbind:paused\n\t\tpreload=\"metadata\"\n\t\tonended={() => {\n\t\t\ttime = 0;\n\t\t}}\n\t></audio>\n\n\t<button\n\t\tclass=\"mx-auto my-auto aspect-square size-8 rounded-full border border-gray-400 bg-gray-100 dark:border-gray-800 dark:bg-gray-700\"\n\t\taria-label={paused ? \"play\" : \"pause\"}\n\t\tonclick={() => (paused = !paused)}\n\t>\n\t\t{#if paused}\n\t\t\t<CarbonPlay class=\"mx-auto my-auto text-gray-600 dark:text-gray-300\" />\n\t\t{:else}\n\t\t\t<CarbonPause class=\"mx-auto my-auto text-gray-600 dark:text-gray-300\" />\n\t\t{/if}\n\t</button>\n\t<div class=\"overflow-hidden\">\n\t\t<div class=\"truncate font-medium\">{name}</div>\n\t\t{#if duration !== Infinity}\n\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t<span class=\"text-xs\">{format(time)}</span>\n\t\t\t\t<div\n\t\t\t\t\tclass=\"relative h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-700\"\n\t\t\t\t\trole=\"slider\"\n\t\t\t\t\taria-label=\"Seek\"\n\t\t\t\t\taria-valuenow={time}\n\t\t\t\t\taria-valuemin={0}\n\t\t\t\t\taria-valuemax={duration}\n\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\tonpointerdown={() => {\n\t\t\t\t\t\tpaused = true;\n\t\t\t\t\t}}\n\t\t\t\t\tonpointerup={seek}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"absolute inset-0 h-full bg-gray-400 dark:bg-gray-600\"\n\t\t\t\t\t\tstyle=\"width: {(time / duration) * 100}%\"\n\t\t\t\t\t></div>\n\t\t\t\t</div>\n\t\t\t\t<span class=\"text-xs\">{duration ? format(duration) : \"--:--\"}</span>\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/lib/components/voice/AudioWaveform.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount, onDestroy } from \"svelte\";\n\n\tinterface Props {\n\t\tfrequencyData: Uint8Array;\n\t\tminHeight?: number;\n\t\tmaxHeight?: number;\n\t}\n\n\tlet { frequencyData, minHeight = 4, maxHeight = 40 }: Props = $props();\n\n\tconst PILL_WIDTH = 2; // w-0.5 = 2px\n\tconst PILL_GAP = 2;\n\tconst SAMPLE_INTERVAL_MS = 50; // Sample every 50ms (~20 samples/sec)\n\n\tlet containerRef: HTMLDivElement | undefined = $state();\n\tlet timeline: number[] = $state([]);\n\tlet pillCount = $state(60); // Default, will be calculated from container width\n\tlet intervalId: ReturnType<typeof setInterval> | undefined;\n\tlet smoothedAmplitude = 0;\n\n\t// Calculate average amplitude from frequency data\n\tfunction getAmplitude(): number {\n\t\tif (!frequencyData.length) return 0;\n\t\tlet sum = 0;\n\t\tfor (let i = 0; i < frequencyData.length; i++) {\n\t\t\tsum += frequencyData[i];\n\t\t}\n\t\treturn sum / frequencyData.length / 255; // Normalize to 0-1\n\t}\n\n\tfunction addSample() {\n\t\tconst rawAmplitude = getAmplitude();\n\t\t// Smooth the amplitude for less jittery visualization\n\t\tsmoothedAmplitude = smoothedAmplitude * 0.3 + rawAmplitude * 0.7;\n\n\t\t// Boost amplitude by 1.5x and apply slight curve for better visibility\n\t\tconst boostedAmplitude = Math.min(1, Math.pow(smoothedAmplitude * 1.5, 0.85));\n\n\t\tconst height = minHeight + boostedAmplitude * (maxHeight - minHeight);\n\n\t\t// Push new sample, keep only pillCount samples (sliding window)\n\t\ttimeline = [...timeline, height].slice(-pillCount);\n\t}\n\n\tfunction calculatePillCount() {\n\t\tif (containerRef) {\n\t\t\tconst width = containerRef.clientWidth;\n\t\t\tpillCount = Math.max(20, Math.floor(width / (PILL_WIDTH + PILL_GAP)));\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\tcalculatePillCount();\n\n\t\t// Initialize timeline with minimum height dots\n\t\ttimeline = Array(pillCount).fill(minHeight);\n\n\t\t// Start sampling at fixed intervals\n\t\tintervalId = setInterval(addSample, SAMPLE_INTERVAL_MS);\n\n\t\t// Handle resize\n\t\tconst resizeObserver = new ResizeObserver(() => {\n\t\t\tconst oldCount = pillCount;\n\t\t\tcalculatePillCount();\n\t\t\t// Adjust timeline buffer if container size changed\n\t\t\tif (pillCount > oldCount) {\n\t\t\t\t// Pad with min height on the left\n\t\t\t\ttimeline = [...Array(pillCount - oldCount).fill(minHeight), ...timeline];\n\t\t\t} else if (pillCount < oldCount) {\n\t\t\t\ttimeline = timeline.slice(-pillCount);\n\t\t\t}\n\t\t});\n\n\t\tif (containerRef) {\n\t\t\tresizeObserver.observe(containerRef);\n\t\t}\n\n\t\treturn () => {\n\t\t\tresizeObserver.disconnect();\n\t\t};\n\t});\n\n\tonDestroy(() => {\n\t\tif (intervalId) clearInterval(intervalId);\n\t});\n</script>\n\n<div bind:this={containerRef} class=\"flex h-12 w-full items-center justify-start gap-[2px]\">\n\t{#each timeline as height, i (i)}\n\t\t<div\n\t\t\tclass=\"w-0.5 shrink-0 rounded-full bg-gray-400 dark:bg-white/60\"\n\t\t\tstyle=\"height: {Math.max(minHeight, Math.round(height))}px;\"\n\t\t></div>\n\t{/each}\n</div>\n"
  },
  {
    "path": "src/lib/constants/mcpExamples.ts",
    "content": "import type { RouterExample } from \"./routerExamples\";\n\n// Examples that showcase MCP tool capabilities (web search, Hugging Face, etc.)\nexport const mcpExamples: RouterExample[] = [\n\t{\n\t\ttitle: \"Generate an image\",\n\t\tprompt: \"Generate an image of a zebra in front of a volcanic eruption\",\n\t},\n\t{\n\t\ttitle: \"Latest world news\",\n\t\tprompt: \"What is the latest world news?\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Tech focus\",\n\t\t\t\tprompt: \"What about technology news?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"San Francisco\",\n\t\t\t\tprompt: \"What's happening in San Francisco?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"vs last week\",\n\t\t\t\tprompt: \"How does this compare to last week's news?\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Trending models\",\n\t\tprompt: \"What are the top trending models on Hugging Face?\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Text generation\",\n\t\t\t\tprompt: \"What about text generation models?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Image generation\",\n\t\t\t\tprompt: \"What about text-to-image models?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"How to use\",\n\t\t\t\tprompt: \"Show me how to use the most popular one\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Plan a trip\",\n\t\tprompt: \"Things to do in Tokyo next week\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Transport & prices\",\n\t\t\t\tprompt: \"How do I get around and how much will it cost?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Weather\",\n\t\t\t\tprompt: \"What's the weather like in Tokyo next week?\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Meet people\",\n\t\t\t\tprompt: \"Where can I meet new people and make friends?\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Compare technologies\",\n\t\tprompt: \"Search the web to compare React, Vue, and Svelte for building web apps in 2025\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Performance benchmarks\",\n\t\t\t\tprompt: \"Search for recent performance benchmarks comparing these frameworks\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Job market\",\n\t\t\t\tprompt: \"Search for job market trends for each of these frameworks\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Migration guides\",\n\t\t\t\tprompt: \"Search for guides on migrating from React to Svelte\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Find a dataset\",\n\t\tprompt: \"Find datasets on Hugging Face for training a sentiment analysis model\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Dataset details\",\n\t\t\t\tprompt: \"Tell me more about the largest dataset - its size, format, and how to load it\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Find models\",\n\t\t\t\tprompt: \"Find pre-trained models that were trained on this dataset\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Code snippet\",\n\t\t\t\tprompt: \"Show me how to load and preprocess this dataset with the datasets library\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Gift ideas\",\n\t\tprompt: \"Search for unique gift ideas for someone who loves cooking\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Budget options\",\n\t\t\t\tprompt: \"Search for gift ideas under $50\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Top rated\",\n\t\t\t\tprompt: \"Search for the top-rated cooking gadgets of this year\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"DIY gifts\",\n\t\t\t\tprompt: \"Search for homemade gift ideas for cooking enthusiasts\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Learn something new\",\n\t\tprompt: \"Search for the best resources to learn Rust programming in 2025\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Project ideas\",\n\t\t\t\tprompt: \"Search for beginner Rust project ideas to practice with\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Find tools\",\n\t\t\t\tprompt: \"Search for the most popular Rust tools and libraries I should know about\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Community\",\n\t\t\t\tprompt: \"Search for Rust communities and forums where I can ask questions\",\n\t\t\t},\n\t\t],\n\t},\n];\n"
  },
  {
    "path": "src/lib/constants/mime.ts",
    "content": "// Centralized MIME allowlists used across client and server\n// Keep these lists minimal and consistent with server processing.\n\nexport const TEXT_MIME_ALLOWLIST = [\n\t\"text/*\",\n\t\"application/json\",\n\t\"application/xml\",\n\t\"application/csv\",\n] as const;\n\nexport const IMAGE_MIME_ALLOWLIST_DEFAULT = [\"image/jpeg\", \"image/png\"] as const;\n"
  },
  {
    "path": "src/lib/constants/pagination.ts",
    "content": "export const CONV_NUM_PER_PAGE = 30;\n"
  },
  {
    "path": "src/lib/constants/publicSepToken.ts",
    "content": "export const PUBLIC_SEP_TOKEN = \"</s>\";\n"
  },
  {
    "path": "src/lib/constants/routerExamples.ts",
    "content": "export type RouterFollowUp = {\n\ttitle: string;\n\tprompt: string;\n};\n\nexport type RouterExampleAttachment = {\n\tsrc: string;\n};\n\nexport type RouterExample = {\n\ttitle: string;\n\tprompt: string;\n\tfollowUps?: RouterFollowUp[];\n\tattachments?: RouterExampleAttachment[];\n};\n\nexport const routerExamples: RouterExample[] = [\n\t{\n\t\ttitle: \"HTML game\",\n\t\tprompt: \"Code a minimal Flappy Bird game using HTML and Canvas\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"README.md file\",\n\t\t\t\tprompt: \"Create a comprehensive README.md for the Flappy Bird game project.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"CRT Screen\",\n\t\t\t\tprompt: \"Add a CRT screen effect to the game\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Add power-ups\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Add collectible coins between pipes that award bonus points and a shield power-up that allows one collision.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Explain collision detection\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Explain the collision detection algorithm for the bird and pipes in simple terms with examples.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Weird painting\",\n\t\tprompt: \"is this a real painting?\",\n\t\tattachments: [\n\t\t\t{\n\t\t\t\tsrc: \"huggingchat/castle-example.jpg\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Landing page\",\n\t\tprompt:\n\t\t\t\"Build a responsive SaaS landing page for my AI coding assitant using Tailwind CSS. With a hero, features, testimonials, and pricing sections.\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Dark mode\",\n\t\t\t\tprompt: \"Add dark mode and make it the default\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Write blog post\",\n\t\t\t\tprompt: \"Write a blog post introducing my service.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Translate to Italian\",\n\t\t\t\tprompt: \"Translate only the text content displayed to users into Italian.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Architecture review\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Review the architecture and suggest improvements for scalability, SEO optimization, and performance.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Eminem song\",\n\t\tprompt:\n\t\t\t\"Write an Eminem-style rap battling AI taking over hip-hop, with two energetic verses and a catchy hook.\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Psychological analysis\",\n\t\t\t\tprompt: \"Provide a psychological analysis of Eminem's emotions in this song.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Wired Article\",\n\t\t\t\tprompt: \"Write an article in the style of Wired explaining this Eminem release.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Roleplay\",\n\t\t\t\tprompt: \"Roleplay as Eminem so I can discuss the song with him.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Translate to Spanish\",\n\t\t\t\tprompt: \"Translate the rap lyrics to Spanish while maintaining the rhyme scheme and flow.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Act as Yoda\",\n\t\tprompt: \"Act as Yoda\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Give advice\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Continue acting as Yoda and offer three pieces of life advice for staying focused under pressure.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Explain the Force\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"In Yoda's voice, explain the concept of the Force to a young padawan using modern language.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Plain English\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Rewrite the previous response from Yoda into plain English while keeping the same meaning.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Compare philosophies\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Compare Yoda's Jedi philosophy to Stoic philosophy from ancient Greece and explain the similarities and differences.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Generate prompts\",\n\t\tprompt: `Generate 5 creative prompts Text-to-image prompts like: \"Cyberpunk cityscape at night, neon lights, flying cars, rain-slicked streets, blade runner aesthetic, highly detailed`,\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Turn into JSON\",\n\t\t\t\tprompt: `Generate a detailed JSON object for each prompt. Include fields for subjects (list of objects), scene (setting, environment, background details), actions (what's happening), style (artistic style or medium)`,\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Sci-fi portraits\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Produce five futuristic character portrait prompts with unique professions and settings.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Explain image generation\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Explain how text-to-image diffusion models work, covering the denoising process and how text prompts guide generation.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Explain LLMs\",\n\t\tprompt:\n\t\t\t\"Explain how large language models based on transformers work, covering attention, embeddings, and training objectives.\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Generate a Quiz\",\n\t\t\t\tprompt: \"Craft a 5-question multiple-choice quiz to validate what I learned.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Compare to RNNs\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Compare transformer-based large language models to recurrent neural networks, focusing on training efficiency and capabilities.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Student summary\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Summarize the explanation of large language models for a high school student using relatable analogies.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Write a blog post\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Write a blog post about how transformers revolutionized NLP, targeting software engineers who are new to AI.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Translate in Italian\",\n\t\tprompt: `Translate in Italian: Some are born great, some achieve greatness, and some have greatness thrust upon 'em`,\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Back to English\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Translate the Italian version back into English while keeping Shakespeare's tone intact.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Explain choices\",\n\t\t\t\tprompt: \"Explain your translation choices for each key phrase from the Italian version.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Modernize\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Modernize the Italian translation into contemporary informal Italian suitable for social media.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Teach me Italian\",\n\t\t\t\tprompt:\n\t\t\t\t\t\"Help me practice Italian by conversing about this Shakespeare quote, correcting my grammar when needed.\",\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\ttitle: \"Pelican on a bicycle\",\n\t\tprompt: \"Draw an SVG of a pelican riding a bicycle\",\n\t\tfollowUps: [\n\t\t\t{\n\t\t\t\ttitle: \"Add a top hat\",\n\t\t\t\tprompt: \"Add a fancy top hat to the pelican and make it look distinguished\",\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: \"Make it animated\",\n\t\t\t\tprompt: \"Add CSS animations to make the bicycle wheels spin and the pelican's wings flap\",\n\t\t\t},\n\t\t],\n\t},\n];\n"
  },
  {
    "path": "src/lib/createShareLink.ts",
    "content": "import { base } from \"$app/paths\";\nimport { page } from \"$app/state\";\n\n// Returns a public share URL for a conversation id.\n// If `id` is already a 7-char share id, no network call is made.\nexport async function createShareLink(id: string): Promise<string> {\n\tconst prefix =\n\t\tpage.data.publicConfig.PUBLIC_SHARE_PREFIX ||\n\t\t`${page.data.publicConfig.PUBLIC_ORIGIN || page.url.origin}${base}`;\n\n\tif (id.length === 7) {\n\t\treturn `${prefix}/r/${id}`;\n\t}\n\n\tconst res = await fetch(`${base}/conversation/${id}/share`, {\n\t\tmethod: \"POST\",\n\t\theaders: { \"Content-Type\": \"application/json\" },\n\t});\n\n\tif (!res.ok) {\n\t\tconst text = await res.text().catch(() => \"\");\n\t\tthrow new Error(text || \"Failed to create share link\");\n\t}\n\n\tconst { shareId } = await res.json();\n\treturn `${prefix}/r/${shareId}`;\n}\n"
  },
  {
    "path": "src/lib/jobs/refresh-conversation-stats.ts",
    "content": "import type { ConversationStats } from \"$lib/types/ConversationStats\";\nimport { CONVERSATION_STATS_COLLECTION, collections } from \"$lib/server/database\";\nimport { logger } from \"$lib/server/logger\";\nimport type { ObjectId } from \"mongodb\";\nimport { acquireLock, refreshLock } from \"$lib/migrations/lock\";\nimport { Semaphores } from \"$lib/types/Semaphore\";\n\nasync function getLastComputationTime(): Promise<Date> {\n\tconst lastStats = await collections.conversationStats.findOne({}, { sort: { \"date.at\": -1 } });\n\treturn lastStats?.date?.at || new Date(0);\n}\n\nasync function shouldComputeStats(): Promise<boolean> {\n\tconst lastComputationTime = await getLastComputationTime();\n\tconst oneDayAgo = new Date(Date.now() - 24 * 3_600_000);\n\treturn lastComputationTime < oneDayAgo;\n}\n\nexport async function computeAllStats() {\n\tfor (const span of [\"day\", \"week\", \"month\"] as const) {\n\t\tcomputeStats({ dateField: \"updatedAt\", type: \"conversation\", span }).catch((e) =>\n\t\t\tlogger.error(e, \"Error computing conversation stats for updatedAt\")\n\t\t);\n\t\tcomputeStats({ dateField: \"createdAt\", type: \"conversation\", span }).catch((e) =>\n\t\t\tlogger.error(e, \"Error computing conversation stats for createdAt\")\n\t\t);\n\t\tcomputeStats({ dateField: \"createdAt\", type: \"message\", span }).catch((e) =>\n\t\t\tlogger.error(e, \"Error computing message stats for createdAt\")\n\t\t);\n\t}\n}\n\nasync function computeStats(params: {\n\tdateField: ConversationStats[\"date\"][\"field\"];\n\tspan: ConversationStats[\"date\"][\"span\"];\n\ttype: ConversationStats[\"type\"];\n}) {\n\tconst indexes = await collections.semaphores.listIndexes().toArray();\n\tif (indexes.length <= 2) {\n\t\tlogger.info(\"Indexes not created, skipping stats computation\");\n\t\treturn;\n\t}\n\n\tconst lastComputed = await collections.conversationStats.findOne(\n\t\t{ \"date.field\": params.dateField, \"date.span\": params.span, type: params.type },\n\t\t{ sort: { \"date.at\": -1 } }\n\t);\n\n\t// If the last computed week is at the beginning of the last computed month, we need to include some days from the previous month\n\t// In those cases we need to compute the stats from before the last month as everything is one aggregation\n\tconst minDate = lastComputed ? lastComputed.date.at : new Date(0);\n\n\tlogger.debug(\n\t\t{ minDate, dateField: params.dateField, span: params.span, type: params.type },\n\t\t\"Computing conversation stats\"\n\t);\n\n\tconst dateField = params.type === \"message\" ? \"messages.\" + params.dateField : params.dateField;\n\n\tconst pipeline = [\n\t\t{\n\t\t\t$match: {\n\t\t\t\t[dateField]: { $gte: minDate },\n\t\t\t},\n\t\t},\n\t\t// For message stats: use $filter to reduce data before $unwind (optimization)\n\t\t// For conversation stats: simple projection\n\t\t...(params.type === \"message\"\n\t\t\t? [\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t// Filter messages by date, then map to only keep the date field\n\t\t\t\t\t\t\t// This avoids carrying large message payloads (content, files, etc.) through the pipeline\n\t\t\t\t\t\t\tmessages: {\n\t\t\t\t\t\t\t\t$map: {\n\t\t\t\t\t\t\t\t\tinput: {\n\t\t\t\t\t\t\t\t\t\t$filter: {\n\t\t\t\t\t\t\t\t\t\t\tinput: \"$messages\",\n\t\t\t\t\t\t\t\t\t\t\tas: \"msg\",\n\t\t\t\t\t\t\t\t\t\t\tcond: { $gte: [`$$msg.${params.dateField}`, minDate] },\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tas: \"msg\",\n\t\t\t\t\t\t\t\t\tin: { [params.dateField]: `$$msg.${params.dateField}` },\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tsessionId: 1,\n\t\t\t\t\t\t\tuserId: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$unwind: \"$messages\",\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t: [\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t[dateField]: 1,\n\t\t\t\t\t\t\tsessionId: 1,\n\t\t\t\t\t\t\tuserId: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t]),\n\t\t{\n\t\t\t$sort: {\n\t\t\t\t[dateField]: 1,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$facet: {\n\t\t\t\tuserId: [\n\t\t\t\t\t{\n\t\t\t\t\t\t$match: {\n\t\t\t\t\t\t\tuserId: { $exists: true },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: {\n\t\t\t\t\t\t\t\tat: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },\n\t\t\t\t\t\t\t\tuserId: \"$userId\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: \"$_id.at\",\n\t\t\t\t\t\t\tcount: { $sum: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t_id: 0,\n\t\t\t\t\t\t\tdate: {\n\t\t\t\t\t\t\t\tat: \"$_id\",\n\t\t\t\t\t\t\t\tfield: params.dateField,\n\t\t\t\t\t\t\t\tspan: params.span,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tdistinct: \"userId\",\n\t\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tsessionId: [\n\t\t\t\t\t{\n\t\t\t\t\t\t$match: {\n\t\t\t\t\t\t\tsessionId: { $exists: true },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: {\n\t\t\t\t\t\t\t\tat: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },\n\t\t\t\t\t\t\t\tsessionId: \"$sessionId\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: \"$_id.at\",\n\t\t\t\t\t\t\tcount: { $sum: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t_id: 0,\n\t\t\t\t\t\t\tdate: {\n\t\t\t\t\t\t\t\tat: \"$_id\",\n\t\t\t\t\t\t\t\tfield: params.dateField,\n\t\t\t\t\t\t\t\tspan: params.span,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tdistinct: \"sessionId\",\n\t\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tuserOrSessionId: [\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: {\n\t\t\t\t\t\t\t\tat: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },\n\t\t\t\t\t\t\t\tuserOrSessionId: { $ifNull: [\"$userId\", \"$sessionId\"] },\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: \"$_id.at\",\n\t\t\t\t\t\t\tcount: { $sum: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t_id: 0,\n\t\t\t\t\t\t\tdate: {\n\t\t\t\t\t\t\t\tat: \"$_id\",\n\t\t\t\t\t\t\t\tfield: params.dateField,\n\t\t\t\t\t\t\t\tspan: params.span,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tdistinct: \"userOrSessionId\",\n\t\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t_id: [\n\t\t\t\t\t{\n\t\t\t\t\t\t$group: {\n\t\t\t\t\t\t\t_id: { $dateTrunc: { date: `$${dateField}`, unit: params.span } },\n\t\t\t\t\t\t\tcount: { $sum: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t$project: {\n\t\t\t\t\t\t\t_id: 0,\n\t\t\t\t\t\t\tdate: {\n\t\t\t\t\t\t\t\tat: \"$_id\",\n\t\t\t\t\t\t\t\tfield: params.dateField,\n\t\t\t\t\t\t\t\tspan: params.span,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tdistinct: \"_id\",\n\t\t\t\t\t\t\tcount: 1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$project: {\n\t\t\t\tstats: {\n\t\t\t\t\t$concatArrays: [\"$userId\", \"$sessionId\", \"$userOrSessionId\", \"$_id\"],\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$unwind: \"$stats\",\n\t\t},\n\t\t{\n\t\t\t$replaceRoot: {\n\t\t\t\tnewRoot: \"$stats\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$set: {\n\t\t\t\ttype: params.type,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$merge: {\n\t\t\t\tinto: CONVERSATION_STATS_COLLECTION,\n\t\t\t\ton: [\"date.at\", \"type\", \"date.span\", \"date.field\", \"distinct\"],\n\t\t\t\twhenMatched: \"replace\",\n\t\t\t\twhenNotMatched: \"insert\",\n\t\t\t},\n\t\t},\n\t];\n\n\tawait collections.conversations.aggregate(pipeline, { allowDiskUse: true }).next();\n\n\tlogger.debug(\n\t\t{ minDate, dateField: params.dateField, span: params.span, type: params.type },\n\t\t\"Computed conversation stats\"\n\t);\n}\n\nlet hasLock = false;\nlet lockId: ObjectId | null = null;\n\nasync function maintainLock() {\n\tif (hasLock && lockId) {\n\t\thasLock = await refreshLock(Semaphores.CONVERSATION_STATS, lockId);\n\n\t\tif (!hasLock) {\n\t\t\tlockId = null;\n\t\t}\n\t} else if (!hasLock) {\n\t\tlockId = (await acquireLock(Semaphores.CONVERSATION_STATS)) || null;\n\t\thasLock = !!lockId;\n\t}\n\n\tsetTimeout(maintainLock, 10_000);\n}\n\nexport function refreshConversationStats() {\n\tconst ONE_HOUR_MS = 3_600_000;\n\n\tmaintainLock().then(async () => {\n\t\tif (await shouldComputeStats()) {\n\t\t\tcomputeAllStats();\n\t\t}\n\n\t\tsetInterval(async () => {\n\t\t\tif (await shouldComputeStats()) {\n\t\t\t\tcomputeAllStats();\n\t\t\t}\n\t\t}, 24 * ONE_HOUR_MS);\n\t});\n}\n"
  },
  {
    "path": "src/lib/migrations/lock.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport type { Semaphores } from \"$lib/types/Semaphore\";\n\n/**\n * Returns the lock id if the lock was acquired, false otherwise\n */\nexport async function acquireLock(key: Semaphores | string): Promise<ObjectId | false> {\n\ttry {\n\t\tconst id = new ObjectId();\n\n\t\tconst insert = await collections.semaphores.insertOne({\n\t\t\t_id: id,\n\t\t\tkey,\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tdeleteAt: new Date(Date.now() + 1000 * 60 * 3), // 3 minutes\n\t\t});\n\n\t\treturn insert.acknowledged ? id : false; // true if the document was inserted\n\t} catch (e) {\n\t\t// unique index violation, so there must already be a lock\n\t\treturn false;\n\t}\n}\n\nexport async function releaseLock(key: Semaphores | string, lockId: ObjectId) {\n\tawait collections.semaphores.deleteOne({\n\t\t_id: lockId,\n\t\tkey,\n\t});\n}\n\nexport async function isDBLocked(key: Semaphores | string): Promise<boolean> {\n\tconst res = await collections.semaphores.countDocuments({\n\t\tkey,\n\t});\n\treturn res > 0;\n}\n\nexport async function refreshLock(key: Semaphores | string, lockId: ObjectId): Promise<boolean> {\n\tconst result = await collections.semaphores.updateOne(\n\t\t{\n\t\t\t_id: lockId,\n\t\t\tkey,\n\t\t},\n\t\t{\n\t\t\t$set: {\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\tdeleteAt: new Date(Date.now() + 1000 * 60 * 3), // 3 minutes\n\t\t\t},\n\t\t}\n\t);\n\n\treturn result.matchedCount > 0;\n}\n"
  },
  {
    "path": "src/lib/migrations/migrations.spec.ts",
    "content": "import { afterEach, assert, beforeAll, describe, expect, it } from \"vitest\";\nimport { migrations } from \"./routines\";\nimport { acquireLock, isDBLocked, refreshLock, releaseLock } from \"./lock\";\nimport { Semaphores } from \"$lib/types/Semaphore\";\nimport { collections, ready } from \"$lib/server/database\";\n\ndescribe(\n\t\"migrations\",\n\t{\n\t\tretry: 3,\n\t},\n\t() => {\n\t\tbeforeAll(async () => {\n\t\t\tawait ready;\n\t\t\ttry {\n\t\t\t\tawait collections.semaphores.createIndex({ key: 1 }, { unique: true });\n\t\t\t} catch (e) {\n\t\t\t\t// Index might already exist, ignore error\n\t\t\t}\n\t\t}, 20000);\n\n\t\tit(\"should not have duplicates guid\", async () => {\n\t\t\tconst guids = migrations.map((m) => m._id.toString());\n\t\t\tconst uniqueGuids = [...new Set(guids)];\n\t\t\texpect(uniqueGuids.length).toBe(guids.length);\n\t\t});\n\n\t\tit(\"should acquire only one lock on DB\", async () => {\n\t\t\tconst results = await Promise.all(\n\t\t\t\tnew Array(1000).fill(0).map(() => acquireLock(Semaphores.TEST_MIGRATION))\n\t\t\t);\n\t\t\tconst locks = results.filter((r) => r);\n\n\t\t\tconst semaphores = await collections.semaphores.find({}).toArray();\n\n\t\t\texpect(locks.length).toBe(1);\n\t\t\texpect(semaphores).toBeDefined();\n\t\t\texpect(semaphores.length).toBe(1);\n\t\t\texpect(semaphores?.[0].key).toBe(Semaphores.TEST_MIGRATION);\n\t\t});\n\n\t\tit(\"should read the lock correctly\", async () => {\n\t\t\tconst lockId = await acquireLock(Semaphores.TEST_MIGRATION);\n\t\t\tassert(lockId);\n\t\t\texpect(await isDBLocked(Semaphores.TEST_MIGRATION)).toBe(true);\n\t\t\texpect(!!(await acquireLock(Semaphores.TEST_MIGRATION))).toBe(false);\n\t\t\tawait releaseLock(Semaphores.TEST_MIGRATION, lockId);\n\t\t\texpect(await isDBLocked(Semaphores.TEST_MIGRATION)).toBe(false);\n\t\t});\n\n\t\tit(\"should refresh the lock\", async () => {\n\t\t\tconst lockId = await acquireLock(Semaphores.TEST_MIGRATION);\n\n\t\t\tassert(lockId);\n\n\t\t\t// get the updatedAt time\n\n\t\t\tconst updatedAtInitially = (await collections.semaphores.findOne({}))?.updatedAt;\n\n\t\t\tawait refreshLock(Semaphores.TEST_MIGRATION, lockId);\n\n\t\t\tconst updatedAtAfterRefresh = (await collections.semaphores.findOne({}))?.updatedAt;\n\n\t\t\texpect(updatedAtInitially).toBeDefined();\n\t\t\texpect(updatedAtAfterRefresh).toBeDefined();\n\t\t\texpect(updatedAtInitially).not.toBe(updatedAtAfterRefresh);\n\t\t});\n\n\t\tafterEach(async () => {\n\t\t\tawait collections.semaphores.deleteMany({});\n\t\t\tawait collections.migrationResults.deleteMany({});\n\t\t});\n\t}\n);\n"
  },
  {
    "path": "src/lib/migrations/migrations.ts",
    "content": "import { Database } from \"$lib/server/database\";\nimport { migrations } from \"./routines\";\nimport { acquireLock, releaseLock, isDBLocked, refreshLock } from \"./lock\";\nimport { Semaphores } from \"$lib/types/Semaphore\";\nimport { logger } from \"$lib/server/logger\";\nimport { config } from \"$lib/server/config\";\n\nexport async function checkAndRunMigrations() {\n\t// make sure all GUIDs are unique\n\tif (new Set(migrations.map((m) => m._id.toString())).size !== migrations.length) {\n\t\tthrow new Error(\"Duplicate migration GUIDs found.\");\n\t}\n\n\t// check if all migrations have already been run\n\tconst migrationResults = await (await Database.getInstance())\n\t\t.getCollections()\n\t\t.migrationResults.find()\n\t\t.toArray();\n\n\tlogger.debug(\"[MIGRATIONS] Begin check...\");\n\n\t// connect to the database\n\tconst connectedClient = await (await Database.getInstance()).getClient().connect();\n\n\tconst lockId = await acquireLock(Semaphores.MIGRATION);\n\n\tif (!lockId) {\n\t\t// another instance already has the lock, so we exit early\n\t\tlogger.debug(\n\t\t\t\"[MIGRATIONS] Another instance already has the lock. Waiting for DB to be unlocked.\"\n\t\t);\n\n\t\t// Todo: is this necessary? Can we just return?\n\t\t// block until the lock is released\n\t\twhile (await isDBLocked(Semaphores.MIGRATION)) {\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 1000));\n\t\t}\n\t\treturn;\n\t}\n\n\t// once here, we have the lock\n\t// make sure to refresh it regularly while it's running\n\tconst refreshInterval = setInterval(async () => {\n\t\tawait refreshLock(Semaphores.MIGRATION, lockId);\n\t}, 1000 * 10);\n\n\t// iterate over all migrations\n\tfor (const migration of migrations) {\n\t\t// check if the migration has already been applied\n\t\tconst shouldRun =\n\t\t\tmigration.runEveryTime ||\n\t\t\t!migrationResults.find((m) => m._id.toString() === migration._id.toString());\n\n\t\t// check if the migration has already been applied\n\t\tif (!shouldRun) {\n\t\t\tlogger.debug(`[MIGRATIONS] \"${migration.name}\" already applied. Skipping...`);\n\t\t} else {\n\t\t\t// check the modifiers to see if some cases match\n\t\t\tif (\n\t\t\t\t(migration.runForHuggingChat === \"only\" && !config.isHuggingChat) ||\n\t\t\t\t(migration.runForHuggingChat === \"never\" && config.isHuggingChat)\n\t\t\t) {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t`[MIGRATIONS] \"${migration.name}\" should not be applied for this run. Skipping...`\n\t\t\t\t);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// otherwise all is good and we can run the migration\n\t\t\tlogger.debug(\n\t\t\t\t`[MIGRATIONS] \"${migration.name}\" ${\n\t\t\t\t\tmigration.runEveryTime ? \"should run every time\" : \"not applied yet\"\n\t\t\t\t}. Applying...`\n\t\t\t);\n\n\t\t\tawait (await Database.getInstance()).getCollections().migrationResults.updateOne(\n\t\t\t\t{ _id: migration._id },\n\t\t\t\t{\n\t\t\t\t\t$set: {\n\t\t\t\t\t\tname: migration.name,\n\t\t\t\t\t\tstatus: \"ongoing\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{ upsert: true }\n\t\t\t);\n\n\t\t\tconst session = connectedClient.startSession();\n\t\t\tlet result = false;\n\n\t\t\ttry {\n\t\t\t\tawait session.withTransaction(async () => {\n\t\t\t\t\tresult = await migration.up(await Database.getInstance());\n\t\t\t\t});\n\t\t\t} catch (e) {\n\t\t\t\tlogger.error(e, `[MIGRATIONS]  \"${migration.name}\" failed!`);\n\t\t\t} finally {\n\t\t\t\tawait session.endSession();\n\t\t\t}\n\n\t\t\tawait (await Database.getInstance()).getCollections().migrationResults.updateOne(\n\t\t\t\t{ _id: migration._id },\n\t\t\t\t{\n\t\t\t\t\t$set: {\n\t\t\t\t\t\tname: migration.name,\n\t\t\t\t\t\tstatus: result ? \"success\" : \"failure\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{ upsert: true }\n\t\t\t);\n\t\t}\n\t}\n\n\tlogger.debug(\"[MIGRATIONS] All migrations applied. Releasing lock\");\n\n\tclearInterval(refreshInterval);\n\tawait releaseLock(Semaphores.MIGRATION, lockId);\n}\n"
  },
  {
    "path": "src/lib/migrations/routines/01-update-search-assistants.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId, type AnyBulkWriteOperation } from \"mongodb\";\nimport type { Assistant } from \"$lib/types/Assistant\";\nimport { generateSearchTokens } from \"$lib/utils/searchTokens\";\n\nconst migration: Migration = {\n\t_id: new ObjectId(\"5f9f3e3e3e3e3e3e3e3e3e3e\"),\n\tname: \"Update search assistants\",\n\tup: async () => {\n\t\tconst { assistants } = collections;\n\t\tlet ops: AnyBulkWriteOperation<Assistant>[] = [];\n\n\t\tfor await (const assistant of assistants\n\t\t\t.find()\n\t\t\t.project<Pick<Assistant, \"_id\" | \"name\">>({ _id: 1, name: 1 })) {\n\t\t\tops.push({\n\t\t\t\tupdateOne: {\n\t\t\t\t\tfilter: {\n\t\t\t\t\t\t_id: assistant._id,\n\t\t\t\t\t},\n\t\t\t\t\tupdate: {\n\t\t\t\t\t\t$set: {\n\t\t\t\t\t\t\tsearchTokens: generateSearchTokens(assistant.name),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tif (ops.length >= 1000) {\n\t\t\t\tprocess.stdout.write(\".\");\n\t\t\t\tawait assistants.bulkWrite(ops, { ordered: false });\n\t\t\t\tops = [];\n\t\t\t}\n\t\t}\n\n\t\tif (ops.length) {\n\t\t\tawait assistants.bulkWrite(ops, { ordered: false });\n\t\t}\n\n\t\treturn true;\n\t},\n\tdown: async () => {\n\t\tconst { assistants } = collections;\n\t\tawait assistants.updateMany({}, { $unset: { searchTokens: \"\" } });\n\t\treturn true;\n\t},\n};\n\nexport default migration;\n"
  },
  {
    "path": "src/lib/migrations/routines/02-update-assistants-models.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\n\nconst updateAssistantsModels: Migration = {\n\t_id: new ObjectId(\"5f9f3f3f3f3f3f3f3f3f3f3f\"),\n\tname: \"Update deprecated models in assistants with the default model\",\n\tup: async () => {\n\t\tconst models = (await import(\"$lib/server/models\")).models;\n\t\t//@ts-expect-error the property doesn't exist anymore, keeping the script for reference\n\t\tconst oldModels = (await import(\"$lib/server/models\")).oldModels;\n\t\tconst { assistants } = collections;\n\n\t\tconst modelIds = models.map((el) => el.id);\n\t\tconst defaultModelId = models[0].id;\n\n\t\t// Find all assistants whose modelId is not in modelIds, and update it\n\t\tconst bulkOps = await assistants\n\t\t\t.find({ modelId: { $nin: modelIds } })\n\t\t\t.map((assistant) => {\n\t\t\t\t// has an old model\n\t\t\t\tlet newModelId = defaultModelId;\n\n\t\t\t\tconst oldModel = oldModels.find((m: (typeof models)[number]) => m.id === assistant.modelId);\n\t\t\t\tif (oldModel && oldModel.transferTo && !!models.find((m) => m.id === oldModel.transferTo)) {\n\t\t\t\t\tnewModelId = oldModel.transferTo;\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tupdateOne: {\n\t\t\t\t\t\tfilter: { _id: assistant._id },\n\t\t\t\t\t\tupdate: { $set: { modelId: newModelId } },\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t})\n\t\t\t.toArray();\n\n\t\tif (bulkOps.length > 0) {\n\t\t\tawait assistants.bulkWrite(bulkOps);\n\t\t}\n\n\t\treturn true;\n\t},\n\trunEveryTime: true,\n\trunForHuggingChat: \"only\",\n};\n\nexport default updateAssistantsModels;\n"
  },
  {
    "path": "src/lib/migrations/routines/04-update-message-updates.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId, type WithId } from \"mongodb\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport {\n\tMessageUpdateStatus,\n\tMessageUpdateType,\n\ttype MessageUpdate,\n} from \"$lib/types/MessageUpdate\";\nimport type { Message } from \"$lib/types/Message\";\n// isMessageWebSearchSourcesUpdate removed from utils; use inline predicate\n\n// -----------\n// Copy of the previous message update types\nexport type FinalAnswer = {\n\ttype: \"finalAnswer\";\n\ttext: string;\n};\n\nexport type TextStreamUpdate = {\n\ttype: \"stream\";\n\ttoken: string;\n};\n\ntype WebSearchUpdate = {\n\ttype: \"webSearch\";\n\tmessageType: \"update\" | \"error\" | \"sources\";\n\tmessage: string;\n\targs?: string[];\n\tsources?: { title?: string; link: string }[];\n};\n\ntype StatusUpdate = {\n\ttype: \"status\";\n\tstatus: \"started\" | \"pending\" | \"finished\" | \"error\" | \"title\";\n\tmessage?: string;\n};\n\ntype ErrorUpdate = {\n\ttype: \"error\";\n\tmessage: string;\n\tname: string;\n};\n\ntype FileUpdate = {\n\ttype: \"file\";\n\tsha: string;\n};\n\ntype OldMessageUpdate =\n\t| FinalAnswer\n\t| TextStreamUpdate\n\t| WebSearchUpdate\n\t| StatusUpdate\n\t| ErrorUpdate\n\t| FileUpdate;\n\n/** Converts the old message update to the new schema */\nfunction convertMessageUpdate(message: Message, update: OldMessageUpdate): MessageUpdate | null {\n\ttry {\n\t\t// Text and files\n\t\tif (update.type === \"finalAnswer\") {\n\t\t\treturn {\n\t\t\t\ttype: MessageUpdateType.FinalAnswer,\n\t\t\t\ttext: update.text,\n\t\t\t\tinterrupted: message.interrupted ?? false,\n\t\t\t};\n\t\t} else if (update.type === \"stream\") {\n\t\t\treturn {\n\t\t\t\ttype: MessageUpdateType.Stream,\n\t\t\t\ttoken: update.token,\n\t\t\t};\n\t\t} else if (update.type === \"file\") {\n\t\t\treturn {\n\t\t\t\ttype: MessageUpdateType.File,\n\t\t\t\tname: \"Unknown\",\n\t\t\t\tsha: update.sha,\n\t\t\t\t// assume jpeg but could be any image. should be harmless\n\t\t\t\tmime: \"image/jpeg\",\n\t\t\t};\n\t\t}\n\n\t\t// Status\n\t\telse if (update.type === \"status\") {\n\t\t\tif (update.status === \"title\") {\n\t\t\t\treturn {\n\t\t\t\t\ttype: MessageUpdateType.Title,\n\t\t\t\t\ttitle: update.message ?? \"New Chat\",\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (update.status === \"pending\") return null;\n\n\t\t\tconst status =\n\t\t\t\tupdate.status === \"started\"\n\t\t\t\t\t? MessageUpdateStatus.Started\n\t\t\t\t\t: update.status === \"finished\"\n\t\t\t\t\t\t? MessageUpdateStatus.Finished\n\t\t\t\t\t\t: MessageUpdateStatus.Error;\n\t\t\treturn {\n\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\tstatus,\n\t\t\t\tmessage: update.message,\n\t\t\t};\n\t\t} else if (update.type === \"error\") {\n\t\t\t// Treat it as an error status update\n\t\t\treturn {\n\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\tstatus: MessageUpdateStatus.Error,\n\t\t\t\tmessage: update.message,\n\t\t\t};\n\t\t}\n\n\t\t// Web Search\n\t\telse if (update.type === \"webSearch\") {\n\t\t\treturn null; // Web search updates are no longer supported\n\t\t}\n\t\tconsole.warn(\"Unknown message update during migration:\", update);\n\t\treturn null;\n\t} catch (error) {\n\t\tconsole.error(\"Error converting message update during migration. Skipping it... Error:\", error);\n\t\treturn null;\n\t}\n}\n\nconst updateMessageUpdates: Migration = {\n\t_id: new ObjectId(\"5f9f7f7f7f7f7f7f7f7f7f7f\"),\n\tname: \"Convert message updates to the new schema\",\n\tup: async () => {\n\t\tconst allConversations = collections.conversations.find({});\n\n\t\tlet conversation: WithId<Pick<Conversation, \"messages\">> | null = null;\n\t\twhile ((conversation = await allConversations.tryNext())) {\n\t\t\tconst messages = conversation.messages.map((message) => {\n\t\t\t\t// Convert all of the existing updates to the new schema\n\t\t\t\tconst updates = message.updates\n\t\t\t\t\t?.map((update) => convertMessageUpdate(message, update as OldMessageUpdate))\n\t\t\t\t\t.filter((update): update is MessageUpdate => Boolean(update));\n\n\t\t\t\treturn { ...message, updates };\n\t\t\t});\n\n\t\t\t// Set the new messages array\n\t\t\tawait collections.conversations.updateOne({ _id: conversation._id }, { $set: { messages } });\n\t\t}\n\n\t\treturn true;\n\t},\n\trunEveryTime: false,\n};\n\nexport default updateMessageUpdates;\n"
  },
  {
    "path": "src/lib/migrations/routines/05-update-message-files.ts",
    "content": "import { ObjectId, type WithId } from \"mongodb\";\nimport { collections } from \"$lib/server/database\";\n\nimport type { Migration } from \".\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport type { MessageFile } from \"$lib/types/Message\";\n\nconst updateMessageFiles: Migration = {\n\t_id: new ObjectId(\"5f9f5f5f5f5f5f5f5f5f5f5f\"),\n\tname: \"Convert message files to the new schema\",\n\tup: async () => {\n\t\tconst allConversations = collections.conversations.find({}, { projection: { messages: 1 } });\n\n\t\tlet conversation: WithId<Pick<Conversation, \"messages\">> | null = null;\n\t\twhile ((conversation = await allConversations.tryNext())) {\n\t\t\tconst messages = conversation.messages.map((message) => {\n\t\t\t\tconst files = (message.files as string[] | undefined)?.map<MessageFile>((file) => {\n\t\t\t\t\t// File is already in the new format\n\t\t\t\t\tif (typeof file !== \"string\") return file;\n\n\t\t\t\t\t// File was a hash pointing to a file in the bucket\n\t\t\t\t\tif (file.length === 64) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\ttype: \"hash\",\n\t\t\t\t\t\t\tname: \"unknown.jpg\",\n\t\t\t\t\t\t\tvalue: file,\n\t\t\t\t\t\t\tmime: \"image/jpeg\",\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t\t// File was a base64 string\n\t\t\t\t\telse {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\ttype: \"base64\",\n\t\t\t\t\t\t\tname: \"unknown.jpg\",\n\t\t\t\t\t\t\tvalue: file,\n\t\t\t\t\t\t\tmime: \"image/jpeg\",\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\treturn {\n\t\t\t\t\t...message,\n\t\t\t\t\tfiles,\n\t\t\t\t};\n\t\t\t});\n\n\t\t\t// Set the new messages array\n\t\t\tawait collections.conversations.updateOne({ _id: conversation._id }, { $set: { messages } });\n\t\t}\n\n\t\treturn true;\n\t},\n\trunEveryTime: false,\n};\n\nexport default updateMessageFiles;\n"
  },
  {
    "path": "src/lib/migrations/routines/06-trim-message-updates.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId, type WithId } from \"mongodb\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport type { Message } from \"$lib/types/Message\";\nimport type { MessageUpdate } from \"$lib/types/MessageUpdate\";\nimport { logger } from \"$lib/server/logger\";\n\n// -----------\n\n/** Converts the old message update to the new schema */\nfunction convertMessageUpdate(message: Message, update: unknown): MessageUpdate | null {\n\ttry {\n\t\t// Trim legacy web search updates entirely\n\t\tif (\n\t\t\ttypeof update === \"object\" &&\n\t\t\tupdate !== null &&\n\t\t\t(update as { type: string }).type === \"webSearch\"\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn update as MessageUpdate;\n\t} catch (error) {\n\t\tlogger.error(error, \"Error converting message update during migration. Skipping it..\");\n\t\treturn null;\n\t}\n}\n\nconst trimMessageUpdates: Migration = {\n\t_id: new ObjectId(\"000000000000000000000006\"),\n\tname: \"Trim message updates to reduce stored size\",\n\tup: async () => {\n\t\tconst allConversations = collections.conversations.find({});\n\n\t\tlet conversation: WithId<Pick<Conversation, \"messages\">> | null = null;\n\t\twhile ((conversation = await allConversations.tryNext())) {\n\t\t\tconst messages = conversation.messages.map((message) => {\n\t\t\t\t// Convert all of the existing updates to the new schema\n\t\t\t\tconst updates = message.updates\n\t\t\t\t\t?.map((update) => convertMessageUpdate(message, update))\n\t\t\t\t\t.filter((update): update is MessageUpdate => Boolean(update));\n\n\t\t\t\treturn { ...message, updates };\n\t\t\t});\n\n\t\t\t// Set the new messages array\n\t\t\tawait collections.conversations.updateOne({ _id: conversation._id }, { $set: { messages } });\n\t\t}\n\n\t\treturn true;\n\t},\n\trunEveryTime: false,\n};\n\nexport default trimMessageUpdates;\n"
  },
  {
    "path": "src/lib/migrations/routines/08-update-featured-to-review.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { ReviewStatus } from \"$lib/types/Review\";\n\nconst updateFeaturedToReview: Migration = {\n\t_id: new ObjectId(\"000000000000000000000008\"),\n\tname: \"Update featured to review\",\n\tup: async () => {\n\t\tconst { assistants, tools } = collections;\n\n\t\t// Update assistants\n\t\tawait assistants.updateMany({ featured: true }, { $set: { review: ReviewStatus.APPROVED } });\n\t\tawait assistants.updateMany(\n\t\t\t{ featured: { $ne: true } },\n\t\t\t{ $set: { review: ReviewStatus.PRIVATE } }\n\t\t);\n\n\t\tawait assistants.updateMany({}, { $unset: { featured: \"\" } });\n\n\t\t// Update tools\n\t\tawait tools.updateMany({ featured: true }, { $set: { review: ReviewStatus.APPROVED } });\n\t\tawait tools.updateMany({ featured: { $ne: true } }, { $set: { review: ReviewStatus.PRIVATE } });\n\n\t\tawait tools.updateMany({}, { $unset: { featured: \"\" } });\n\n\t\treturn true;\n\t},\n\trunEveryTime: false,\n};\n\nexport default updateFeaturedToReview;\n"
  },
  {
    "path": "src/lib/migrations/routines/09-delete-empty-conversations.spec.ts",
    "content": "import type { Session } from \"$lib/types/Session\";\nimport type { User } from \"$lib/types/User\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport { ObjectId } from \"mongodb\";\nimport { deleteConversations } from \"./09-delete-empty-conversations\";\nimport { afterAll, afterEach, beforeAll, describe, expect, test } from \"vitest\";\nimport { collections, ready } from \"$lib/server/database\";\n\ntype Message = Conversation[\"messages\"][number];\n\nconst userData = {\n\t_id: new ObjectId(),\n\tcreatedAt: new Date(),\n\tupdatedAt: new Date(),\n\tusername: \"new-username\",\n\tname: \"name\",\n\tavatarUrl: \"https://example.com/avatar.png\",\n\thfUserId: \"9999999999\",\n} satisfies User;\nObject.freeze(userData);\n\nconst sessionForUser = {\n\t_id: new ObjectId(),\n\tcreatedAt: new Date(),\n\tupdatedAt: new Date(),\n\tuserId: userData._id,\n\tsessionId: \"session-id-9999999999\",\n\texpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),\n} satisfies Session;\nObject.freeze(sessionForUser);\n\nconst userMessage = {\n\tfrom: \"user\",\n\tid: \"user-message-id\",\n\tcontent: \"Hello, how are you?\",\n} satisfies Message;\n\nconst assistantMessage = {\n\tfrom: \"assistant\",\n\tid: \"assistant-message-id\",\n\tcontent: \"I'm fine, thank you!\",\n} satisfies Message;\n\nconst systemMessage = {\n\tfrom: \"system\",\n\tid: \"system-message-id\",\n\tcontent: \"This is a system message\",\n} satisfies Message;\n\nconst conversationBase = {\n\t_id: new ObjectId(),\n\tcreatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),\n\tupdatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),\n\tmodel: \"model-id\",\n\n\ttitle: \"title\",\n\tmessages: [],\n} satisfies Conversation;\n\ndescribe.sequential(\"Deleting discarded conversations\", async () => {\n\ttest(\"a conversation with no messages should get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(1);\n\t});\n\ttest(\"a conversation with no messages that is less than 1 hour old should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t\tcreatedAt: new Date(Date.now() - 30 * 60 * 1000),\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with only system messages should get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t\tmessages: [systemMessage],\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(1);\n\t});\n\ttest(\"a conversation with a user message should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t\tmessages: [userMessage],\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with an assistant message should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t\tmessages: [assistantMessage],\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with a mix of messages should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t\tmessages: [systemMessage, userMessage, assistantMessage, userMessage, assistantMessage],\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with a userId and no sessionId should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tmessages: [userMessage, assistantMessage],\n\t\t\tuserId: userData._id,\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with no userId or sessionId should get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tmessages: [userMessage, assistantMessage],\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(1);\n\t});\n\ttest(\"a conversation with a sessionId that exists should not get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tmessages: [userMessage, assistantMessage],\n\t\t\tsessionId: sessionForUser.sessionId,\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with a userId and a sessionId that doesn't exist should NOT get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tuserId: userData._id,\n\t\t\tmessages: [userMessage, assistantMessage],\n\t\t\tsessionId: new ObjectId().toString(),\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(0);\n\t});\n\ttest(\"a conversation with only a sessionId that doesn't exist, should get deleted\", async () => {\n\t\tawait collections.conversations.insertOne({\n\t\t\t...conversationBase,\n\t\t\tmessages: [userMessage, assistantMessage],\n\t\t\tsessionId: new ObjectId().toString(),\n\t\t});\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(1);\n\t});\n\ttest(\"many conversations should get deleted\", async () => {\n\t\tconst conversations = Array.from({ length: 10010 }, () => ({\n\t\t\t...conversationBase,\n\t\t\t_id: new ObjectId(),\n\t\t}));\n\n\t\tawait collections.conversations.insertMany(conversations);\n\n\t\tconst result = await deleteConversations(collections);\n\n\t\texpect(result).toBe(10010);\n\t});\n\n\tbeforeAll(async () => {\n\t\tawait ready;\n\t\tawait collections.users.insertOne(userData);\n\t\tawait collections.sessions.insertOne(sessionForUser);\n\t}, 20000);\n\n\tafterAll(async () => {\n\t\tawait collections.users.deleteOne({\n\t\t\t_id: userData._id,\n\t\t});\n\t\tawait collections.sessions.deleteOne({\n\t\t\t_id: sessionForUser._id,\n\t\t});\n\t\tawait collections.conversations.deleteMany({});\n\t});\n\n\tafterEach(async () => {\n\t\tawait collections.conversations.deleteMany({\n\t\t\t_id: { $in: [conversationBase._id] },\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "src/lib/migrations/routines/09-delete-empty-conversations.ts",
    "content": "import type { Migration } from \".\";\nimport { collections } from \"$lib/server/database\";\nimport { Collection, FindCursor, ObjectId } from \"mongodb\";\nimport { logger } from \"$lib/server/logger\";\nimport type { Conversation } from \"$lib/types/Conversation\";\n\nconst BATCH_SIZE = 1000;\nconst DELETE_THRESHOLD_MS = 60 * 60 * 1000;\n\nasync function deleteBatch(conversations: Collection<Conversation>, ids: ObjectId[]) {\n\tif (ids.length === 0) return 0;\n\tconst deleteResult = await conversations.deleteMany({ _id: { $in: ids } });\n\treturn deleteResult.deletedCount;\n}\n\nasync function processCursor<T>(\n\tcursor: FindCursor<T>,\n\tprocessBatchFn: (batch: T[]) => Promise<void>\n) {\n\tlet batch = [];\n\twhile (await cursor.hasNext()) {\n\t\tconst doc = await cursor.next();\n\t\tif (doc) {\n\t\t\tbatch.push(doc);\n\t\t}\n\t\tif (batch.length >= BATCH_SIZE) {\n\t\t\tawait processBatchFn(batch);\n\t\t\tbatch = [];\n\t\t}\n\t}\n\tif (batch.length > 0) {\n\t\tawait processBatchFn(batch);\n\t}\n}\n\nexport async function deleteConversations(\n\tcollections: typeof import(\"$lib/server/database\").collections\n) {\n\tlet deleteCount = 0;\n\tconst { conversations, sessions } = collections;\n\n\t// First criteria: Delete conversations with no user/assistant messages older than 1 hour\n\tconst emptyConvCursor = conversations\n\t\t.find({\n\t\t\t\"messages.from\": { $not: { $in: [\"user\", \"assistant\"] } },\n\t\t\tcreatedAt: { $lt: new Date(Date.now() - DELETE_THRESHOLD_MS) },\n\t\t})\n\t\t.batchSize(BATCH_SIZE);\n\n\tawait processCursor(emptyConvCursor, async (batch) => {\n\t\tconst ids = batch.map((doc) => doc._id);\n\t\tdeleteCount += await deleteBatch(conversations, ids);\n\t});\n\n\t// Second criteria: Process conversations without users in batches and check sessions\n\tconst noUserCursor = conversations.find({ userId: { $exists: false } }).batchSize(BATCH_SIZE);\n\n\tawait processCursor(noUserCursor, async (batch) => {\n\t\tconst sessionIds = [\n\t\t\t...new Set(batch.map((conv) => conv.sessionId).filter((id): id is string => !!id)),\n\t\t];\n\n\t\tconst existingSessions = await sessions.find({ sessionId: { $in: sessionIds } }).toArray();\n\t\tconst validSessionIds = new Set(existingSessions.map((s) => s.sessionId));\n\n\t\tconst invalidConvs = batch.filter(\n\t\t\t(conv) => !conv.sessionId || !validSessionIds.has(conv.sessionId)\n\t\t);\n\t\tconst idsToDelete = invalidConvs.map((conv) => conv._id);\n\t\tdeleteCount += await deleteBatch(conversations, idsToDelete);\n\t});\n\n\tlogger.info(`[MIGRATIONS] Deleted ${deleteCount} conversations in total.`);\n\treturn deleteCount;\n}\n\nconst deleteEmptyConversations: Migration = {\n\t_id: new ObjectId(\"000000000000000000000009\"),\n\tname: \"Delete conversations with no user or assistant messages or valid sessions\",\n\tup: async () => {\n\t\tawait deleteConversations(collections);\n\t\treturn true;\n\t},\n\trunEveryTime: false,\n\trunForHuggingChat: \"only\",\n};\n\nexport default deleteEmptyConversations;\n"
  },
  {
    "path": "src/lib/migrations/routines/10-update-reports-assistantid.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport type { Migration } from \".\";\nimport { ObjectId } from \"mongodb\";\n\nconst migration: Migration = {\n\t_id: new ObjectId(\"000000000000000000000010\"),\n\tname: \"Update reports with assistantId to use contentId\",\n\tup: async () => {\n\t\tawait collections.reports.updateMany(\n\t\t\t{\n\t\t\t\tassistantId: { $exists: true, $ne: null },\n\t\t\t},\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\t$set: {\n\t\t\t\t\t\tobject: \"assistant\",\n\t\t\t\t\t\tcontentId: \"$assistantId\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t$unset: \"assistantId\",\n\t\t\t\t},\n\t\t\t]\n\t\t);\n\t\treturn true;\n\t},\n};\n\nexport default migration;\n"
  },
  {
    "path": "src/lib/migrations/routines/index.ts",
    "content": "import type { ObjectId } from \"mongodb\";\n\nimport type { Database } from \"$lib/server/database\";\n\nexport interface Migration {\n\t_id: ObjectId;\n\tname: string;\n\tup: (client: Database) => Promise<boolean>;\n\tdown?: (client: Database) => Promise<boolean>;\n\trunForFreshInstall?: \"only\" | \"never\"; // leave unspecified to run for both\n\trunForHuggingChat?: \"only\" | \"never\"; // leave unspecified to run for both\n\trunEveryTime?: boolean;\n}\n\nexport const migrations: Migration[] = [];\n"
  },
  {
    "path": "src/lib/server/__tests__/conversation-stop-generating.spec.ts",
    "content": "import { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { ObjectId } from \"mongodb\";\n\nimport { collections } from \"$lib/server/database\";\nimport { AbortRegistry } from \"$lib/server/abortRegistry\";\nimport {\n\tcleanupTestData,\n\tcreateTestConversation,\n\tcreateTestLocals,\n\tcreateTestUser,\n} from \"$lib/server/api/__tests__/testHelpers\";\nimport { POST } from \"../../../routes/conversation/[id]/stop-generating/+server\";\n\ndescribe.sequential(\"POST /conversation/[id]/stop-generating\", () => {\n\tafterEach(async () => {\n\t\tvi.restoreAllMocks();\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\n\t\t\"creates abort marker and aborts active registry controllers\",\n\t\t{ timeout: 30000 },\n\t\tasync () => {\n\t\t\tconst { locals } = await createTestUser();\n\t\t\tconst conversation = await createTestConversation(locals);\n\t\t\tconst abortSpy = vi.spyOn(AbortRegistry.getInstance(), \"abort\");\n\n\t\t\tconst response = await POST({\n\t\t\t\tparams: { id: conversation._id.toString() },\n\t\t\t\tlocals,\n\t\t\t} as never);\n\n\t\t\texpect(response.status).toBe(200);\n\t\t\texpect(abortSpy).toHaveBeenCalledWith(conversation._id.toString());\n\n\t\t\tconst marker = await collections.abortedGenerations.findOne({\n\t\t\t\tconversationId: conversation._id,\n\t\t\t});\n\t\t\texpect(marker).not.toBeNull();\n\t\t\texpect(marker?.createdAt).toBeInstanceOf(Date);\n\t\t\texpect(marker?.updatedAt).toBeInstanceOf(Date);\n\t\t}\n\t);\n\n\tit(\"updates updatedAt while preserving createdAt on repeated stop\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conversation = await createTestConversation(locals);\n\n\t\tawait POST({\n\t\t\tparams: { id: conversation._id.toString() },\n\t\t\tlocals,\n\t\t} as never);\n\t\tconst firstMarker = await collections.abortedGenerations.findOne({\n\t\t\tconversationId: conversation._id,\n\t\t});\n\n\t\tawait new Promise((resolve) => setTimeout(resolve, 5));\n\n\t\tawait POST({\n\t\t\tparams: { id: conversation._id.toString() },\n\t\t\tlocals,\n\t\t} as never);\n\t\tconst secondMarker = await collections.abortedGenerations.findOne({\n\t\t\tconversationId: conversation._id,\n\t\t});\n\n\t\texpect(firstMarker).not.toBeNull();\n\t\texpect(secondMarker).not.toBeNull();\n\t\texpect(secondMarker?.createdAt.getTime()).toBe(firstMarker?.createdAt.getTime());\n\t\texpect(secondMarker?.updatedAt.getTime()).toBeGreaterThan(\n\t\t\tfirstMarker?.updatedAt.getTime() ?? 0\n\t\t);\n\t});\n\n\tit(\"throws 404 when conversation is not found\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst missingId = new ObjectId().toString();\n\n\t\ttry {\n\t\t\tawait POST({\n\t\t\t\tparams: { id: missingId },\n\t\t\t\tlocals,\n\t\t\t} as never);\n\t\t\texpect.fail(\"Expected 404 error\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(404);\n\t\t}\n\t});\n\n\tit(\"throws 401 for unauthenticated requests\", async () => {\n\t\tconst locals = createTestLocals({ user: undefined, sessionId: undefined });\n\n\t\ttry {\n\t\t\tawait POST({\n\t\t\t\tparams: { id: new ObjectId().toString() },\n\t\t\t\tlocals,\n\t\t\t} as never);\n\t\t\texpect.fail(\"Expected 401 error\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/abortRegistry.ts",
    "content": "import { logger } from \"$lib/server/logger\";\n\n/**\n * Tracks active upstream generation requests so they can be cancelled on demand.\n * Multiple controllers can be registered per conversation (for threaded/background runs).\n */\nexport class AbortRegistry {\n\tprivate static instance: AbortRegistry;\n\n\tprivate controllers = new Map<string, Set<AbortController>>();\n\n\tpublic static getInstance(): AbortRegistry {\n\t\tif (!AbortRegistry.instance) {\n\t\t\tAbortRegistry.instance = new AbortRegistry();\n\t\t}\n\t\treturn AbortRegistry.instance;\n\t}\n\n\tpublic register(conversationId: string, controller: AbortController) {\n\t\tconst key = conversationId.toString();\n\t\tlet set = this.controllers.get(key);\n\t\tif (!set) {\n\t\t\tset = new Set();\n\t\t\tthis.controllers.set(key, set);\n\t\t}\n\t\tset.add(controller);\n\t\tcontroller.signal.addEventListener(\n\t\t\t\"abort\",\n\t\t\t() => {\n\t\t\t\tthis.unregister(key, controller);\n\t\t\t},\n\t\t\t{ once: true }\n\t\t);\n\t}\n\n\tpublic abort(conversationId: string) {\n\t\tconst set = this.controllers.get(conversationId);\n\t\tif (!set?.size) return;\n\n\t\tlogger.debug({ conversationId }, \"Aborting active generation via AbortRegistry\");\n\t\tfor (const controller of set) {\n\t\t\tif (!controller.signal.aborted) {\n\t\t\t\tcontroller.abort();\n\t\t\t}\n\t\t}\n\t\tthis.controllers.delete(conversationId);\n\t}\n\n\tpublic unregister(conversationId: string, controller: AbortController) {\n\t\tconst set = this.controllers.get(conversationId);\n\t\tif (!set) return;\n\t\tset.delete(controller);\n\t\tif (set.size === 0) {\n\t\t\tthis.controllers.delete(conversationId);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/abortedGenerations.ts",
    "content": "// Shouldn't be needed if we dove into sveltekit internals, see https://github.com/huggingface/chat-ui/pull/88#issuecomment-1523173850\n\nimport { logger } from \"$lib/server/logger\";\nimport { collections } from \"$lib/server/database\";\nimport { onExit } from \"./exitHandler\";\n\nexport class AbortedGenerations {\n\tprivate static instance: AbortedGenerations;\n\n\tprivate abortedGenerations: Record<string, Date> = {};\n\n\tprivate constructor() {\n\t\t// Poll every 500ms for faster abort detection (reduced from 1000ms)\n\t\tconst interval = setInterval(() => this.updateList(), 500);\n\t\tonExit(() => clearInterval(interval));\n\n\t\tthis.updateList();\n\t}\n\n\tpublic static getInstance(): AbortedGenerations {\n\t\tif (!AbortedGenerations.instance) {\n\t\t\tAbortedGenerations.instance = new AbortedGenerations();\n\t\t}\n\n\t\treturn AbortedGenerations.instance;\n\t}\n\n\tpublic getAbortTime(conversationId: string): Date | undefined {\n\t\treturn this.abortedGenerations[conversationId];\n\t}\n\n\tprivate async updateList() {\n\t\ttry {\n\t\t\tconst aborts = await collections.abortedGenerations.find({}).sort({ createdAt: 1 }).toArray();\n\n\t\t\tthis.abortedGenerations = Object.fromEntries(\n\t\t\t\taborts.map((abort) => [abort.conversationId.toString(), abort.updatedAt ?? abort.createdAt])\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tlogger.error(err, \"Error updating aborted generations list\");\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/adminToken.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport type { Session } from \"$lib/types/Session\";\nimport { logger } from \"./logger\";\nimport { v4 } from \"uuid\";\n\nclass AdminTokenManager {\n\tprivate token = config.ADMIN_TOKEN || v4();\n\t// contains all session ids that are currently admin sessions\n\tprivate adminSessions: Array<Session[\"sessionId\"]> = [];\n\n\tpublic get enabled() {\n\t\t// if open id is configured, disable the feature\n\t\treturn config.ADMIN_CLI_LOGIN === \"true\";\n\t}\n\tpublic isAdmin(sessionId: Session[\"sessionId\"]) {\n\t\tif (!this.enabled) return false;\n\t\treturn this.adminSessions.includes(sessionId);\n\t}\n\n\tpublic checkToken(token: string, sessionId: Session[\"sessionId\"]) {\n\t\tif (!this.enabled) return false;\n\t\tif (token === this.token) {\n\t\t\tlogger.info(`[ADMIN] Token validated`);\n\t\t\tthis.adminSessions.push(sessionId);\n\t\t\tthis.token = config.ADMIN_TOKEN || v4();\n\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tpublic removeSession(sessionId: Session[\"sessionId\"]) {\n\t\tthis.adminSessions = this.adminSessions.filter((id) => id !== sessionId);\n\t}\n\n\tpublic displayToken() {\n\t\t// if admin token is set, don't display it\n\t\tif (!this.enabled || config.ADMIN_TOKEN) return;\n\n\t\tlet port = process.env.PORT\n\t\t\t? parseInt(process.env.PORT)\n\t\t\t: process.argv.includes(\"--port\")\n\t\t\t\t? parseInt(process.argv[process.argv.indexOf(\"--port\") + 1])\n\t\t\t\t: undefined;\n\n\t\tif (!port) {\n\t\t\tconst mode = process.argv.find((arg) => arg === \"preview\" || arg === \"dev\");\n\t\t\tif (mode === \"preview\") {\n\t\t\t\tport = 4173;\n\t\t\t} else if (mode === \"dev\") {\n\t\t\t\tport = 5173;\n\t\t\t} else {\n\t\t\t\tport = 3000;\n\t\t\t}\n\t\t}\n\n\t\tconst url = (config.PUBLIC_ORIGIN || `http://localhost:${port}`) + \"?token=\";\n\t\tlogger.info(`[ADMIN] You can login with ${url + this.token}`);\n\t}\n}\n\nexport const adminTokenManager = new AdminTokenManager();\n"
  },
  {
    "path": "src/lib/server/api/__tests__/conversations-id.spec.ts",
    "content": "import { describe, expect, it, afterEach } from \"vitest\";\nimport { ObjectId } from \"mongodb\";\nimport superjson from \"superjson\";\nimport { collections } from \"$lib/server/database\";\nimport {\n\tcreateTestLocals,\n\tcreateTestUser,\n\tcreateTestConversation,\n\tcleanupTestData,\n} from \"./testHelpers\";\n\nimport { GET, DELETE, PATCH } from \"../../../../routes/api/v2/conversations/[id]/+server\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\nfunction mockUrl(): URL {\n\treturn new URL(\"http://localhost:5173/api/v2/conversations/some-id\");\n}\n\ndescribe.sequential(\"GET /api/v2/conversations/[id]\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"returns conversation data for owner\", { timeout: 15000 }, async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, {\n\t\t\ttitle: \"My Conversation\",\n\t\t\tmodel: \"test-model\",\n\t\t\tpreprompt: \"You are helpful.\",\n\t\t});\n\n\t\tconst res = await GET({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString() },\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{\n\t\t\ttitle: string;\n\t\t\tmodel: string;\n\t\t\tpreprompt: string;\n\t\t\tid: string;\n\t\t}>(res);\n\t\texpect(data.title).toBe(\"My Conversation\");\n\t\texpect(data.model).toBe(\"test-model\");\n\t\texpect(data.preprompt).toBe(\"You are helpful.\");\n\t\texpect(data.id).toBe(conv._id.toString());\n\t});\n\n\tit(\"throws 404 for non-existent conversation\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst fakeId = new ObjectId().toString();\n\n\t\ttry {\n\t\t\tawait GET({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: fakeId },\n\t\t\t\turl: mockUrl(),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(404);\n\t\t}\n\t});\n\n\tit(\"throws 403 for another user's conversation\", async () => {\n\t\tconst { locals: localsA } = await createTestUser();\n\t\tconst { locals: localsB } = await createTestUser();\n\t\tconst conv = await createTestConversation(localsA, { title: \"Private Chat\" });\n\n\t\ttry {\n\t\t\tawait GET({\n\t\t\t\tlocals: localsB,\n\t\t\t\tparams: { id: conv._id.toString() },\n\t\t\t\turl: mockUrl(),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(403);\n\t\t}\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait GET({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: new ObjectId().toString() },\n\t\t\t\turl: mockUrl(),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n\n\tit(\"throws 400 for invalid ObjectId format\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\ttry {\n\t\t\tawait GET({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: \"not-a-valid-objectid\" },\n\t\t\t\turl: mockUrl(),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(400);\n\t\t}\n\t});\n});\n\ndescribe.sequential(\"DELETE /api/v2/conversations/[id]\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"removes owned conversation\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"To Delete\" });\n\n\t\tconst res = await DELETE({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString() },\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{ success: boolean }>(res);\n\t\texpect(data.success).toBe(true);\n\n\t\tconst found = await collections.conversations.findOne({ _id: conv._id });\n\t\texpect(found).toBeNull();\n\t});\n\n\tit(\"throws 404 for non-existent conversation\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst fakeId = new ObjectId().toString();\n\n\t\ttry {\n\t\t\tawait DELETE({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: fakeId },\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(404);\n\t\t}\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait DELETE({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: new ObjectId().toString() },\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n});\n\ndescribe.sequential(\"PATCH /api/v2/conversations/[id]\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"updates title\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"Old Title\" });\n\n\t\tconst res = await PATCH({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString() },\n\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\tmethod: \"PATCH\",\n\t\t\t\tbody: JSON.stringify({ title: \"New Title\" }),\n\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t}),\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{ success: boolean }>(res);\n\t\texpect(data.success).toBe(true);\n\n\t\tconst updated = await collections.conversations.findOne({ _id: conv._id });\n\t\texpect(updated?.title).toBe(\"New Title\");\n\t});\n\n\tit(\"strips <think> tags from title\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"Old Title\" });\n\n\t\tconst res = await PATCH({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString() },\n\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\tmethod: \"PATCH\",\n\t\t\t\tbody: JSON.stringify({ title: \"<think>hidden</think>Visible Title\" }),\n\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t}),\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\n\t\tconst updated = await collections.conversations.findOne({ _id: conv._id });\n\t\texpect(updated?.title).toBe(\"hiddenVisible Title\");\n\t});\n\n\tit(\"rejects empty title\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"Original\" });\n\n\t\ttry {\n\t\t\tawait PATCH({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: conv._id.toString() },\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: JSON.stringify({ title: \"\" }),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(400);\n\t\t}\n\t});\n\n\tit(\"rejects title longer than 100 characters\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"Original\" });\n\t\tconst longTitle = \"a\".repeat(101);\n\n\t\ttry {\n\t\t\tawait PATCH({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: conv._id.toString() },\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: JSON.stringify({ title: longTitle }),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(400);\n\t\t}\n\t});\n\n\tit(\"throws 404 for non-existent conversation\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst fakeId = new ObjectId().toString();\n\n\t\ttry {\n\t\t\tawait PATCH({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: fakeId },\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: JSON.stringify({ title: \"New Title\" }),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(404);\n\t\t}\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait PATCH({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: new ObjectId().toString() },\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\tbody: JSON.stringify({ title: \"New Title\" }),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/__tests__/conversations-message.spec.ts",
    "content": "import { describe, expect, it, afterEach } from \"vitest\";\nimport { ObjectId } from \"mongodb\";\nimport { v4 } from \"uuid\";\nimport superjson from \"superjson\";\nimport { collections } from \"$lib/server/database\";\nimport type { Message } from \"$lib/types/Message\";\nimport {\n\tcreateTestLocals,\n\tcreateTestUser,\n\tcreateTestConversation,\n\tcleanupTestData,\n} from \"./testHelpers\";\n\nimport { DELETE } from \"../../../../routes/api/v2/conversations/[id]/message/[messageId]/+server\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\n/**\n * Build a simple message tree:\n *\n *   root (system)\n *     -> msg1 (user)\n *       -> msg2 (assistant)\n *         -> msg3 (user)\n *     -> unrelated (user) -- sibling branch from root\n */\nfunction buildMessageTree(): {\n\tmessages: Message[];\n\trootId: string;\n\tmsg1Id: string;\n\tmsg2Id: string;\n\tmsg3Id: string;\n\tunrelatedId: string;\n} {\n\tconst rootId = v4();\n\tconst msg1Id = v4();\n\tconst msg2Id = v4();\n\tconst msg3Id = v4();\n\tconst unrelatedId = v4();\n\n\tconst root: Message = {\n\t\tid: rootId,\n\t\tfrom: \"system\",\n\t\tcontent: \"System prompt\",\n\t\tancestors: [],\n\t\tchildren: [msg1Id, unrelatedId],\n\t};\n\tconst msg1: Message = {\n\t\tid: msg1Id,\n\t\tfrom: \"user\",\n\t\tcontent: \"Hello\",\n\t\tancestors: [rootId],\n\t\tchildren: [msg2Id],\n\t};\n\tconst msg2: Message = {\n\t\tid: msg2Id,\n\t\tfrom: \"assistant\",\n\t\tcontent: \"Hi there!\",\n\t\tancestors: [rootId, msg1Id],\n\t\tchildren: [msg3Id],\n\t};\n\tconst msg3: Message = {\n\t\tid: msg3Id,\n\t\tfrom: \"user\",\n\t\tcontent: \"How are you?\",\n\t\tancestors: [rootId, msg1Id, msg2Id],\n\t\tchildren: [],\n\t};\n\tconst unrelated: Message = {\n\t\tid: unrelatedId,\n\t\tfrom: \"user\",\n\t\tcontent: \"Unrelated branch\",\n\t\tancestors: [rootId],\n\t\tchildren: [],\n\t};\n\n\treturn {\n\t\tmessages: [root, msg1, msg2, msg3, unrelated],\n\t\trootId,\n\t\tmsg1Id,\n\t\tmsg2Id,\n\t\tmsg3Id,\n\t\tunrelatedId,\n\t};\n}\n\ndescribe.sequential(\"DELETE /api/v2/conversations/[id]/message/[messageId]\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"removes target message and its descendants\", { timeout: 30000 }, async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst tree = buildMessageTree();\n\n\t\tconst conv = await createTestConversation(locals, {\n\t\t\tmessages: tree.messages,\n\t\t\trootMessageId: tree.rootId,\n\t\t});\n\n\t\t// Delete msg1 -> should also remove msg2 and msg3 (descendants)\n\t\tconst res = await DELETE({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString(), messageId: tree.msg1Id },\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{ success: boolean }>(res);\n\t\texpect(data.success).toBe(true);\n\n\t\tconst updated = await collections.conversations.findOne({ _id: conv._id });\n\t\texpect(updated).not.toBeNull();\n\n\t\tconst remainingIds = (updated?.messages ?? []).map((m) => m.id);\n\t\t// msg1, msg2, msg3 should all be removed\n\t\texpect(remainingIds).not.toContain(tree.msg1Id);\n\t\texpect(remainingIds).not.toContain(tree.msg2Id);\n\t\texpect(remainingIds).not.toContain(tree.msg3Id);\n\t\t// root and unrelated should remain\n\t\texpect(remainingIds).toContain(tree.rootId);\n\t\texpect(remainingIds).toContain(tree.unrelatedId);\n\t});\n\n\tit(\"cleans up children arrays referencing deleted message\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst tree = buildMessageTree();\n\n\t\tconst conv = await createTestConversation(locals, {\n\t\t\tmessages: tree.messages,\n\t\t\trootMessageId: tree.rootId,\n\t\t});\n\n\t\t// Delete msg1 -> root's children should no longer include msg1Id\n\t\tawait DELETE({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString(), messageId: tree.msg1Id },\n\t\t} as never);\n\n\t\tconst updated = await collections.conversations.findOne({ _id: conv._id });\n\t\tconst rootMsg = updated?.messages.find((m) => m.id === tree.rootId);\n\t\texpect(rootMsg).toBeDefined();\n\t\texpect(rootMsg?.children).not.toContain(tree.msg1Id);\n\t\t// The unrelated sibling should still be in root's children\n\t\texpect(rootMsg?.children).toContain(tree.unrelatedId);\n\t});\n\n\tit(\"throws 404 for non-existent message\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst tree = buildMessageTree();\n\n\t\tconst conv = await createTestConversation(locals, {\n\t\t\tmessages: tree.messages,\n\t\t\trootMessageId: tree.rootId,\n\t\t});\n\n\t\tconst fakeMessageId = v4();\n\n\t\ttry {\n\t\t\tawait DELETE({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: conv._id.toString(), messageId: fakeMessageId },\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(404);\n\t\t}\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait DELETE({\n\t\t\t\tlocals,\n\t\t\t\tparams: { id: new ObjectId().toString(), messageId: v4() },\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n\n\tit(\"preserves unrelated messages in the tree\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst tree = buildMessageTree();\n\n\t\tconst conv = await createTestConversation(locals, {\n\t\t\tmessages: tree.messages,\n\t\t\trootMessageId: tree.rootId,\n\t\t});\n\n\t\t// Delete msg3 (a leaf) -> should only remove msg3, everything else stays\n\t\tconst res = await DELETE({\n\t\t\tlocals,\n\t\t\tparams: { id: conv._id.toString(), messageId: tree.msg3Id },\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\n\t\tconst updated = await collections.conversations.findOne({ _id: conv._id });\n\t\tconst remainingIds = (updated?.messages ?? []).map((m) => m.id);\n\n\t\texpect(remainingIds).toHaveLength(4);\n\t\texpect(remainingIds).toContain(tree.rootId);\n\t\texpect(remainingIds).toContain(tree.msg1Id);\n\t\texpect(remainingIds).toContain(tree.msg2Id);\n\t\texpect(remainingIds).toContain(tree.unrelatedId);\n\t\texpect(remainingIds).not.toContain(tree.msg3Id);\n\n\t\t// msg2's children should no longer include msg3Id\n\t\tconst msg2 = updated?.messages.find((m) => m.id === tree.msg2Id);\n\t\texpect(msg2?.children).not.toContain(tree.msg3Id);\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/__tests__/conversations.spec.ts",
    "content": "import { describe, expect, it, afterEach } from \"vitest\";\nimport superjson from \"superjson\";\nimport { collections } from \"$lib/server/database\";\nimport { CONV_NUM_PER_PAGE } from \"$lib/constants/pagination\";\nimport {\n\tcreateTestLocals,\n\tcreateTestUser,\n\tcreateTestConversation,\n\tcleanupTestData,\n} from \"./testHelpers\";\n\nimport { GET, DELETE } from \"../../../../routes/api/v2/conversations/+server\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\nfunction mockUrl(params?: Record<string, string>): URL {\n\tconst url = new URL(\"http://localhost:5173/api/v2/conversations\");\n\tif (params) {\n\t\tfor (const [key, value] of Object.entries(params)) {\n\t\t\turl.searchParams.set(key, value);\n\t\t}\n\t}\n\treturn url;\n}\n\ndescribe.sequential(\"GET /api/v2/conversations\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"returns conversations for authenticated user\", { timeout: 30000 }, async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tconst conv = await createTestConversation(locals, { title: \"My Chat\" });\n\n\t\tconst res = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{\n\t\t\tconversations: Array<{ title: string; _id: { toString(): string } }>;\n\t\t\thasMore: boolean;\n\t\t}>(res);\n\t\texpect(data.conversations).toHaveLength(1);\n\t\texpect(data.conversations[0].title).toBe(\"My Chat\");\n\t\texpect(data.conversations[0]._id.toString()).toBe(conv._id.toString());\n\t\texpect(data.hasMore).toBe(false);\n\t});\n\n\tit(\"returns empty array for user with no conversations\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tconst res = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\texpect(res.status).toBe(200);\n\t\tconst data = await parseResponse<{ conversations: unknown[]; hasMore: boolean }>(res);\n\t\texpect(data.conversations).toHaveLength(0);\n\t\texpect(data.hasMore).toBe(false);\n\t});\n\n\tit(\"supports pagination with p=0 and p=1\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\t// Create CONV_NUM_PER_PAGE + 5 conversations with distinct updatedAt values\n\t\tfor (let i = 0; i < CONV_NUM_PER_PAGE + 5; i++) {\n\t\t\tawait createTestConversation(locals, {\n\t\t\t\ttitle: `Conv ${i}`,\n\t\t\t\tupdatedAt: new Date(Date.now() - (CONV_NUM_PER_PAGE + 5 - i) * 1000),\n\t\t\t});\n\t\t}\n\n\t\tconst resPage0 = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl({ p: \"0\" }),\n\t\t} as never);\n\n\t\tconst dataPage0 = await parseResponse<{\n\t\t\tconversations: Array<{ title: string }>;\n\t\t\thasMore: boolean;\n\t\t}>(resPage0);\n\t\texpect(dataPage0.conversations).toHaveLength(CONV_NUM_PER_PAGE);\n\t\texpect(dataPage0.hasMore).toBe(true);\n\n\t\tconst resPage1 = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl({ p: \"1\" }),\n\t\t} as never);\n\n\t\tconst dataPage1 = await parseResponse<{\n\t\t\tconversations: Array<{ title: string }>;\n\t\t\thasMore: boolean;\n\t\t}>(resPage1);\n\t\texpect(dataPage1.conversations).toHaveLength(5);\n\t\texpect(dataPage1.hasMore).toBe(false);\n\t});\n\n\tit(\"returns hasMore=true when more than CONV_NUM_PER_PAGE exist\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tfor (let i = 0; i < CONV_NUM_PER_PAGE + 1; i++) {\n\t\t\tawait createTestConversation(locals, {\n\t\t\t\ttitle: `Conv ${i}`,\n\t\t\t\tupdatedAt: new Date(Date.now() - i * 1000),\n\t\t\t});\n\t\t}\n\n\t\tconst res = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\tconst data = await parseResponse<{ conversations: unknown[]; hasMore: boolean }>(res);\n\t\texpect(data.conversations).toHaveLength(CONV_NUM_PER_PAGE);\n\t\texpect(data.hasMore).toBe(true);\n\t});\n\n\tit(\"sorts by updatedAt descending\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tawait createTestConversation(locals, {\n\t\t\ttitle: \"Oldest\",\n\t\t\tupdatedAt: new Date(\"2024-01-01\"),\n\t\t});\n\t\tawait createTestConversation(locals, {\n\t\t\ttitle: \"Newest\",\n\t\t\tupdatedAt: new Date(\"2024-06-01\"),\n\t\t});\n\t\tawait createTestConversation(locals, {\n\t\t\ttitle: \"Middle\",\n\t\t\tupdatedAt: new Date(\"2024-03-01\"),\n\t\t});\n\n\t\tconst res = await GET({\n\t\t\tlocals,\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\tconst data = await parseResponse<{ conversations: Array<{ title: string }> }>(res);\n\t\texpect(data.conversations[0].title).toBe(\"Newest\");\n\t\texpect(data.conversations[1].title).toBe(\"Middle\");\n\t\texpect(data.conversations[2].title).toBe(\"Oldest\");\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait GET({\n\t\t\t\tlocals,\n\t\t\t\turl: mockUrl(),\n\t\t\t} as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n\n\tit(\"does not return other users' conversations\", async () => {\n\t\tconst { locals: localsA } = await createTestUser();\n\t\tconst { locals: localsB } = await createTestUser();\n\n\t\tawait createTestConversation(localsA, { title: \"User A Chat\" });\n\t\tawait createTestConversation(localsB, { title: \"User B Chat\" });\n\n\t\tconst res = await GET({\n\t\t\tlocals: localsA,\n\t\t\turl: mockUrl(),\n\t\t} as never);\n\n\t\tconst data = await parseResponse<{ conversations: Array<{ title: string }> }>(res);\n\t\texpect(data.conversations).toHaveLength(1);\n\t\texpect(data.conversations[0].title).toBe(\"User A Chat\");\n\t});\n});\n\ndescribe.sequential(\"DELETE /api/v2/conversations\", () => {\n\tafterEach(async () => {\n\t\tawait cleanupTestData();\n\t});\n\n\tit(\"removes all conversations for authenticated user\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tawait createTestConversation(locals, { title: \"Chat 1\" });\n\t\tawait createTestConversation(locals, { title: \"Chat 2\" });\n\t\tawait createTestConversation(locals, { title: \"Chat 3\" });\n\n\t\tconst res = await DELETE({ locals } as never);\n\t\texpect(res.status).toBe(200);\n\n\t\tconst data = await parseResponse<number>(res);\n\t\texpect(data).toBe(3);\n\n\t\tconst remaining = await collections.conversations.countDocuments();\n\t\texpect(remaining).toBe(0);\n\t});\n\n\tit(\"throws 401 for unauthenticated request\", async () => {\n\t\tconst locals = createTestLocals({ sessionId: undefined, user: undefined });\n\n\t\ttry {\n\t\t\tawait DELETE({ locals } as never);\n\t\t\texpect.fail(\"Should have thrown\");\n\t\t} catch (e: unknown) {\n\t\t\texpect((e as { status: number }).status).toBe(401);\n\t\t}\n\t});\n\n\tit(\"does not remove other users' conversations\", async () => {\n\t\tconst { locals: localsA } = await createTestUser();\n\t\tconst { locals: localsB } = await createTestUser();\n\n\t\tawait createTestConversation(localsA, { title: \"User A Chat\" });\n\t\tawait createTestConversation(localsB, { title: \"User B Chat\" });\n\n\t\tconst res = await DELETE({ locals: localsA } as never);\n\t\tconst data = await parseResponse<number>(res);\n\t\texpect(data).toBe(1);\n\n\t\tconst remaining = await collections.conversations.countDocuments();\n\t\texpect(remaining).toBe(1);\n\n\t\tconst userBConvs = await collections.conversations\n\t\t\t.find({ userId: localsB.user?._id })\n\t\t\t.toArray();\n\t\texpect(userBConvs).toHaveLength(1);\n\t\texpect(userBConvs[0].title).toBe(\"User B Chat\");\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/__tests__/misc.spec.ts",
    "content": "import { describe, it, expect, beforeEach } from \"vitest\";\nimport superjson from \"superjson\";\nimport { createTestLocals, createTestUser, cleanupTestData } from \"./testHelpers\";\nimport { GET as featureFlagsGET } from \"../../../../routes/api/v2/feature-flags/+server\";\nimport { GET as publicConfigGET } from \"../../../../routes/api/v2/public-config/+server\";\nimport type { FeatureFlags } from \"$lib/server/api/types\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\nfunction mockRequestEvent(locals: App.Locals) {\n\treturn {\n\t\tlocals,\n\t\turl: new URL(\"http://localhost\"),\n\t\trequest: new Request(\"http://localhost\"),\n\t} as Parameters<typeof featureFlagsGET>[0];\n}\n\ndescribe(\"GET /api/v2/feature-flags\", () => {\n\tbeforeEach(async () => {\n\t\tawait cleanupTestData();\n\t}, 20000);\n\n\tit(\"returns correct shape with expected fields\", async () => {\n\t\tconst locals = createTestLocals();\n\n\t\tconst res = await featureFlagsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<FeatureFlags>(res);\n\n\t\texpect(data).toHaveProperty(\"enableAssistants\");\n\t\texpect(data).toHaveProperty(\"loginEnabled\");\n\t\texpect(data).toHaveProperty(\"isAdmin\");\n\t\texpect(data).toHaveProperty(\"transcriptionEnabled\");\n\t\texpect(typeof data.enableAssistants).toBe(\"boolean\");\n\t\texpect(typeof data.loginEnabled).toBe(\"boolean\");\n\t\texpect(typeof data.isAdmin).toBe(\"boolean\");\n\t\texpect(typeof data.transcriptionEnabled).toBe(\"boolean\");\n\t});\n\n\tit(\"reflects isAdmin from locals for non-admin user\", async () => {\n\t\tconst locals = createTestLocals({ isAdmin: false });\n\n\t\tconst res = await featureFlagsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<FeatureFlags>(res);\n\n\t\texpect(data.isAdmin).toBe(false);\n\t});\n\n\tit(\"reflects isAdmin from locals for admin user\", async () => {\n\t\tconst { locals } = await createTestUser();\n\t\tlocals.isAdmin = true;\n\n\t\tconst res = await featureFlagsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<FeatureFlags>(res);\n\n\t\texpect(data.isAdmin).toBe(true);\n\t});\n});\n\ndescribe(\"GET /api/v2/public-config\", () => {\n\tit(\"returns an object\", async () => {\n\t\tconst locals = createTestLocals();\n\n\t\tconst res = await publicConfigGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Record<string, unknown>>(res);\n\n\t\texpect(data).toBeDefined();\n\t\texpect(typeof data).toBe(\"object\");\n\t\texpect(data).not.toBeNull();\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/__tests__/testHelpers.ts",
    "content": "import { ObjectId } from \"mongodb\";\nimport { collections } from \"$lib/server/database\";\nimport type { User } from \"$lib/types/User\";\nimport type { Session } from \"$lib/types/Session\";\nimport type { Conversation } from \"$lib/types/Conversation\";\n\nexport function createTestLocals(overrides?: Partial<App.Locals>): App.Locals {\n\treturn {\n\t\tsessionId: \"test-session-id\",\n\t\tisAdmin: false,\n\t\tuser: undefined,\n\t\ttoken: undefined,\n\t\t...overrides,\n\t};\n}\n\nexport async function createTestUser(): Promise<{\n\tuser: User;\n\tsession: Session;\n\tlocals: App.Locals;\n}> {\n\tconst userId = new ObjectId();\n\tconst sessionId = `test-session-${userId.toString()}`;\n\n\tconst user: User = {\n\t\t_id: userId,\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\tusername: `user-${userId.toString().slice(0, 8)}`,\n\t\tname: \"Test User\",\n\t\tavatarUrl: \"https://example.com/avatar.png\",\n\t\thfUserId: `hf-${userId.toString()}`,\n\t};\n\n\tconst session: Session = {\n\t\t_id: new ObjectId(),\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\tuserId,\n\t\tsessionId,\n\t\texpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),\n\t};\n\n\tawait collections.users.insertOne(user);\n\tawait collections.sessions.insertOne(session);\n\n\treturn {\n\t\tuser,\n\t\tsession,\n\t\tlocals: {\n\t\t\tuser,\n\t\t\tsessionId,\n\t\t\tisAdmin: false,\n\t\t\ttoken: undefined,\n\t\t},\n\t};\n}\n\nexport async function createTestConversation(\n\tlocals: App.Locals,\n\toverrides?: Partial<Conversation>\n): Promise<Conversation> {\n\tconst conv: Conversation = {\n\t\t_id: new ObjectId(),\n\t\ttitle: \"Test Conversation\",\n\t\tmodel: \"test-model\",\n\t\tmessages: [],\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\t...(locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId }),\n\t\t...overrides,\n\t};\n\n\tawait collections.conversations.insertOne(conv);\n\treturn conv;\n}\n\nexport async function cleanupTestData() {\n\tawait collections.conversations.deleteMany({});\n\tawait collections.abortedGenerations.deleteMany({});\n\tawait collections.users.deleteMany({});\n\tawait collections.sessions.deleteMany({});\n\tawait collections.settings.deleteMany({});\n\tawait collections.sharedConversations.deleteMany({});\n\tawait collections.reports.deleteMany({});\n}\n"
  },
  {
    "path": "src/lib/server/api/__tests__/user-reports.spec.ts",
    "content": "import { describe, it, expect, beforeEach } from \"vitest\";\nimport { ObjectId } from \"mongodb\";\nimport superjson from \"superjson\";\nimport { collections } from \"$lib/server/database\";\nimport { createTestLocals, createTestUser, cleanupTestData } from \"./testHelpers\";\nimport { GET } from \"../../../../routes/api/v2/user/reports/+server\";\nimport type { Report } from \"$lib/types/Report\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\nfunction mockRequestEvent(locals: App.Locals) {\n\treturn {\n\t\tlocals,\n\t\turl: new URL(\"http://localhost\"),\n\t\trequest: new Request(\"http://localhost\"),\n\t} as Parameters<typeof GET>[0];\n}\n\ndescribe(\"GET /api/v2/user/reports\", () => {\n\tbeforeEach(async () => {\n\t\tawait cleanupTestData();\n\t}, 20000);\n\n\tit(\"returns empty array for unauthenticated user\", async () => {\n\t\tconst locals = createTestLocals();\n\n\t\tconst res = await GET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<unknown[]>(res);\n\n\t\texpect(data).toEqual([]);\n\t});\n\n\tit(\"returns reports for authenticated user\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tconst report1: Report = {\n\t\t\t_id: new ObjectId(),\n\t\t\tcreatedBy: user._id,\n\t\t\tobject: \"assistant\",\n\t\t\tcontentId: new ObjectId(),\n\t\t\treason: \"Inappropriate content\",\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t};\n\n\t\tconst report2: Report = {\n\t\t\t_id: new ObjectId(),\n\t\t\tcreatedBy: user._id,\n\t\t\tobject: \"tool\",\n\t\t\tcontentId: new ObjectId(),\n\t\t\treason: \"Broken tool\",\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t};\n\n\t\tawait collections.reports.insertMany([report1, report2]);\n\n\t\tconst res = await GET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Report[]>(res);\n\n\t\texpect(data).toHaveLength(2);\n\t\texpect(data[0]._id.toString()).toBe(report1._id.toString());\n\t\texpect(data[1]._id.toString()).toBe(report2._id.toString());\n\t\texpect(data[0].reason).toBe(\"Inappropriate content\");\n\t\texpect(data[1].reason).toBe(\"Broken tool\");\n\t});\n\n\tit(\"returns empty array when authenticated user has no reports\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tconst res = await GET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<unknown[]>(res);\n\n\t\texpect(data).toEqual([]);\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/__tests__/user.spec.ts",
    "content": "import { describe, it, expect, beforeEach } from \"vitest\";\nimport superjson from \"superjson\";\nimport { collections } from \"$lib/server/database\";\nimport { createTestLocals, createTestUser, cleanupTestData } from \"./testHelpers\";\nimport { GET as userGET } from \"../../../../routes/api/v2/user/+server\";\nimport {\n\tGET as settingsGET,\n\tPOST as settingsPOST,\n} from \"../../../../routes/api/v2/user/settings/+server\";\n\nasync function parseResponse<T = unknown>(res: Response): Promise<T> {\n\treturn superjson.parse(await res.text()) as T;\n}\n\nfunction mockRequestEvent(locals: App.Locals, overrides?: Record<string, unknown>) {\n\treturn {\n\t\tlocals,\n\t\turl: new URL(\"http://localhost\"),\n\t\trequest: new Request(\"http://localhost\"),\n\t\t...overrides,\n\t} as Parameters<typeof userGET>[0];\n}\n\ndescribe(\"GET /api/v2/user\", () => {\n\tbeforeEach(async () => {\n\t\tawait cleanupTestData();\n\t}, 20000);\n\n\tit(\"returns user info for authenticated user\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tconst res = await userGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Record<string, unknown>>(res);\n\n\t\texpect(data).not.toBeNull();\n\t\texpect(data).toMatchObject({\n\t\t\tid: user._id.toString(),\n\t\t\tusername: user.username,\n\t\t\tavatarUrl: user.avatarUrl,\n\t\t\tisAdmin: false,\n\t\t\tisEarlyAccess: false,\n\t\t});\n\t});\n\n\tit(\"returns null for unauthenticated user\", async () => {\n\t\tconst locals = createTestLocals();\n\n\t\tconst res = await userGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse(res);\n\n\t\texpect(data).toBeNull();\n\t});\n});\n\ndescribe(\"GET /api/v2/user/settings\", () => {\n\tbeforeEach(async () => {\n\t\tawait cleanupTestData();\n\t}, 20000);\n\n\tit(\"returns default settings when none exist\", async () => {\n\t\tconst { locals } = await createTestUser();\n\n\t\tconst res = await settingsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Record<string, unknown>>(res);\n\n\t\texpect(data).toMatchObject({\n\t\t\twelcomeModalSeen: false,\n\t\t\twelcomeModalSeenAt: null,\n\t\t\tstreamingMode: \"smooth\",\n\t\t\tdirectPaste: false,\n\t\t\tshareConversationsWithModelAuthors: true,\n\t\t\tcustomPrompts: {},\n\t\t\tmultimodalOverrides: {},\n\t\t\ttoolsOverrides: {},\n\t\t\tproviderOverrides: {},\n\t\t});\n\t});\n\n\tit(\"returns stored settings with canonical streaming mode\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tawait collections.settings.insertOne({\n\t\t\tuserId: user._id,\n\t\t\tshareConversationsWithModelAuthors: false,\n\t\t\tactiveModel: \"custom-model\",\n\t\t\tstreamingMode: \"raw\",\n\t\t\tdirectPaste: true,\n\t\t\thapticsEnabled: true,\n\t\t\tcustomPrompts: { \"my-model\": \"Be helpful\" },\n\t\t\tmultimodalOverrides: {},\n\t\t\ttoolsOverrides: {},\n\t\t\thidePromptExamples: {},\n\t\t\tproviderOverrides: {},\n\t\t\twelcomeModalSeenAt: new Date(\"2024-01-01\"),\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t});\n\n\t\tconst res = await settingsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Record<string, unknown>>(res);\n\n\t\texpect(data).toMatchObject({\n\t\t\twelcomeModalSeen: true,\n\t\t\tshareConversationsWithModelAuthors: false,\n\t\t\tstreamingMode: \"raw\",\n\t\t\tdirectPaste: true,\n\t\t\tcustomPrompts: { \"my-model\": \"Be helpful\" },\n\t\t});\n\t});\n\n\tit(\"maps legacy stored streamingMode=final to smooth\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tconst legacySettingsWithFinal = {\n\t\t\tuserId: user._id,\n\t\t\tshareConversationsWithModelAuthors: true,\n\t\t\tactiveModel: \"custom-model\",\n\t\t\tstreamingMode: \"final\",\n\t\t\tdirectPaste: false,\n\t\t\tcustomPrompts: {},\n\t\t\tmultimodalOverrides: {},\n\t\t\ttoolsOverrides: {},\n\t\t\thidePromptExamples: {},\n\t\t\tproviderOverrides: {},\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t};\n\n\t\tawait collections.settings.insertOne(\n\t\t\tlegacySettingsWithFinal as unknown as Parameters<typeof collections.settings.insertOne>[0]\n\t\t);\n\n\t\tconst res = await settingsGET(mockRequestEvent(locals));\n\t\tconst data = await parseResponse<Record<string, unknown>>(res);\n\n\t\texpect(data).toMatchObject({\n\t\t\tstreamingMode: \"smooth\",\n\t\t});\n\t});\n});\n\ndescribe(\"POST /api/v2/user/settings\", () => {\n\tbeforeEach(async () => {\n\t\tawait cleanupTestData();\n\t}, 20000);\n\n\tit(\"creates settings with upsert\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tconst body = {\n\t\t\tshareConversationsWithModelAuthors: false,\n\t\t\tactiveModel: \"test-model\",\n\t\t\tcustomPrompts: {},\n\t\t\tmultimodalOverrides: {},\n\t\t\ttoolsOverrides: {},\n\t\t\tproviderOverrides: {},\n\t\t\tstreamingMode: \"raw\",\n\t\t\tdirectPaste: false,\n\t\t\thidePromptExamples: {},\n\t\t};\n\n\t\tconst res = await settingsPOST(\n\t\t\tmockRequestEvent(locals, {\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t})\n\t\t);\n\n\t\texpect(res.status).toBe(200);\n\n\t\tconst stored = await collections.settings.findOne({ userId: user._id });\n\t\texpect(stored).not.toBeNull();\n\t\texpect(stored?.shareConversationsWithModelAuthors).toBe(false);\n\t\texpect(stored?.streamingMode).toBe(\"raw\");\n\t\texpect(stored?.createdAt).toBeInstanceOf(Date);\n\t\texpect(stored?.updatedAt).toBeInstanceOf(Date);\n\t});\n\n\tit(\"sets welcomeModalSeenAt when welcomeModalSeen is true\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\tconst body = {\n\t\t\twelcomeModalSeen: true,\n\t\t\tshareConversationsWithModelAuthors: true,\n\t\t\tactiveModel: \"test-model\",\n\t\t\tcustomPrompts: {},\n\t\t\tmultimodalOverrides: {},\n\t\t\ttoolsOverrides: {},\n\t\t\tproviderOverrides: {},\n\t\t\tstreamingMode: \"smooth\",\n\t\t\tdirectPaste: false,\n\t\t\thidePromptExamples: {},\n\t\t};\n\n\t\tawait settingsPOST(\n\t\t\tmockRequestEvent(locals, {\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t})\n\t\t);\n\n\t\tconst stored = await collections.settings.findOne({ userId: user._id });\n\t\texpect(stored).not.toBeNull();\n\t\texpect(stored?.welcomeModalSeenAt).toBeInstanceOf(Date);\n\t});\n\n\tit(\"validates body with Zod and applies defaults for missing fields\", async () => {\n\t\tconst { user, locals } = await createTestUser();\n\n\t\t// POST with minimal body — Zod defaults should fill in the rest\n\t\tconst body = {};\n\n\t\tconst res = await settingsPOST(\n\t\t\tmockRequestEvent(locals, {\n\t\t\t\trequest: new Request(\"http://localhost\", {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t}),\n\t\t\t})\n\t\t);\n\n\t\texpect(res.status).toBe(200);\n\n\t\tconst stored = await collections.settings.findOne({ userId: user._id });\n\t\texpect(stored).not.toBeNull();\n\t\t// Zod defaults should be applied\n\t\texpect(stored?.shareConversationsWithModelAuthors).toBe(true);\n\t\texpect(stored?.streamingMode).toBe(\"smooth\");\n\t\texpect(stored?.directPaste).toBe(false);\n\t\texpect(stored?.customPrompts).toEqual({});\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/api/types.ts",
    "content": "import type { BackendModel } from \"$lib/server/models\";\n\nexport type GETModelsResponse = Array<{\n\tid: string;\n\tname: string;\n\twebsiteUrl?: string;\n\tmodelUrl?: string;\n\tdatasetName?: string;\n\tdatasetUrl?: string;\n\tdisplayName: string;\n\tdescription?: string;\n\tlogoUrl?: string;\n\tproviders?: Array<{ provider: string } & Record<string, unknown>>;\n\tpromptExamples?: { title: string; prompt: string }[];\n\tparameters: BackendModel[\"parameters\"];\n\tpreprompt?: string;\n\tmultimodal: boolean;\n\tmultimodalAcceptedMimetypes?: string[];\n\tsupportsTools?: boolean;\n\tunlisted: boolean;\n\thasInferenceAPI: boolean;\n\tisRouter: boolean;\n}>;\n\nexport type GETOldModelsResponse = Array<{\n\tid: string;\n\tname: string;\n\tdisplayName: string;\n\ttransferTo?: string;\n}>;\n\nexport interface FeatureFlags {\n\tenableAssistants: boolean;\n\tloginEnabled: boolean;\n\tisAdmin: boolean;\n\ttranscriptionEnabled: boolean;\n}\n"
  },
  {
    "path": "src/lib/server/api/utils/requireAuth.ts",
    "content": "import { error } from \"@sveltejs/kit\";\n\n/**\n * Throws 401 if neither a user._id nor sessionId is present in locals.\n */\nexport function requireAuth(locals: App.Locals): void {\n\tif (!locals.user?._id && !locals.sessionId) {\n\t\terror(401, \"Must have a valid session or user\");\n\t}\n}\n\n/**\n * Throws 401 if no user/session, 403 if not admin.\n */\nexport function requireAdmin(locals: App.Locals): void {\n\tif (!locals.user && !locals.sessionId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\tif (!locals.isAdmin) {\n\t\terror(403, \"Admin privileges required\");\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/api/utils/resolveConversation.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { convertLegacyConversation } from \"$lib/utils/tree/convertLegacyConversation\";\nimport { error } from \"@sveltejs/kit\";\n\n/**\n * Resolve a conversation by ID.\n * - 7-char IDs → shared conversation lookup\n * - ObjectId strings → owned conversation lookup with auth check\n *\n * Returns the conversation with legacy fields converted and a `shared` flag.\n */\nexport async function resolveConversation(\n\tid: string,\n\tlocals: App.Locals,\n\tfromShare?: string | null\n) {\n\tlet conversation;\n\tlet shared = false;\n\n\tif (id.length === 7) {\n\t\t// shared link of length 7\n\t\tconversation = await collections.sharedConversations.findOne({\n\t\t\t_id: id,\n\t\t});\n\t\tshared = true;\n\t\tif (!conversation) {\n\t\t\terror(404, \"Conversation not found\");\n\t\t}\n\t} else {\n\t\ttry {\n\t\t\tnew ObjectId(id);\n\t\t} catch {\n\t\t\terror(400, \"Invalid conversation ID format\");\n\t\t}\n\n\t\tconversation = await collections.conversations.findOne({\n\t\t\t_id: new ObjectId(id),\n\t\t\t...authCondition(locals),\n\t\t});\n\n\t\tif (!conversation) {\n\t\t\tconst conversationExists =\n\t\t\t\t(await collections.conversations.countDocuments({\n\t\t\t\t\t_id: new ObjectId(id),\n\t\t\t\t})) !== 0;\n\n\t\t\tif (conversationExists) {\n\t\t\t\terror(\n\t\t\t\t\t403,\n\t\t\t\t\t\"You don't have access to this conversation. If someone gave you this link, ask them to use the 'share' feature instead.\"\n\t\t\t\t);\n\t\t\t}\n\n\t\t\terror(404, \"Conversation not found.\");\n\t\t}\n\n\t\tif (fromShare && conversation.meta?.fromShareId === fromShare) {\n\t\t\tshared = true;\n\t\t}\n\t}\n\n\treturn {\n\t\t...conversation,\n\t\t...convertLegacyConversation(conversation),\n\t\tshared,\n\t};\n}\n"
  },
  {
    "path": "src/lib/server/api/utils/resolveModel.ts",
    "content": "import { error } from \"@sveltejs/kit\";\n\n/**\n * Resolve a model by namespace and optional model name.\n * Looks up in the models registry and returns the model, or throws 404 if not found or unlisted.\n */\nexport async function resolveModel(namespace: string, model?: string) {\n\tlet modelId = namespace;\n\tif (model) {\n\t\tmodelId += \"/\" + model;\n\t}\n\n\ttry {\n\t\tconst { models } = await import(\"$lib/server/models\");\n\t\tconst found = models.find((m) => m.id === modelId);\n\t\tif (!found || found.unlisted) {\n\t\t\terror(404, \"Model not found\");\n\t\t}\n\t\treturn found;\n\t} catch (e) {\n\t\t// Re-throw SvelteKit HttpErrors\n\t\tif (e && typeof e === \"object\" && \"status\" in e) {\n\t\t\tthrow e;\n\t\t}\n\t\terror(500, \"Models not available\");\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/api/utils/superjsonResponse.ts",
    "content": "import superjson from \"superjson\";\n\n/**\n * Create a JSON response serialized with superjson.\n * Matches the wire format of the former Elysia `mapResponse` hook.\n */\nexport function superjsonResponse(data: unknown, init?: ResponseInit): Response {\n\treturn new Response(superjson.stringify(data), {\n\t\t...init,\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t...init?.headers,\n\t\t},\n\t});\n}\n"
  },
  {
    "path": "src/lib/server/apiToken.ts",
    "content": "import { config } from \"$lib/server/config\";\n\nexport function getApiToken(locals: App.Locals | undefined) {\n\tif (config.USE_USER_TOKEN === \"true\") {\n\t\tif (!locals?.token) {\n\t\t\tthrow new Error(\"User token not found\");\n\t\t}\n\t\treturn locals.token;\n\t}\n\treturn config.OPENAI_API_KEY || config.HF_TOKEN;\n}\n"
  },
  {
    "path": "src/lib/server/auth.ts",
    "content": "import {\n\tIssuer,\n\ttype BaseClient,\n\ttype UserinfoResponse,\n\ttype TokenSet,\n\tcustom,\n\tgenerators,\n} from \"openid-client\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { addHours, addWeeks, differenceInMinutes, subMinutes } from \"date-fns\";\nimport { config } from \"$lib/server/config\";\nimport { sha256 } from \"$lib/utils/sha256\";\nimport { z } from \"zod\";\nimport { dev } from \"$app/environment\";\nimport { redirect, type Cookies } from \"@sveltejs/kit\";\nimport { collections } from \"$lib/server/database\";\nimport JSON5 from \"json5\";\nimport { logger } from \"$lib/server/logger\";\nimport { ObjectId } from \"mongodb\";\nimport { adminTokenManager } from \"./adminToken\";\nimport type { User } from \"$lib/types/User\";\nimport type { Session } from \"$lib/types/Session\";\nimport { base } from \"$app/paths\";\nimport { acquireLock, isDBLocked, releaseLock } from \"$lib/migrations/lock\";\nimport { Semaphores } from \"$lib/types/Semaphore\";\n\nexport interface OIDCSettings {\n\tredirectURI: string;\n}\n\nexport interface OIDCUserInfo {\n\ttoken: TokenSet;\n\tuserData: UserinfoResponse;\n}\n\nconst stringWithDefault = (value: string) =>\n\tz\n\t\t.string()\n\t\t.default(value)\n\t\t.transform((el) => (el ? el : value));\n\nexport const OIDConfig = z\n\t.object({\n\t\tCLIENT_ID: stringWithDefault(config.OPENID_CLIENT_ID),\n\t\tCLIENT_SECRET: stringWithDefault(config.OPENID_CLIENT_SECRET),\n\t\tPROVIDER_URL: stringWithDefault(config.OPENID_PROVIDER_URL),\n\t\tSCOPES: stringWithDefault(config.OPENID_SCOPES),\n\t\tNAME_CLAIM: stringWithDefault(config.OPENID_NAME_CLAIM).refine(\n\t\t\t(el) => ![\"preferred_username\", \"email\", \"picture\", \"sub\"].includes(el),\n\t\t\t{ message: \"nameClaim cannot be one of the restricted keys.\" }\n\t\t),\n\t\tTOLERANCE: stringWithDefault(config.OPENID_TOLERANCE),\n\t\tRESOURCE: stringWithDefault(config.OPENID_RESOURCE),\n\t\tID_TOKEN_SIGNED_RESPONSE_ALG: z.string().optional(),\n\t})\n\t.parse(JSON5.parse(config.OPENID_CONFIG || \"{}\"));\n\nexport const loginEnabled = !!OIDConfig.CLIENT_ID;\n\nconst sameSite = z\n\t.enum([\"lax\", \"none\", \"strict\"])\n\t.default(dev || config.ALLOW_INSECURE_COOKIES === \"true\" ? \"lax\" : \"none\")\n\t.parse(config.COOKIE_SAMESITE === \"\" ? undefined : config.COOKIE_SAMESITE);\n\nconst secure = z\n\t.boolean()\n\t.default(!(dev || config.ALLOW_INSECURE_COOKIES === \"true\"))\n\t.parse(config.COOKIE_SECURE === \"\" ? undefined : config.COOKIE_SECURE === \"true\");\n\nfunction sanitizeReturnPath(path: string | undefined | null): string | undefined {\n\tif (!path) {\n\t\treturn undefined;\n\t}\n\tif (path.startsWith(\"//\")) {\n\t\treturn undefined;\n\t}\n\tif (!path.startsWith(\"/\")) {\n\t\treturn undefined;\n\t}\n\treturn path;\n}\n\nexport function refreshSessionCookie(cookies: Cookies, sessionId: string) {\n\tcookies.set(config.COOKIE_NAME, sessionId, {\n\t\tpath: \"/\",\n\t\t// So that it works inside the space's iframe\n\t\tsameSite,\n\t\tsecure,\n\t\thttpOnly: true,\n\t\texpires: addWeeks(new Date(), 2),\n\t});\n}\n\nexport async function findUser(\n\tsessionId: string,\n\tcoupledCookieHash: string | undefined,\n\turl: URL\n): Promise<{\n\tuser: User | null;\n\tinvalidateSession: boolean;\n\toauth?: Session[\"oauth\"];\n}> {\n\tconst session = await collections.sessions.findOne({ sessionId });\n\n\tif (!session) {\n\t\treturn { user: null, invalidateSession: false };\n\t}\n\n\tif (coupledCookieHash && session.coupledCookieHash !== coupledCookieHash) {\n\t\treturn { user: null, invalidateSession: true };\n\t}\n\n\t// Check if OAuth token needs refresh\n\tif (session.oauth?.token && session.oauth.refreshToken) {\n\t\t// If token expires in less than 5 minutes, refresh it\n\t\tif (differenceInMinutes(session.oauth.token.expiresAt, new Date()) < 5) {\n\t\t\tconst lockKey = `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`;\n\n\t\t\t// Acquire lock for token refresh\n\t\t\tconst lockId = await acquireLock(lockKey);\n\t\t\tif (lockId) {\n\t\t\t\ttry {\n\t\t\t\t\t// Attempt to refresh the token\n\t\t\t\t\tconst newTokenSet = await refreshOAuthToken(\n\t\t\t\t\t\t{ redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` },\n\t\t\t\t\t\tsession.oauth.refreshToken,\n\t\t\t\t\t\turl\n\t\t\t\t\t);\n\n\t\t\t\t\tif (!newTokenSet || !newTokenSet.access_token) {\n\t\t\t\t\t\t// Token refresh failed, invalidate session\n\t\t\t\t\t\treturn { user: null, invalidateSession: true };\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update session with new token information\n\t\t\t\t\tconst updatedOAuth = tokenSetToSessionOauth(newTokenSet);\n\n\t\t\t\t\tif (!updatedOAuth) {\n\t\t\t\t\t\t// Token refresh failed, invalidate session\n\t\t\t\t\t\treturn { user: null, invalidateSession: true };\n\t\t\t\t\t}\n\n\t\t\t\t\tawait collections.sessions.updateOne(\n\t\t\t\t\t\t{ sessionId },\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t$set: {\n\t\t\t\t\t\t\t\toauth: updatedOAuth,\n\t\t\t\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t);\n\n\t\t\t\t\tsession.oauth = updatedOAuth;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tlogger.error(err, \"Error during token refresh:\");\n\t\t\t\t\treturn { user: null, invalidateSession: true };\n\t\t\t\t} finally {\n\t\t\t\t\tawait releaseLock(lockKey, lockId);\n\t\t\t\t}\n\t\t\t} else if (new Date() > session.oauth.token.expiresAt) {\n\t\t\t\t// If the token has expired, we need to wait for the token refresh to complete\n\t\t\t\tlet attempts = 0;\n\t\t\t\tdo {\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 200));\n\t\t\t\t\tattempts++;\n\t\t\t\t\tif (attempts > 20) {\n\t\t\t\t\t\treturn { user: null, invalidateSession: true };\n\t\t\t\t\t}\n\t\t\t\t} while (await isDBLocked(lockKey));\n\n\t\t\t\tconst updatedSession = await collections.sessions.findOne({ sessionId });\n\t\t\t\tif (!updatedSession || updatedSession.oauth?.token === session.oauth.token) {\n\t\t\t\t\treturn { user: null, invalidateSession: true };\n\t\t\t\t}\n\n\t\t\t\tsession.oauth = updatedSession.oauth;\n\t\t\t}\n\t\t}\n\t} else if (session.oauth?.token && !session.oauth.refreshToken) {\n\t\tif (new Date() > session.oauth.token.expiresAt) {\n\t\t\treturn { user: null, invalidateSession: true };\n\t\t}\n\t}\n\n\treturn {\n\t\tuser: await collections.users.findOne({ _id: session.userId }),\n\t\tinvalidateSession: false,\n\t\toauth: session.oauth,\n\t};\n}\nexport const authCondition = (locals: App.Locals) => {\n\tif (!locals.user && !locals.sessionId) {\n\t\tthrow new Error(\"User or sessionId is required\");\n\t}\n\n\treturn locals.user\n\t\t? { userId: locals.user._id }\n\t\t: { sessionId: locals.sessionId, userId: { $exists: false } };\n};\n\nexport function tokenSetToSessionOauth(tokenSet: TokenSet): Session[\"oauth\"] {\n\tif (!tokenSet.access_token) {\n\t\treturn undefined;\n\t}\n\n\treturn {\n\t\ttoken: {\n\t\t\tvalue: tokenSet.access_token,\n\t\t\texpiresAt: tokenSet.expires_at\n\t\t\t\t? subMinutes(new Date(tokenSet.expires_at * 1000), 1)\n\t\t\t\t: addWeeks(new Date(), 2),\n\t\t},\n\t\trefreshToken: tokenSet.refresh_token || undefined,\n\t};\n}\n\n/**\n * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.\n */\nexport async function generateCsrfToken(\n\tsessionId: string,\n\tredirectUrl: string,\n\tnext?: string\n): Promise<string> {\n\tconst sanitizedNext = sanitizeReturnPath(next);\n\tconst data = {\n\t\texpiration: addHours(new Date(), 1).getTime(),\n\t\tredirectUrl,\n\t\t...(sanitizedNext ? { next: sanitizedNext } : {}),\n\t} as {\n\t\texpiration: number;\n\t\tredirectUrl: string;\n\t\tnext?: string;\n\t};\n\n\treturn Buffer.from(\n\t\tJSON.stringify({\n\t\t\tdata,\n\t\t\tsignature: await sha256(JSON.stringify(data) + \"##\" + sessionId),\n\t\t})\n\t).toString(\"base64\");\n}\n\nlet lastIssuer: Issuer<BaseClient> | null = null;\nlet lastIssuerFetchedAt: Date | null = null;\nasync function getOIDCClient(settings: OIDCSettings, url: URL): Promise<BaseClient> {\n\tif (\n\t\tlastIssuer &&\n\t\tlastIssuerFetchedAt &&\n\t\tdifferenceInMinutes(new Date(), lastIssuerFetchedAt) >= 10\n\t) {\n\t\tlastIssuer = null;\n\t\tlastIssuerFetchedAt = null;\n\t}\n\tif (!lastIssuer) {\n\t\tlastIssuer = await Issuer.discover(OIDConfig.PROVIDER_URL);\n\t\tlastIssuerFetchedAt = new Date();\n\t}\n\n\tconst issuer = lastIssuer;\n\n\tconst client_config: ConstructorParameters<typeof issuer.Client>[0] = {\n\t\tclient_id: OIDConfig.CLIENT_ID,\n\t\tclient_secret: OIDConfig.CLIENT_SECRET,\n\t\tredirect_uris: [settings.redirectURI],\n\t\tresponse_types: [\"code\"],\n\t\t[custom.clock_tolerance]: OIDConfig.TOLERANCE || undefined,\n\t\tid_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined,\n\t};\n\n\tif (OIDConfig.CLIENT_ID === \"__CIMD__\") {\n\t\t// See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/\n\t\tclient_config.client_id = new URL(\n\t\t\t`${base}/.well-known/oauth-cimd`,\n\t\t\tconfig.PUBLIC_ORIGIN || url.origin\n\t\t).toString();\n\t}\n\n\tconst alg_supported = issuer.metadata[\"id_token_signing_alg_values_supported\"];\n\n\tif (Array.isArray(alg_supported)) {\n\t\tclient_config.id_token_signed_response_alg ??= alg_supported[0];\n\t}\n\n\treturn new issuer.Client(client_config);\n}\n\nexport async function getOIDCAuthorizationUrl(\n\tsettings: OIDCSettings,\n\tparams: { sessionId: string; next?: string; url: URL; cookies: Cookies }\n): Promise<string> {\n\tconst client = await getOIDCClient(settings, params.url);\n\tconst csrfToken = await generateCsrfToken(\n\t\tparams.sessionId,\n\t\tsettings.redirectURI,\n\t\tsanitizeReturnPath(params.next)\n\t);\n\n\tconst codeVerifier = generators.codeVerifier();\n\tconst codeChallenge = generators.codeChallenge(codeVerifier);\n\n\tparams.cookies.set(\"hfChat-codeVerifier\", codeVerifier, {\n\t\tpath: \"/\",\n\t\tsameSite,\n\t\tsecure,\n\t\thttpOnly: true,\n\t\texpires: addHours(new Date(), 1),\n\t});\n\n\treturn client.authorizationUrl({\n\t\tcode_challenge_method: \"S256\",\n\t\tcode_challenge: codeChallenge,\n\t\tscope: OIDConfig.SCOPES,\n\t\tstate: csrfToken,\n\t\tresource: OIDConfig.RESOURCE || undefined,\n\t});\n}\n\nexport async function getOIDCUserData(\n\tsettings: OIDCSettings,\n\tcode: string,\n\tcodeVerifier: string,\n\tiss: string | undefined,\n\turl: URL\n): Promise<OIDCUserInfo> {\n\tconst client = await getOIDCClient(settings, url);\n\tconst token = await client.callback(\n\t\tsettings.redirectURI,\n\t\t{\n\t\t\tcode,\n\t\t\tiss,\n\t\t},\n\t\t{ code_verifier: codeVerifier }\n\t);\n\tconst userData = await client.userinfo(token);\n\n\treturn { token, userData };\n}\n\n/**\n * Refreshes an OAuth token using the refresh token\n */\nexport async function refreshOAuthToken(\n\tsettings: OIDCSettings,\n\trefreshToken: string,\n\turl: URL\n): Promise<TokenSet | null> {\n\tconst client = await getOIDCClient(settings, url);\n\tconst tokenSet = await client.refresh(refreshToken);\n\treturn tokenSet;\n}\n\nexport async function validateAndParseCsrfToken(\n\ttoken: string,\n\tsessionId: string\n): Promise<{\n\t/** This is the redirect url that was passed to the OIDC provider */\n\tredirectUrl: string;\n\t/** Relative path (within this app) to return to after login */\n\tnext?: string;\n} | null> {\n\ttry {\n\t\tconst { data, signature } = z\n\t\t\t.object({\n\t\t\t\tdata: z.object({\n\t\t\t\t\texpiration: z.number().int(),\n\t\t\t\t\tredirectUrl: z.string().url(),\n\t\t\t\t\tnext: z.string().optional(),\n\t\t\t\t}),\n\t\t\t\tsignature: z.string().length(64),\n\t\t\t})\n\t\t\t.parse(JSON.parse(token));\n\n\t\tconst reconstructSign = await sha256(JSON.stringify(data) + \"##\" + sessionId);\n\n\t\tif (data.expiration > Date.now() && signature === reconstructSign) {\n\t\t\treturn { redirectUrl: data.redirectUrl, next: sanitizeReturnPath(data.next) };\n\t\t}\n\t} catch (e) {\n\t\tlogger.error(e, \"Error validating and parsing CSRF token\");\n\t}\n\treturn null;\n}\n\ntype CookieRecord = Cookies;\ntype HeaderRecord = Headers;\n\nexport async function getCoupledCookieHash(cookie: CookieRecord): Promise<string | undefined> {\n\tif (!config.COUPLE_SESSION_WITH_COOKIE_NAME) {\n\t\treturn undefined;\n\t}\n\n\tconst cookieValue = cookie.get(config.COUPLE_SESSION_WITH_COOKIE_NAME);\n\n\tif (!cookieValue) {\n\t\treturn \"no-cookie\";\n\t}\n\n\treturn await sha256(cookieValue);\n}\n\nexport async function authenticateRequest(\n\theaders: HeaderRecord,\n\tcookie: CookieRecord,\n\turl: URL,\n\tisApi?: boolean\n): Promise<App.Locals & { secretSessionId: string }> {\n\tconst token = cookie.get(config.COOKIE_NAME);\n\n\tlet email = null;\n\tif (config.TRUSTED_EMAIL_HEADER) {\n\t\temail = headers.get(config.TRUSTED_EMAIL_HEADER);\n\t}\n\n\tlet secretSessionId: string | null = null;\n\tlet sessionId: string | null = null;\n\n\tif (email) {\n\t\tsecretSessionId = sessionId = await sha256(email);\n\t\treturn {\n\t\t\tuser: {\n\t\t\t\t_id: new ObjectId(sessionId.slice(0, 24)),\n\t\t\t\tname: email,\n\t\t\t\temail,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\thfUserId: email,\n\t\t\t\tavatarUrl: \"\",\n\t\t\t},\n\t\t\tsessionId,\n\t\t\tsecretSessionId,\n\t\t\tisAdmin: adminTokenManager.isAdmin(sessionId),\n\t\t};\n\t}\n\n\tif (token) {\n\t\tsecretSessionId = token;\n\t\tsessionId = await sha256(token);\n\n\t\tconst result = await findUser(sessionId, await getCoupledCookieHash(cookie), url);\n\n\t\tif (result.invalidateSession) {\n\t\t\tsecretSessionId = crypto.randomUUID();\n\t\t\tsessionId = await sha256(secretSessionId);\n\n\t\t\tif (await collections.sessions.findOne({ sessionId })) {\n\t\t\t\tthrow new Error(\"Session ID collision\");\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\tuser: result.user ?? undefined,\n\t\t\ttoken: result.oauth?.token?.value,\n\t\t\tsessionId,\n\t\t\tsecretSessionId,\n\t\t\tisAdmin: result.user?.isAdmin || adminTokenManager.isAdmin(sessionId),\n\t\t};\n\t}\n\n\tif (isApi) {\n\t\tconst authorization = headers.get(\"Authorization\");\n\t\tif (authorization?.startsWith(\"Bearer \")) {\n\t\t\tconst token = authorization.slice(7);\n\t\t\tconst hash = await sha256(token);\n\t\t\tsessionId = secretSessionId = hash;\n\n\t\t\tconst cacheHit = await collections.tokenCaches.findOne({ tokenHash: hash });\n\t\t\tif (cacheHit) {\n\t\t\t\tconst user = await collections.users.findOne({ hfUserId: cacheHit.userId });\n\t\t\t\tif (!user) {\n\t\t\t\t\tthrow new Error(\"User not found\");\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\tuser,\n\t\t\t\t\tsessionId,\n\t\t\t\t\ttoken,\n\t\t\t\t\tsecretSessionId,\n\t\t\t\t\tisAdmin: user.isAdmin || adminTokenManager.isAdmin(sessionId),\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst response = await fetch(\"https://huggingface.co/api/whoami-v2\", {\n\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(\"Unauthorized\");\n\t\t\t}\n\n\t\t\tconst data = await response.json();\n\t\t\tconst user = await collections.users.findOne({ hfUserId: data.id });\n\t\t\tif (!user) {\n\t\t\t\tthrow new Error(\"User not found\");\n\t\t\t}\n\n\t\t\tawait collections.tokenCaches.insertOne({\n\t\t\t\ttokenHash: hash,\n\t\t\t\tuserId: data.id,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t});\n\n\t\t\treturn {\n\t\t\t\tuser,\n\t\t\t\tsessionId,\n\t\t\t\tsecretSessionId,\n\t\t\t\ttoken,\n\t\t\t\tisAdmin: user.isAdmin || adminTokenManager.isAdmin(sessionId),\n\t\t\t};\n\t\t}\n\t}\n\n\t// Generate new session if none exists\n\tsecretSessionId = crypto.randomUUID();\n\tsessionId = await sha256(secretSessionId);\n\n\tif (await collections.sessions.findOne({ sessionId })) {\n\t\tthrow new Error(\"Session ID collision\");\n\t}\n\n\treturn { user: undefined, sessionId, secretSessionId, isAdmin: false };\n}\n\nexport async function triggerOauthFlow({ url, locals, cookies }: RequestEvent): Promise<Response> {\n\t// const referer = request.headers.get(\"referer\");\n\t// let redirectURI = `${(referer ? new URL(referer) : url).origin}${base}/login/callback`;\n\tlet redirectURI = `${url.origin}${base}/login/callback`;\n\n\t// TODO: Handle errors if provider is not responding\n\n\tif (url.searchParams.has(\"callback\")) {\n\t\tconst callback = url.searchParams.get(\"callback\") || redirectURI;\n\t\tif (config.ALTERNATIVE_REDIRECT_URLS.includes(callback)) {\n\t\t\tredirectURI = callback;\n\t\t}\n\t}\n\n\t// Preserve a safe in-app return path after login.\n\t// Priority: explicit ?next=... (must be an absolute path), else the current path (when auto-login kicks in).\n\tlet next: string | undefined = undefined;\n\tconst nextParam = sanitizeReturnPath(url.searchParams.get(\"next\"));\n\tif (nextParam) {\n\t\t// Only accept absolute in-app paths to prevent open redirects\n\t\tnext = nextParam;\n\t} else if (!url.pathname.startsWith(`${base}/login`)) {\n\t\t// For automatic login on protected pages, return to the page the user was on\n\t\tnext = sanitizeReturnPath(`${url.pathname}${url.search}`) ?? `${base}/`;\n\t} else {\n\t\tnext = sanitizeReturnPath(`${base}/`) ?? \"/\";\n\t}\n\n\tconst authorizationUrl = await getOIDCAuthorizationUrl(\n\t\t{ redirectURI },\n\t\t{ sessionId: locals.sessionId, next, url, cookies }\n\t);\n\n\tthrow redirect(302, authorizationUrl);\n}\n"
  },
  {
    "path": "src/lib/server/config.ts",
    "content": "import { env as publicEnv } from \"$env/dynamic/public\";\nimport { env as serverEnv } from \"$env/dynamic/private\";\nimport { building } from \"$app/environment\";\nimport type { Collection } from \"mongodb\";\nimport type { ConfigKey as ConfigKeyType } from \"$lib/types/ConfigKey\";\nimport type { Semaphore } from \"$lib/types/Semaphore\";\nimport { Semaphores } from \"$lib/types/Semaphore\";\n\nexport type PublicConfigKey = keyof typeof publicEnv;\nconst keysFromEnv = { ...publicEnv, ...serverEnv };\nexport type ConfigKey = keyof typeof keysFromEnv;\n\nclass ConfigManager {\n\tprivate keysFromDB: Partial<Record<ConfigKey, string>> = {};\n\tprivate isInitialized = false;\n\n\tprivate configCollection: Collection<ConfigKeyType> | undefined;\n\tprivate semaphoreCollection: Collection<Semaphore> | undefined;\n\tprivate lastConfigUpdate: Date | undefined;\n\n\tasync init() {\n\t\tif (this.isInitialized) return;\n\n\t\tif (building || import.meta.env.MODE === \"test\") {\n\t\t\tthis.isInitialized = true;\n\t\t\treturn;\n\t\t}\n\n\t\tconst { getCollectionsEarly } = await import(\"./database\");\n\t\tconst collections = await getCollectionsEarly();\n\n\t\tthis.configCollection = collections.config;\n\t\tthis.semaphoreCollection = collections.semaphores;\n\n\t\tawait this.checkForUpdates().then(() => {\n\t\t\tthis.isInitialized = true;\n\t\t});\n\t}\n\n\tget ConfigManagerEnabled() {\n\t\treturn serverEnv.ENABLE_CONFIG_MANAGER === \"true\" && import.meta.env.MODE !== \"test\";\n\t}\n\n\tget isHuggingChat() {\n\t\treturn this.get(\"PUBLIC_APP_ASSETS\") === \"huggingchat\";\n\t}\n\n\tasync checkForUpdates() {\n\t\tif (await this.isConfigStale()) {\n\t\t\tawait this.updateConfig();\n\t\t}\n\t}\n\n\tasync isConfigStale(): Promise<boolean> {\n\t\tif (!this.lastConfigUpdate || !this.isInitialized) {\n\t\t\treturn true;\n\t\t}\n\t\tconst count = await this.semaphoreCollection?.countDocuments({\n\t\t\tkey: Semaphores.CONFIG_UPDATE,\n\t\t\tupdatedAt: { $gt: this.lastConfigUpdate },\n\t\t});\n\t\treturn count !== undefined && count > 0;\n\t}\n\n\tasync updateConfig() {\n\t\tconst configs = (await this.configCollection?.find({}).toArray()) ?? [];\n\t\tthis.keysFromDB = configs.reduce(\n\t\t\t(acc, curr) => {\n\t\t\t\tacc[curr.key as ConfigKey] = curr.value;\n\t\t\t\treturn acc;\n\t\t\t},\n\t\t\t{} as Record<ConfigKey, string>\n\t\t);\n\n\t\tthis.lastConfigUpdate = new Date();\n\t}\n\n\tget(key: ConfigKey): string {\n\t\tif (!this.ConfigManagerEnabled) {\n\t\t\treturn keysFromEnv[key] || \"\";\n\t\t}\n\t\treturn this.keysFromDB[key] || keysFromEnv[key] || \"\";\n\t}\n\n\tasync updateSemaphore() {\n\t\tawait this.semaphoreCollection?.updateOne(\n\t\t\t{ key: Semaphores.CONFIG_UPDATE },\n\t\t\t{\n\t\t\t\t$set: {\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t},\n\t\t\t\t$setOnInsert: {\n\t\t\t\t\tcreatedAt: new Date(),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{ upsert: true }\n\t\t);\n\t}\n\n\tasync set(key: ConfigKey, value: string) {\n\t\tif (!this.ConfigManagerEnabled) throw new Error(\"Config manager is disabled\");\n\t\tawait this.configCollection?.updateOne({ key }, { $set: { value } }, { upsert: true });\n\t\tthis.keysFromDB[key] = value;\n\t\tawait this.updateSemaphore();\n\t}\n\n\tasync delete(key: ConfigKey) {\n\t\tif (!this.ConfigManagerEnabled) throw new Error(\"Config manager is disabled\");\n\t\tawait this.configCollection?.deleteOne({ key });\n\t\tdelete this.keysFromDB[key];\n\t\tawait this.updateSemaphore();\n\t}\n\n\tasync clear() {\n\t\tif (!this.ConfigManagerEnabled) throw new Error(\"Config manager is disabled\");\n\t\tawait this.configCollection?.deleteMany({});\n\t\tthis.keysFromDB = {};\n\t\tawait this.updateSemaphore();\n\t}\n\n\tgetPublicConfig() {\n\t\tlet config = {\n\t\t\t...Object.fromEntries(\n\t\t\t\tObject.entries(keysFromEnv).filter(([key]) => key.startsWith(\"PUBLIC_\"))\n\t\t\t),\n\t\t} as Record<PublicConfigKey, string>;\n\n\t\tif (this.ConfigManagerEnabled) {\n\t\t\tconfig = {\n\t\t\t\t...config,\n\t\t\t\t...Object.fromEntries(\n\t\t\t\t\tObject.entries(this.keysFromDB).filter(([key]) => key.startsWith(\"PUBLIC_\"))\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\n\t\tconst publicEnvKeys = Object.keys(publicEnv);\n\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(config).filter(([key]) => publicEnvKeys.includes(key))\n\t\t) as Record<PublicConfigKey, string>;\n\t}\n}\n\n// Create the instance and initialize it.\nconst configManager = new ConfigManager();\n\nexport const ready = (async () => {\n\tif (!building) {\n\t\tawait configManager.init();\n\t}\n})();\n\ntype ExtraConfigKeys =\n\t| \"HF_TOKEN\"\n\t| \"OLD_MODELS\"\n\t| \"ENABLE_ASSISTANTS\"\n\t| \"METRICS_ENABLED\"\n\t| \"METRICS_PORT\"\n\t| \"MCP_SERVERS\"\n\t| \"MCP_FORWARD_HF_USER_TOKEN\"\n\t| \"MCP_TOOL_TIMEOUT_MS\"\n\t| \"EXA_API_KEY\";\n\ntype ConfigProxy = ConfigManager & { [K in ConfigKey | ExtraConfigKeys]: string };\n\nexport const config: ConfigProxy = new Proxy(configManager, {\n\tget(target, prop, receiver) {\n\t\tif (prop in target) {\n\t\t\treturn Reflect.get(target, prop, receiver);\n\t\t}\n\t\tif (typeof prop === \"string\") {\n\t\t\treturn target.get(prop as ConfigKey);\n\t\t}\n\t\treturn undefined;\n\t},\n\tset(target, prop, value, receiver) {\n\t\tif (prop in target) {\n\t\t\treturn Reflect.set(target, prop, value, receiver);\n\t\t}\n\t\tif (typeof prop === \"string\") {\n\t\t\ttarget.set(prop as ConfigKey, value);\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t},\n}) as ConfigProxy;\n"
  },
  {
    "path": "src/lib/server/conversation.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { MetricsServer } from \"$lib/server/metrics\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\nimport { authCondition } from \"$lib/server/auth\";\n\n/**\n * Create a new conversation from a shared conversation ID.\n * If the conversation already exists for the user/session, return the existing conversation ID.\n * returns the conversation ID.\n */\nexport async function createConversationFromShare(\n\tfromShareId: string,\n\tlocals: App.Locals,\n\tuserAgent?: string\n): Promise<string> {\n\tconst conversation = await collections.sharedConversations.findOne({\n\t\t_id: fromShareId,\n\t});\n\n\tif (!conversation) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\t// Check if shared conversation exists already for this user/session\n\tconst existingConversation = await collections.conversations.findOne({\n\t\t\"meta.fromShareId\": fromShareId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (existingConversation) {\n\t\treturn existingConversation._id.toString();\n\t}\n\n\t// Create new conversation from shared conversation\n\tconst res = await collections.conversations.insertOne({\n\t\t_id: new ObjectId(),\n\t\ttitle: conversation.title.replace(/<\\/?think>/gi, \"\").trim(),\n\t\trootMessageId: conversation.rootMessageId,\n\t\tmessages: conversation.messages,\n\t\tmodel: conversation.model,\n\t\tpreprompt: conversation.preprompt,\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\tuserAgent,\n\t\t...(locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId }),\n\t\tmeta: { fromShareId },\n\t});\n\n\t// Copy files from shared conversation bucket entries to the new conversation\n\t// Shared files are stored with filenames \"${sharedId}-${sha}\" and metadata.conversation = sharedId\n\t// New conversation expects files to be stored under its own id prefix\n\tconst newConvId = res.insertedId.toString();\n\tconst sharedId = fromShareId;\n\tconst files = await collections.bucket.find({ filename: { $regex: `^${sharedId}-` } }).toArray();\n\n\tawait Promise.all(\n\t\tfiles.map(\n\t\t\t(file) =>\n\t\t\t\tnew Promise<void>((resolve, reject) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst newFilename = file.filename.replace(`${sharedId}-`, `${newConvId}-`);\n\t\t\t\t\t\tconst downloadStream = collections.bucket.openDownloadStream(file._id);\n\t\t\t\t\t\tconst uploadStream = collections.bucket.openUploadStream(newFilename, {\n\t\t\t\t\t\t\tmetadata: { ...file.metadata, conversation: newConvId },\n\t\t\t\t\t\t});\n\t\t\t\t\t\tdownloadStream\n\t\t\t\t\t\t\t.on(\"error\", reject)\n\t\t\t\t\t\t\t.pipe(uploadStream)\n\t\t\t\t\t\t\t.on(\"error\", reject)\n\t\t\t\t\t\t\t.on(\"finish\", () => resolve());\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\treject(e);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t)\n\t);\n\n\tif (MetricsServer.isEnabled()) {\n\t\tMetricsServer.getMetrics().model.conversationsTotal.inc({ model: conversation.model });\n\t}\n\treturn res.insertedId.toString();\n}\n"
  },
  {
    "path": "src/lib/server/database.ts",
    "content": "import { GridFSBucket, MongoClient, ReadPreference } from \"mongodb\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport type { SharedConversation } from \"$lib/types/SharedConversation\";\nimport type { AbortedGeneration } from \"$lib/types/AbortedGeneration\";\nimport type { Settings } from \"$lib/types/Settings\";\nimport type { User } from \"$lib/types/User\";\nimport type { MessageEvent } from \"$lib/types/MessageEvent\";\nimport type { Session } from \"$lib/types/Session\";\nimport type { Assistant } from \"$lib/types/Assistant\";\nimport type { Report } from \"$lib/types/Report\";\nimport type { ConversationStats } from \"$lib/types/ConversationStats\";\nimport type { MigrationResult } from \"$lib/types/MigrationResult\";\nimport type { Semaphore } from \"$lib/types/Semaphore\";\nimport type { AssistantStats } from \"$lib/types/AssistantStats\";\nimport { MongoMemoryServer } from \"mongodb-memory-server\";\nimport { logger } from \"$lib/server/logger\";\nimport { building } from \"$app/environment\";\nimport type { TokenCache } from \"$lib/types/TokenCache\";\nimport { onExit } from \"./exitHandler\";\nimport { fileURLToPath } from \"url\";\nimport { dirname, join } from \"path\";\nimport { existsSync, mkdirSync } from \"fs\";\nimport { findRepoRoot } from \"./findRepoRoot\";\nimport type { ConfigKey } from \"$lib/types/ConfigKey\";\nimport { config } from \"$lib/server/config\";\n\nexport const CONVERSATION_STATS_COLLECTION = \"conversations.stats\";\n\nexport class Database {\n\tprivate client?: MongoClient;\n\tprivate mongoServer?: MongoMemoryServer;\n\n\tprivate static instance: Database;\n\n\tprivate async init() {\n\t\tconst DB_FOLDER =\n\t\t\tconfig.MONGO_STORAGE_PATH ||\n\t\t\tjoin(findRepoRoot(dirname(fileURLToPath(import.meta.url))), \"db\");\n\n\t\tif (!config.MONGODB_URL) {\n\t\t\tlogger.warn(\"No MongoDB URL found, using in-memory server\");\n\n\t\t\tlogger.info(`Using database path: ${DB_FOLDER}`);\n\t\t\t// Create db directory if it doesn't exist\n\t\t\tif (!existsSync(DB_FOLDER)) {\n\t\t\t\tlogger.info(`Creating database directory at ${DB_FOLDER}`);\n\t\t\t\tmkdirSync(DB_FOLDER, { recursive: true });\n\t\t\t}\n\n\t\t\tthis.mongoServer = await MongoMemoryServer.create({\n\t\t\t\tinstance: {\n\t\t\t\t\tdbName: config.MONGODB_DB_NAME + (import.meta.env.MODE === \"test\" ? \"-test\" : \"\"),\n\t\t\t\t\tdbPath: DB_FOLDER,\n\t\t\t\t},\n\t\t\t\tbinary: {\n\t\t\t\t\tversion: \"7.0.18\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tthis.client = new MongoClient(this.mongoServer.getUri(), {\n\t\t\t\tdirectConnection: config.MONGODB_DIRECT_CONNECTION === \"true\",\n\t\t\t});\n\t\t} else {\n\t\t\tthis.client = new MongoClient(config.MONGODB_URL, {\n\t\t\t\tdirectConnection: config.MONGODB_DIRECT_CONNECTION === \"true\",\n\t\t\t});\n\t\t}\n\n\t\ttry {\n\t\t\tlogger.info(\"Connecting to database\");\n\t\t\tawait this.client.connect();\n\t\t\tlogger.info(\"Connected to database\");\n\t\t\tthis.client.db(config.MONGODB_DB_NAME + (import.meta.env.MODE === \"test\" ? \"-test\" : \"\"));\n\t\t\tawait this.initDatabase();\n\t\t} catch (err) {\n\t\t\tlogger.error(err, \"Error connecting to database\");\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\t// Disconnect DB on exit\n\t\tonExit(async () => {\n\t\t\tlogger.info(\"Closing database connection\");\n\t\t\tawait this.client?.close(true);\n\t\t\tawait this.mongoServer?.stop();\n\t\t});\n\t}\n\n\tpublic static async getInstance(): Promise<Database> {\n\t\tif (!Database.instance) {\n\t\t\tDatabase.instance = new Database();\n\t\t\tawait Database.instance.init();\n\t\t}\n\n\t\treturn Database.instance;\n\t}\n\n\t/**\n\t * Return mongoClient\n\t */\n\tpublic getClient(): MongoClient {\n\t\tif (!this.client) {\n\t\t\tthrow new Error(\"Database not initialized\");\n\t\t}\n\n\t\treturn this.client;\n\t}\n\n\t/**\n\t * Return map of database's collections\n\t */\n\tpublic getCollections() {\n\t\tif (!this.client) {\n\t\t\tthrow new Error(\"Database not initialized\");\n\t\t}\n\n\t\tconst db = this.client.db(\n\t\t\tconfig.MONGODB_DB_NAME + (import.meta.env.MODE === \"test\" ? \"-test\" : \"\")\n\t\t);\n\n\t\t// Collections with default readPreference (primary) - critical for read-after-write consistency\n\t\tconst conversations = db.collection<Conversation>(\"conversations\");\n\t\tconst settings = db.collection<Settings>(\"settings\");\n\t\tconst users = db.collection<User>(\"users\");\n\t\tconst sessions = db.collection<Session>(\"sessions\");\n\t\tconst messageEvents = db.collection<MessageEvent>(\"messageEvents\");\n\t\tconst abortedGenerations = db.collection<AbortedGeneration>(\"abortedGenerations\");\n\t\tconst semaphores = db.collection<Semaphore>(\"semaphores\");\n\t\tconst tokenCaches = db.collection<TokenCache>(\"tokens\");\n\t\tconst configCollection = db.collection<ConfigKey>(\"config\");\n\t\tconst migrationResults = db.collection<MigrationResult>(\"migrationResults\");\n\t\tconst sharedConversations = db.collection<SharedConversation>(\"sharedConversations\");\n\t\tconst bucket = new GridFSBucket(db, { bucketName: \"files\" });\n\n\t\t// Collections with secondaryPreferred - heavy reads, can tolerate slight replication lag\n\t\tconst secondaryPreferred = ReadPreference.SECONDARY_PREFERRED;\n\t\tconst assistants = db.collection<Assistant>(\"assistants\", {\n\t\t\treadPreference: secondaryPreferred,\n\t\t});\n\t\tconst assistantStats = db.collection<AssistantStats>(\"assistants.stats\", {\n\t\t\treadPreference: secondaryPreferred,\n\t\t});\n\t\tconst conversationStats = db.collection<ConversationStats>(CONVERSATION_STATS_COLLECTION, {\n\t\t\treadPreference: secondaryPreferred,\n\t\t});\n\t\tconst reports = db.collection<Report>(\"reports\", {\n\t\t\treadPreference: secondaryPreferred,\n\t\t});\n\t\tconst tools = db.collection(\"tools\", {\n\t\t\treadPreference: secondaryPreferred,\n\t\t});\n\n\t\treturn {\n\t\t\tconversations,\n\t\t\tconversationStats,\n\t\t\tassistants,\n\t\t\tassistantStats,\n\t\t\treports,\n\t\t\tsharedConversations,\n\t\t\tabortedGenerations,\n\t\t\tsettings,\n\t\t\tusers,\n\t\t\tsessions,\n\t\t\tmessageEvents,\n\t\t\tbucket,\n\t\t\tmigrationResults,\n\t\t\tsemaphores,\n\t\t\ttokenCaches,\n\t\t\ttools,\n\t\t\tconfig: configCollection,\n\t\t};\n\t}\n\n\t/**\n\t * Init database once connected: Index creation\n\t * @private\n\t */\n\tprivate initDatabase() {\n\t\tconst {\n\t\t\tconversations,\n\t\t\tconversationStats,\n\t\t\tassistants,\n\t\t\tassistantStats,\n\t\t\treports,\n\t\t\tsharedConversations,\n\t\t\tabortedGenerations,\n\t\t\tsettings,\n\t\t\tusers,\n\t\t\tsessions,\n\t\t\tmessageEvents,\n\t\t\tsemaphores,\n\t\t\ttokenCaches,\n\t\t\tconfig,\n\t\t} = this.getCollections();\n\n\t\tconversations\n\t\t\t.createIndex(\n\t\t\t\t{ sessionId: 1, updatedAt: -1 },\n\t\t\t\t{ partialFilterExpression: { sessionId: { $exists: true } } }\n\t\t\t)\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by sessionId and updatedAt\")\n\t\t\t);\n\t\tconversations\n\t\t\t.createIndex(\n\t\t\t\t{ userId: 1, updatedAt: -1 },\n\t\t\t\t{ partialFilterExpression: { userId: { $exists: true } } }\n\t\t\t)\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by userId and updatedAt\")\n\t\t\t);\n\t\tconversations\n\t\t\t.createIndex(\n\t\t\t\t{ \"message.id\": 1, \"message.ancestors\": 1 },\n\t\t\t\t{ partialFilterExpression: { userId: { $exists: true } } }\n\t\t\t)\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by messageId and ancestors\")\n\t\t\t);\n\t\t// Not strictly necessary, could use _id, but more convenient. Also for stats\n\t\t// To do stats on conversation messages\n\t\tconversations\n\t\t\t.createIndex({ \"messages.createdAt\": 1 }, { sparse: true })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by messages createdAt\")\n\t\t\t);\n\t\t// Unique index for stats\n\t\tconversationStats\n\t\t\t.createIndex(\n\t\t\t\t{\n\t\t\t\t\ttype: 1,\n\t\t\t\t\t\"date.field\": 1,\n\t\t\t\t\t\"date.span\": 1,\n\t\t\t\t\t\"date.at\": 1,\n\t\t\t\t\tdistinct: 1,\n\t\t\t\t},\n\t\t\t\t{ unique: true }\n\t\t\t)\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(\n\t\t\t\t\te,\n\t\t\t\t\t\"Error creating index for conversationStats by type, date.field and date.span\"\n\t\t\t\t)\n\t\t\t);\n\t\t// Allow easy check of last computed stat for given type/dateField\n\t\tconversationStats\n\t\t\t.createIndex({\n\t\t\t\ttype: 1,\n\t\t\t\t\"date.field\": 1,\n\t\t\t\t\"date.at\": 1,\n\t\t\t})\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for abortedGenerations by updatedAt\"));\n\t\tabortedGenerations\n\t\t\t.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 30 })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(\n\t\t\t\t\te,\n\t\t\t\t\t\"Error creating index for abortedGenerations by updatedAt and expireAfterSeconds\"\n\t\t\t\t)\n\t\t\t);\n\t\tabortedGenerations\n\t\t\t.createIndex({ conversationId: 1 }, { unique: true })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for abortedGenerations by conversationId\")\n\t\t\t);\n\t\tsharedConversations.createIndex({ hash: 1 }, { unique: true }).catch((e) => logger.error(e));\n\t\tsettings\n\t\t\t.createIndex({ sessionId: 1 }, { unique: true, sparse: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for settings by sessionId\"));\n\t\tsettings\n\t\t\t.createIndex({ userId: 1 }, { unique: true, sparse: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for settings by userId\"));\n\t\tsettings\n\t\t\t.createIndex({ assistants: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for settings by assistants\"));\n\t\tusers\n\t\t\t.createIndex({ hfUserId: 1 }, { unique: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for users by hfUserId\"));\n\t\tusers\n\t\t\t.createIndex({ sessionId: 1 }, { unique: true, sparse: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for users by sessionId\"));\n\t\t// No unicity because due to renames & outdated info from oauth provider, there may be the same username on different users\n\t\tusers\n\t\t\t.createIndex({ username: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for users by username\"));\n\t\t// For stats queries filtering users by creation date\n\t\tusers\n\t\t\t.createIndex({ createdAt: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for users by createdAt\"));\n\t\tmessageEvents\n\t\t\t.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for messageEvents by expiresAt\"));\n\t\tsessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch((e) => logger.error(e));\n\t\tsessions\n\t\t\t.createIndex({ sessionId: 1 }, { unique: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for sessions by sessionId\"));\n\t\tassistants\n\t\t\t.createIndex({ createdById: 1, userCount: -1 })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for assistants by createdById and userCount\")\n\t\t\t);\n\t\tassistants\n\t\t\t.createIndex({ userCount: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for assistants by userCount\"));\n\t\tassistants\n\t\t\t.createIndex({ review: 1, userCount: -1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for assistants by review and userCount\"));\n\t\tassistants\n\t\t\t.createIndex({ modelId: 1, userCount: -1 })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for assistants by modelId and userCount\")\n\t\t\t);\n\t\tassistants\n\t\t\t.createIndex({ searchTokens: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for assistants by searchTokens\"));\n\t\tassistants\n\t\t\t.createIndex({ last24HoursCount: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for assistants by last24HoursCount\"));\n\t\tassistants\n\t\t\t.createIndex({ last24HoursUseCount: -1, useCount: -1, _id: 1 })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for assistants by last24HoursUseCount and useCount\")\n\t\t\t);\n\t\tassistantStats\n\t\t\t// Order of keys is important for the queries\n\t\t\t.createIndex({ \"date.span\": 1, \"date.at\": 1, assistantId: 1 }, { unique: true })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(\n\t\t\t\t\te,\n\t\t\t\t\t\"Error creating index for assistantStats by date.span and date.at and assistantId\"\n\t\t\t\t)\n\t\t\t);\n\t\treports\n\t\t\t.createIndex({ assistantId: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for reports by assistantId\"));\n\t\treports\n\t\t\t.createIndex({ createdBy: 1, assistantId: 1 })\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for reports by createdBy and assistantId\")\n\t\t\t);\n\n\t\t// Unique index for semaphore and migration results\n\t\tsemaphores.createIndex({ key: 1 }, { unique: true }).catch((e) => logger.error(e));\n\t\tsemaphores\n\t\t\t.createIndex({ deleteAt: 1 }, { expireAfterSeconds: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for semaphores by deleteAt\"));\n\t\ttokenCaches\n\t\t\t.createIndex({ createdAt: 1 }, { expireAfterSeconds: 5 * 60 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for tokenCaches by createdAt\"));\n\t\ttokenCaches\n\t\t\t.createIndex({ tokenHash: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for tokenCaches by tokenHash\"));\n\t\t// Tools removed: skipping tools indexes\n\n\t\tconversations\n\t\t\t.createIndex({\n\t\t\t\t\"messages.from\": 1,\n\t\t\t\tcreatedAt: 1,\n\t\t\t})\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by messages from and createdAt\")\n\t\t\t);\n\n\t\tconversations\n\t\t\t.createIndex({\n\t\t\t\tuserId: 1,\n\t\t\t\tsessionId: 1,\n\t\t\t})\n\t\t\t.catch((e) =>\n\t\t\t\tlogger.error(e, \"Error creating index for conversations by userId and sessionId\")\n\t\t\t);\n\n\t\t// For stats aggregation jobs that filter by createdAt/updatedAt alone\n\t\tconversations\n\t\t\t.createIndex({ createdAt: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for conversations by createdAt\"));\n\t\tconversations\n\t\t\t.createIndex({ updatedAt: 1 })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for conversations by updatedAt\"));\n\n\t\tconfig\n\t\t\t.createIndex({ key: 1 }, { unique: true })\n\t\t\t.catch((e) => logger.error(e, \"Error creating index for config by key\"));\n\t}\n}\n\nexport let collections: ReturnType<typeof Database.prototype.getCollections>;\n\nexport const ready = (async () => {\n\tif (!building) {\n\t\tconst db = await Database.getInstance();\n\t\tcollections = db.getCollections();\n\t} else {\n\t\tcollections = {} as unknown as ReturnType<typeof Database.prototype.getCollections>;\n\t}\n})();\n\nexport async function getCollectionsEarly(): Promise<\n\tReturnType<typeof Database.prototype.getCollections>\n> {\n\tawait ready;\n\tif (!collections) {\n\t\tthrow new Error(\"Database not initialized\");\n\t}\n\treturn collections;\n}\n"
  },
  {
    "path": "src/lib/server/endpoints/document.ts",
    "content": "import type { MessageFile } from \"$lib/types/Message\";\nimport { z } from \"zod\";\n\nexport interface FileProcessorOptions<TMimeType extends string = string> {\n\tsupportedMimeTypes: TMimeType[];\n\tmaxSizeInMB: number;\n}\n\n// Removed unused ImageProcessor type alias\n\nexport const createDocumentProcessorOptionsValidator = <TMimeType extends string = string>(\n\tdefaults: FileProcessorOptions<TMimeType>\n) => {\n\treturn z\n\t\t.object({\n\t\t\tsupportedMimeTypes: z\n\t\t\t\t.array(\n\t\t\t\t\tz.enum<string, [TMimeType, ...TMimeType[]]>([\n\t\t\t\t\t\tdefaults.supportedMimeTypes[0],\n\t\t\t\t\t\t...defaults.supportedMimeTypes.slice(1),\n\t\t\t\t\t])\n\t\t\t\t)\n\t\t\t\t.default(defaults.supportedMimeTypes),\n\t\t\tmaxSizeInMB: z.number().positive().default(defaults.maxSizeInMB),\n\t\t})\n\t\t.default(defaults);\n};\n\n// Removed unused DocumentProcessor type alias\n\nexport type AsyncDocumentProcessor<TMimeType extends string = string> = (\n\tfile: MessageFile\n) => Promise<{\n\tfile: Buffer;\n\tmime: TMimeType;\n}>;\n\nexport function makeDocumentProcessor<TMimeType extends string = string>(\n\toptions: FileProcessorOptions<TMimeType>\n): AsyncDocumentProcessor<TMimeType> {\n\treturn async (file) => {\n\t\tconst { supportedMimeTypes, maxSizeInMB } = options;\n\t\tconst { mime, value } = file;\n\n\t\tconst buffer = Buffer.from(value, \"base64\");\n\t\tconst tooLargeInBytes = buffer.byteLength > maxSizeInMB * 1000 * 1000;\n\n\t\tif (tooLargeInBytes) {\n\t\t\tthrow Error(\"Document is too large\");\n\t\t}\n\n\t\tconst outputMime = validateMimeType(supportedMimeTypes, mime);\n\t\treturn { file: buffer, mime: outputMime };\n\t};\n}\n\nconst validateMimeType = <T extends readonly string[]>(\n\tsupportedMimes: T,\n\tmime: string\n): T[number] => {\n\tif (!supportedMimes.includes(mime)) {\n\t\tconst supportedMimesStr = supportedMimes.join(\", \");\n\n\t\tthrow Error(`Mimetype \"${mime}\" not found in supported mimes: ${supportedMimesStr}`);\n\t}\n\n\treturn mime;\n};\n"
  },
  {
    "path": "src/lib/server/endpoints/endpoints.ts",
    "content": "import type { Conversation } from \"$lib/types/Conversation\";\nimport type { Message } from \"$lib/types/Message\";\nimport type {\n\tTextGenerationStreamOutput,\n\tTextGenerationStreamToken,\n\tInferenceProvider,\n} from \"@huggingface/inference\";\nimport { z } from \"zod\";\nimport { endpointOAIParametersSchema, endpointOai } from \"./openai/endpointOai\";\nimport type { Model } from \"$lib/types/Model\";\nimport type { ObjectId } from \"mongodb\";\n\nexport type EndpointMessage = Omit<Message, \"id\">;\n\n// parameters passed when generating text\nexport interface EndpointParameters {\n\tmessages: EndpointMessage[];\n\tpreprompt?: Conversation[\"preprompt\"];\n\tgenerateSettings?: Partial<Model[\"parameters\"]>;\n\tisMultimodal?: boolean;\n\tconversationId?: ObjectId;\n\tlocals: App.Locals | undefined;\n\tabortSignal?: AbortSignal;\n\t/** Inference provider preference: \"auto\", \"fastest\", \"cheapest\", or a specific provider name */\n\tprovider?: string;\n}\n\nexport type TextGenerationStreamOutputSimplified = TextGenerationStreamOutput & {\n\ttoken: TextGenerationStreamToken;\n\trouterMetadata?: { route?: string; model?: string; provider?: InferenceProvider };\n};\n// type signature for the endpoint\nexport type Endpoint = (\n\tparams: EndpointParameters\n) => Promise<AsyncGenerator<TextGenerationStreamOutputSimplified, void, void>>;\n\n// list of all endpoint generators\nexport const endpoints = {\n\topenai: endpointOai,\n};\n\nexport const endpointSchema = z.discriminatedUnion(\"type\", [endpointOAIParametersSchema]);\nexport default endpoints;\n"
  },
  {
    "path": "src/lib/server/endpoints/images.ts",
    "content": "import type { Sharp } from \"sharp\";\nimport sharp from \"sharp\";\nimport type { MessageFile } from \"$lib/types/Message\";\nimport { z, type util } from \"zod\";\n\nexport interface ImageProcessorOptions<TMimeType extends string = string> {\n\tsupportedMimeTypes: TMimeType[];\n\tpreferredMimeType: TMimeType;\n\tmaxSizeInMB: number;\n\tmaxWidth: number;\n\tmaxHeight: number;\n}\nexport type ImageProcessor<TMimeType extends string = string> = (file: MessageFile) => Promise<{\n\timage: Buffer;\n\tmime: TMimeType;\n}>;\n\nexport function createImageProcessorOptionsValidator<TMimeType extends string = string>(\n\tdefaults: ImageProcessorOptions<TMimeType>\n) {\n\treturn z\n\t\t.object({\n\t\t\tsupportedMimeTypes: z\n\t\t\t\t.array(\n\t\t\t\t\tz.enum<string, [TMimeType, ...TMimeType[]]>([\n\t\t\t\t\t\tdefaults.supportedMimeTypes[0],\n\t\t\t\t\t\t...defaults.supportedMimeTypes.slice(1),\n\t\t\t\t\t])\n\t\t\t\t)\n\t\t\t\t.default(defaults.supportedMimeTypes),\n\t\t\tpreferredMimeType: z\n\t\t\t\t.enum([defaults.supportedMimeTypes[0], ...defaults.supportedMimeTypes.slice(1)])\n\t\t\t\t.default(defaults.preferredMimeType as util.noUndefined<TMimeType>),\n\t\t\tmaxSizeInMB: z.number().positive().default(defaults.maxSizeInMB),\n\t\t\tmaxWidth: z.number().int().positive().default(defaults.maxWidth),\n\t\t\tmaxHeight: z.number().int().positive().default(defaults.maxHeight),\n\t\t})\n\t\t.default(defaults);\n}\n\nexport function makeImageProcessor<TMimeType extends string = string>(\n\toptions: ImageProcessorOptions<TMimeType>\n): ImageProcessor<TMimeType> {\n\treturn async (file) => {\n\t\tconst { supportedMimeTypes, preferredMimeType, maxSizeInMB, maxWidth, maxHeight } = options;\n\t\tconst { mime, value } = file;\n\n\t\tconst buffer = Buffer.from(value, \"base64\");\n\t\tlet sharpInst = sharp(buffer);\n\n\t\tconst metadata = await sharpInst.metadata();\n\t\tif (!metadata) throw Error(\"Failed to read image metadata\");\n\t\tconst { width, height } = metadata;\n\t\tif (width === undefined || height === undefined) throw Error(\"Failed to read image size\");\n\n\t\tconst tooLargeInSize = width > maxWidth || height > maxHeight;\n\t\tconst tooLargeInBytes = buffer.byteLength > maxSizeInMB * 1000 * 1000;\n\n\t\tconst outputMime = chooseMimeType(supportedMimeTypes, preferredMimeType, mime, {\n\t\t\tpreferSizeReduction: tooLargeInBytes,\n\t\t});\n\n\t\t// Resize if necessary\n\t\tif (tooLargeInSize || tooLargeInBytes) {\n\t\t\tconst size = chooseImageSize({\n\t\t\t\tmime: outputMime,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tmaxWidth,\n\t\t\t\tmaxHeight,\n\t\t\t\tmaxSizeInMB,\n\t\t\t});\n\t\t\tif (size.width !== width || size.height !== height) {\n\t\t\t\tsharpInst = resizeImage(sharpInst, size.width, size.height);\n\t\t\t}\n\t\t}\n\n\t\t// Convert format if necessary\n\t\t// We always want to convert the image when the file was too large in bytes\n\t\t// so we can guarantee that ideal options are used, which are expected when\n\t\t// choosing the image size\n\t\tif (outputMime !== mime || tooLargeInBytes) {\n\t\t\tsharpInst = convertImage(sharpInst, outputMime);\n\t\t}\n\n\t\tconst processedImage = await sharpInst.toBuffer();\n\t\treturn { image: processedImage, mime: outputMime };\n\t};\n}\n\nconst outputFormats = [\"png\", \"jpeg\", \"webp\", \"avif\", \"tiff\", \"gif\"] as const;\ntype OutputImgFormat = (typeof outputFormats)[number];\nconst isOutputFormat = (format: string): format is (typeof outputFormats)[number] =>\n\toutputFormats.includes(format as OutputImgFormat);\n\nexport function convertImage(sharpInst: Sharp, outputMime: string): Sharp {\n\tconst [type, format] = outputMime.split(\"/\");\n\tif (type !== \"image\") throw Error(`Requested non-image mime type: ${outputMime}`);\n\tif (!isOutputFormat(format)) {\n\t\tthrow Error(`Requested to convert to an unsupported format: ${format}`);\n\t}\n\n\treturn sharpInst[format]();\n}\n\n// heic/heif requires proprietary license\n// TODO: blocking heif may be incorrect considering it also supports av1, so we should instead\n// detect the compression method used via sharp().metadata().compression\n// TODO: consider what to do about animated formats: apng, gif, animated webp, ...\nconst blocklistedMimes = [\"image/heic\", \"image/heif\"];\n\n/** Sorted from largest to smallest */\nconst mimesBySizeDesc = [\n\t\"image/png\",\n\t\"image/tiff\",\n\t\"image/gif\",\n\t\"image/jpeg\",\n\t\"image/webp\",\n\t\"image/avif\",\n];\n\n/**\n * Defaults to preferred format or uses existing mime if supported\n * When preferSizeReduction is true, it will choose the smallest format that is supported\n **/\nfunction chooseMimeType<T extends readonly string[]>(\n\tsupportedMimes: T,\n\tpreferredMime: string,\n\tmime: string,\n\t{ preferSizeReduction }: { preferSizeReduction: boolean }\n): T[number] {\n\tif (!supportedMimes.includes(preferredMime)) {\n\t\tconst supportedMimesStr = supportedMimes.join(\", \");\n\t\tthrow Error(\n\t\t\t`Preferred format \"${preferredMime}\" not found in supported mimes: ${supportedMimesStr}`\n\t\t);\n\t}\n\n\tconst [type] = mime.split(\"/\");\n\tif (type !== \"image\") throw Error(`Received non-image mime type: ${mime}`);\n\n\tif (supportedMimes.includes(mime) && !preferSizeReduction) return mime;\n\n\tif (blocklistedMimes.includes(mime)) throw Error(`Received blocklisted mime type: ${mime}`);\n\n\tconst smallestMime = mimesBySizeDesc.findLast((m) => supportedMimes.includes(m));\n\treturn smallestMime ?? preferredMime;\n}\n\ninterface ImageSizeOptions {\n\tmime: string;\n\twidth: number;\n\theight: number;\n\tmaxWidth: number;\n\tmaxHeight: number;\n\tmaxSizeInMB: number;\n}\n\n/** Resizes the image to fit within the specified size in MB by guessing the output size */\nexport function chooseImageSize({\n\tmime,\n\twidth,\n\theight,\n\tmaxWidth,\n\tmaxHeight,\n\tmaxSizeInMB,\n}: ImageSizeOptions): { width: number; height: number } {\n\tconst biggestDiscrepency = Math.max(1, width / maxWidth, height / maxHeight);\n\n\tlet selectedWidth = Math.ceil(width / biggestDiscrepency);\n\tlet selectedHeight = Math.ceil(height / biggestDiscrepency);\n\n\tdo {\n\t\tconst estimatedSize = estimateImageSizeInBytes(mime, selectedWidth, selectedHeight);\n\t\tif (estimatedSize < maxSizeInMB * 1024 * 1024) {\n\t\t\treturn { width: selectedWidth, height: selectedHeight };\n\t\t}\n\t\tselectedWidth = Math.floor(selectedWidth / 1.1);\n\t\tselectedHeight = Math.floor(selectedHeight / 1.1);\n\t} while (selectedWidth > 1 && selectedHeight > 1);\n\n\tthrow Error(`Failed to resize image to fit within ${maxSizeInMB}MB`);\n}\n\nconst mimeToCompressionRatio: Record<string, number> = {\n\t\"image/png\": 1 / 2,\n\t\"image/jpeg\": 1 / 10,\n\t\"image/webp\": 1 / 4,\n\t\"image/avif\": 1 / 5,\n\t\"image/tiff\": 1,\n\t\"image/gif\": 1 / 5,\n};\n\n/**\n * Guesses the side of an image in MB based on its format and dimensions\n * Should guess the worst case\n **/\nfunction estimateImageSizeInBytes(mime: string, width: number, height: number): number {\n\tconst compressionRatio = mimeToCompressionRatio[mime];\n\tif (!compressionRatio) throw Error(`Unsupported image format: ${mime}`);\n\n\tconst bitsPerPixel = 32; // Assuming 32-bit color depth for 8-bit R G B A\n\tconst bytesPerPixel = bitsPerPixel / 8;\n\tconst uncompressedSize = width * height * bytesPerPixel;\n\n\treturn uncompressedSize * compressionRatio;\n}\n\nexport function resizeImage(sharpInst: Sharp, maxWidth: number, maxHeight: number): Sharp {\n\treturn sharpInst.resize({ width: maxWidth, height: maxHeight, fit: \"inside\" });\n}\n"
  },
  {
    "path": "src/lib/server/endpoints/openai/endpointOai.ts",
    "content": "import { z } from \"zod\";\nimport { openAICompletionToTextGenerationStream } from \"./openAICompletionToTextGenerationStream\";\nimport {\n\topenAIChatToTextGenerationSingle,\n\topenAIChatToTextGenerationStream,\n} from \"./openAIChatToTextGenerationStream\";\nimport type { CompletionCreateParamsStreaming } from \"openai/resources/completions\";\nimport type {\n\tChatCompletionCreateParamsNonStreaming,\n\tChatCompletionCreateParamsStreaming,\n} from \"openai/resources/chat/completions\";\nimport { buildPrompt } from \"$lib/buildPrompt\";\nimport { config } from \"$lib/server/config\";\nimport type { Endpoint } from \"../endpoints\";\nimport type OpenAI from \"openai\";\nimport { createImageProcessorOptionsValidator, makeImageProcessor } from \"../images\";\nimport { prepareMessagesWithFiles } from \"$lib/server/textGeneration/utils/prepareFiles\";\n// uuid import removed (no tool call ids)\n\nexport const endpointOAIParametersSchema = z.object({\n\tweight: z.number().int().positive().default(1),\n\tmodel: z.any(),\n\ttype: z.literal(\"openai\"),\n\tbaseURL: z.string().url().default(\"https://api.openai.com/v1\"),\n\t// Canonical auth token is OPENAI_API_KEY; keep HF_TOKEN as legacy alias\n\tapiKey: z.string().default(config.OPENAI_API_KEY || config.HF_TOKEN || \"sk-\"),\n\tcompletion: z\n\t\t.union([z.literal(\"completions\"), z.literal(\"chat_completions\")])\n\t\t.default(\"chat_completions\"),\n\tdefaultHeaders: z.record(z.string()).optional(),\n\tdefaultQuery: z.record(z.string()).optional(),\n\textraBody: z.record(z.any()).optional(),\n\tmultimodal: z\n\t\t.object({\n\t\t\timage: createImageProcessorOptionsValidator({\n\t\t\t\tsupportedMimeTypes: [\n\t\t\t\t\t// Restrict to the most widely-supported formats\n\t\t\t\t\t\"image/png\",\n\t\t\t\t\t\"image/jpeg\",\n\t\t\t\t],\n\t\t\t\tpreferredMimeType: \"image/jpeg\",\n\t\t\t\tmaxSizeInMB: 1,\n\t\t\t\tmaxWidth: 1024,\n\t\t\t\tmaxHeight: 1024,\n\t\t\t}),\n\t\t})\n\t\t.default({}),\n\t/* enable use of max_completion_tokens in place of max_tokens */\n\tuseCompletionTokens: z.boolean().default(false),\n\tstreamingSupported: z.boolean().default(true),\n});\n\nexport async function endpointOai(\n\tinput: z.input<typeof endpointOAIParametersSchema>\n): Promise<Endpoint> {\n\tconst {\n\t\tbaseURL,\n\t\tapiKey,\n\t\tcompletion,\n\t\tmodel,\n\t\tdefaultHeaders,\n\t\tdefaultQuery,\n\t\tmultimodal,\n\t\textraBody,\n\t\tuseCompletionTokens,\n\t\tstreamingSupported,\n\t} = endpointOAIParametersSchema.parse(input);\n\n\tlet OpenAI;\n\ttry {\n\t\tOpenAI = (await import(\"openai\")).OpenAI;\n\t} catch (e) {\n\t\tthrow new Error(\"Failed to import OpenAI\", { cause: e });\n\t}\n\n\t// Store router metadata if captured\n\tlet routerMetadata: { route?: string; model?: string; provider?: string } = {};\n\n\t// Custom fetch wrapper to capture response headers for router metadata\n\tconst customFetch = async (url: RequestInfo, init?: RequestInit): Promise<Response> => {\n\t\tconst response = await fetch(url, init);\n\n\t\t// Capture router headers if present (fallback for non-streaming)\n\t\tconst routeHeader = response.headers.get(\"X-Router-Route\");\n\t\tconst modelHeader = response.headers.get(\"X-Router-Model\");\n\t\tconst providerHeader = response.headers.get(\"x-inference-provider\");\n\n\t\tif (routeHeader && modelHeader) {\n\t\t\trouterMetadata = {\n\t\t\t\troute: routeHeader,\n\t\t\t\tmodel: modelHeader,\n\t\t\t\tprovider: providerHeader || undefined,\n\t\t\t};\n\t\t} else if (providerHeader) {\n\t\t\t// Even without router metadata, capture provider info\n\t\t\trouterMetadata = {\n\t\t\t\tprovider: providerHeader,\n\t\t\t};\n\t\t}\n\n\t\treturn response;\n\t};\n\n\tconst openai = new OpenAI({\n\t\tapiKey: apiKey || \"sk-\",\n\t\tbaseURL,\n\t\tdefaultHeaders: {\n\t\t\t...(config.PUBLIC_APP_NAME === \"HuggingChat\" && { \"User-Agent\": \"huggingchat\" }),\n\t\t\t...defaultHeaders,\n\t\t},\n\t\tdefaultQuery,\n\t\tfetch: customFetch,\n\t});\n\n\tconst imageProcessor = makeImageProcessor(multimodal.image);\n\n\tif (completion === \"completions\") {\n\t\treturn async ({\n\t\t\tmessages,\n\t\t\tpreprompt,\n\t\t\tgenerateSettings,\n\t\t\tconversationId,\n\t\t\tlocals,\n\t\t\tabortSignal,\n\t\t\tprovider,\n\t\t}) => {\n\t\t\tconst prompt = await buildPrompt({\n\t\t\t\tmessages,\n\t\t\t\tpreprompt,\n\t\t\t\tmodel,\n\t\t\t});\n\n\t\t\t// Build model ID with optional provider suffix (e.g., \"model:fastest\" or \"model:together\")\n\t\t\tconst baseModelId = model.id ?? model.name;\n\t\t\tconst modelId = provider && provider !== \"auto\" ? `${baseModelId}:${provider}` : baseModelId;\n\n\t\t\tconst parameters = { ...model.parameters, ...generateSettings };\n\t\t\tconst body: CompletionCreateParamsStreaming = {\n\t\t\t\tmodel: modelId,\n\t\t\t\tprompt,\n\t\t\t\tstream: true,\n\t\t\t\tmax_tokens: parameters?.max_tokens,\n\t\t\t\tstop: parameters?.stop,\n\t\t\t\ttemperature: parameters?.temperature,\n\t\t\t\ttop_p: parameters?.top_p,\n\t\t\t\tfrequency_penalty: parameters?.frequency_penalty,\n\t\t\t\tpresence_penalty: parameters?.presence_penalty,\n\t\t\t};\n\n\t\t\tconst openAICompletion = await openai.completions.create(body, {\n\t\t\t\tbody: { ...body, ...extraBody },\n\t\t\t\theaders: {\n\t\t\t\t\t\"ChatUI-Conversation-ID\": conversationId?.toString() ?? \"\",\n\t\t\t\t\t\"X-use-cache\": \"false\",\n\t\t\t\t\t...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),\n\t\t\t\t\t// Bill to organization if configured\n\t\t\t\t\t...(locals?.billingOrganization ? { \"X-HF-Bill-To\": locals.billingOrganization } : {}),\n\t\t\t\t},\n\t\t\t\tsignal: abortSignal,\n\t\t\t});\n\n\t\t\treturn openAICompletionToTextGenerationStream(openAICompletion);\n\t\t};\n\t} else if (completion === \"chat_completions\") {\n\t\treturn async ({\n\t\t\tmessages,\n\t\t\tpreprompt,\n\t\t\tgenerateSettings,\n\t\t\tconversationId,\n\t\t\tisMultimodal,\n\t\t\tlocals,\n\t\t\tabortSignal,\n\t\t\tprovider,\n\t\t}) => {\n\t\t\t// Format messages for the chat API, handling multimodal content if supported\n\t\t\tlet messagesOpenAI: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =\n\t\t\t\tawait prepareMessagesWithFiles(messages, imageProcessor, isMultimodal ?? model.multimodal);\n\n\t\t\t// Normalize preprompt and handle empty values\n\t\t\tconst normalizedPreprompt = typeof preprompt === \"string\" ? preprompt.trim() : \"\";\n\n\t\t\t// Check if a system message already exists as the first message\n\t\t\tconst hasSystemMessage = messagesOpenAI.length > 0 && messagesOpenAI[0]?.role === \"system\";\n\n\t\t\tif (hasSystemMessage) {\n\t\t\t\t// Prepend normalized preprompt to existing system content when non-empty\n\t\t\t\tif (normalizedPreprompt) {\n\t\t\t\t\tconst userSystemPrompt =\n\t\t\t\t\t\t(typeof messagesOpenAI[0].content === \"string\"\n\t\t\t\t\t\t\t? (messagesOpenAI[0].content as string)\n\t\t\t\t\t\t\t: \"\") || \"\";\n\t\t\t\t\tmessagesOpenAI[0].content =\n\t\t\t\t\t\tnormalizedPreprompt + (userSystemPrompt ? \"\\n\\n\" + userSystemPrompt : \"\");\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Insert a system message only if the preprompt is non-empty\n\t\t\t\tif (normalizedPreprompt) {\n\t\t\t\t\tmessagesOpenAI = [{ role: \"system\", content: normalizedPreprompt }, ...messagesOpenAI];\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Combine model defaults with request-specific parameters\n\t\t\tconst parameters = { ...model.parameters, ...generateSettings };\n\n\t\t\t// Build model ID with optional provider suffix (e.g., \"model:fastest\" or \"model:together\")\n\t\t\tconst baseModelId = model.id ?? model.name;\n\t\t\tconst modelId = provider && provider !== \"auto\" ? `${baseModelId}:${provider}` : baseModelId;\n\n\t\t\tconst body = {\n\t\t\t\tmodel: modelId,\n\t\t\t\tmessages: messagesOpenAI,\n\t\t\t\tstream: streamingSupported,\n\t\t\t\t// Support two different ways of specifying token limits depending on the model\n\t\t\t\t...(useCompletionTokens\n\t\t\t\t\t? { max_completion_tokens: parameters?.max_tokens }\n\t\t\t\t\t: { max_tokens: parameters?.max_tokens }),\n\t\t\t\tstop: parameters?.stop,\n\t\t\t\ttemperature: parameters?.temperature,\n\t\t\t\ttop_p: parameters?.top_p,\n\t\t\t\tfrequency_penalty: parameters?.frequency_penalty,\n\t\t\t\tpresence_penalty: parameters?.presence_penalty,\n\t\t\t};\n\n\t\t\t// Handle both streaming and non-streaming responses with appropriate processors\n\t\t\tif (streamingSupported) {\n\t\t\t\tconst openChatAICompletion = await openai.chat.completions.create(\n\t\t\t\t\tbody as ChatCompletionCreateParamsStreaming,\n\t\t\t\t\t{\n\t\t\t\t\t\tbody: { ...body, ...extraBody },\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\"ChatUI-Conversation-ID\": conversationId?.toString() ?? \"\",\n\t\t\t\t\t\t\t\"X-use-cache\": \"false\",\n\t\t\t\t\t\t\t...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),\n\t\t\t\t\t\t\t// Bill to organization if configured\n\t\t\t\t\t\t\t...(locals?.billingOrganization\n\t\t\t\t\t\t\t\t? { \"X-HF-Bill-To\": locals.billingOrganization }\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal: abortSignal,\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t\t\treturn openAIChatToTextGenerationStream(openChatAICompletion, () => routerMetadata);\n\t\t\t} else {\n\t\t\t\tconst openChatAICompletion = await openai.chat.completions.create(\n\t\t\t\t\tbody as ChatCompletionCreateParamsNonStreaming,\n\t\t\t\t\t{\n\t\t\t\t\t\tbody: { ...body, ...extraBody },\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\"ChatUI-Conversation-ID\": conversationId?.toString() ?? \"\",\n\t\t\t\t\t\t\t\"X-use-cache\": \"false\",\n\t\t\t\t\t\t\t...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),\n\t\t\t\t\t\t\t// Bill to organization if configured\n\t\t\t\t\t\t\t...(locals?.billingOrganization\n\t\t\t\t\t\t\t\t? { \"X-HF-Bill-To\": locals.billingOrganization }\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal: abortSignal,\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t\t\treturn openAIChatToTextGenerationSingle(openChatAICompletion, () => routerMetadata);\n\t\t\t}\n\t\t};\n\t} else {\n\t\tthrow new Error(\"Invalid completion type\");\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/endpoints/openai/openAIChatToTextGenerationStream.ts",
    "content": "import type { TextGenerationStreamOutput } from \"@huggingface/inference\";\nimport type OpenAI from \"openai\";\nimport type { Stream } from \"openai/streaming\";\n\n/**\n * Transform a stream of OpenAI.Chat.ChatCompletion into a stream of TextGenerationStreamOutput\n */\nexport async function* openAIChatToTextGenerationStream(\n\tcompletionStream: Stream<OpenAI.Chat.Completions.ChatCompletionChunk>,\n\tgetRouterMetadata?: () => { route?: string; model?: string; provider?: string }\n) {\n\tlet generatedText = \"\";\n\tlet tokenId = 0;\n\tlet toolBuffer = \"\"; // legacy hack kept harmless\n\tlet metadataYielded = false;\n\tlet thinkOpen = false;\n\n\tfor await (const completion of completionStream) {\n\t\tconst retyped = completion as {\n\t\t\t\"x-router-metadata\"?: { route: string; model: string; provider?: string };\n\t\t};\n\t\t// Check if this chunk contains router metadata (first chunk from llm-router)\n\t\tif (!metadataYielded && retyped[\"x-router-metadata\"]) {\n\t\t\tconst metadata = retyped[\"x-router-metadata\"];\n\t\t\tyield {\n\t\t\t\ttoken: {\n\t\t\t\t\tid: tokenId++,\n\t\t\t\t\ttext: \"\",\n\t\t\t\t\tlogprob: 0,\n\t\t\t\t\tspecial: true,\n\t\t\t\t},\n\t\t\t\tgenerated_text: null,\n\t\t\t\tdetails: null,\n\t\t\t\trouterMetadata: {\n\t\t\t\t\troute: metadata.route,\n\t\t\t\t\tmodel: metadata.model,\n\t\t\t\t\tprovider: metadata.provider,\n\t\t\t\t},\n\t\t\t} as TextGenerationStreamOutput & {\n\t\t\t\trouterMetadata: { route: string; model: string; provider?: string };\n\t\t\t};\n\t\t\tmetadataYielded = true;\n\t\t\t// Skip processing this chunk as content since it's just metadata\n\t\t\tif (\n\t\t\t\t!completion.choices ||\n\t\t\t\tcompletion.choices.length === 0 ||\n\t\t\t\t!completion.choices[0].delta?.content\n\t\t\t) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\t\tconst { choices } = completion;\n\t\tconst delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta & {\n\t\t\treasoning?: string;\n\t\t\treasoning_content?: string;\n\t\t} = choices?.[0]?.delta ?? {};\n\t\tconst content: string = delta.content ?? \"\";\n\t\tconst reasoning: string =\n\t\t\ttypeof delta?.reasoning === \"string\"\n\t\t\t\t? (delta.reasoning as string)\n\t\t\t\t: typeof delta?.reasoning_content === \"string\"\n\t\t\t\t\t? (delta.reasoning_content as string)\n\t\t\t\t\t: \"\";\n\t\tconst last = choices?.[0]?.finish_reason === \"stop\" || choices?.[0]?.finish_reason === \"length\";\n\n\t\t// if the last token is a stop and the tool buffer is not empty, yield it as a generated_text\n\t\tif (choices?.[0]?.finish_reason === \"stop\" && toolBuffer.length > 0) {\n\t\t\tyield {\n\t\t\t\ttoken: {\n\t\t\t\t\tid: tokenId++,\n\t\t\t\t\tspecial: true,\n\t\t\t\t\tlogprob: 0,\n\t\t\t\t\ttext: \"\",\n\t\t\t\t},\n\t\t\t\tgenerated_text: toolBuffer,\n\t\t\t\tdetails: null,\n\t\t\t} as TextGenerationStreamOutput;\n\t\t\tbreak;\n\t\t}\n\n\t\t// weird bug where the parameters are streamed in like this\n\t\tif (choices?.[0]?.delta?.tool_calls) {\n\t\t\tconst calls = Array.isArray(choices[0].delta.tool_calls)\n\t\t\t\t? choices[0].delta.tool_calls\n\t\t\t\t: [choices[0].delta.tool_calls];\n\n\t\t\tif (\n\t\t\t\tcalls.length === 1 &&\n\t\t\t\tcalls[0].index === 0 &&\n\t\t\t\tcalls[0].id === \"\" &&\n\t\t\t\tcalls[0].type === \"function\" &&\n\t\t\t\t!!calls[0].function &&\n\t\t\t\tcalls[0].function.name === null\n\t\t\t) {\n\t\t\t\ttoolBuffer += calls[0].function.arguments;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\tlet combined = \"\";\n\t\tif (reasoning && reasoning.length > 0) {\n\t\t\tif (!thinkOpen) {\n\t\t\t\tcombined += \"<think>\" + reasoning;\n\t\t\t\tthinkOpen = true;\n\t\t\t} else {\n\t\t\t\tcombined += reasoning;\n\t\t\t}\n\t\t}\n\n\t\tif (content && content.length > 0) {\n\t\t\tconst trimmed = content.trim();\n\t\t\t// Allow <think> tags in content to pass through (for models like DeepSeek R1)\n\t\t\tif (thinkOpen && trimmed === \"</think>\") {\n\t\t\t\t// close once without duplicating the tag\n\t\t\t\tcombined += \"</think>\";\n\t\t\t\tthinkOpen = false;\n\t\t\t} else if (thinkOpen) {\n\t\t\t\tcombined += \"</think>\" + content;\n\t\t\t\tthinkOpen = false;\n\t\t\t} else {\n\t\t\t\tcombined += content;\n\t\t\t}\n\t\t}\n\n\t\t// Accumulate the combined token into the full text\n\t\tgeneratedText += combined;\n\t\tconst output: TextGenerationStreamOutput = {\n\t\t\ttoken: {\n\t\t\t\tid: tokenId++,\n\t\t\t\ttext: combined,\n\t\t\t\tlogprob: 0,\n\t\t\t\tspecial: last,\n\t\t\t},\n\t\t\tgenerated_text: last ? generatedText : null,\n\t\t\tdetails: null,\n\t\t};\n\t\tyield output;\n\n\t\t// Tools removed: ignore tool_calls deltas\n\t}\n\n\t// If metadata wasn't yielded from chunks (e.g., from headers), yield it at the end\n\tif (!metadataYielded && getRouterMetadata) {\n\t\tconst routerMetadata = getRouterMetadata();\n\t\t// Yield if we have either complete router metadata OR just provider info\n\t\tif (\n\t\t\t(routerMetadata && routerMetadata.route && routerMetadata.model) ||\n\t\t\trouterMetadata?.provider\n\t\t) {\n\t\t\tyield {\n\t\t\t\ttoken: {\n\t\t\t\t\tid: tokenId++,\n\t\t\t\t\ttext: \"\",\n\t\t\t\t\tlogprob: 0,\n\t\t\t\t\tspecial: true,\n\t\t\t\t},\n\t\t\t\tgenerated_text: null,\n\t\t\t\tdetails: null,\n\t\t\t\trouterMetadata,\n\t\t\t} as TextGenerationStreamOutput & {\n\t\t\t\trouterMetadata: { route?: string; model?: string; provider?: string };\n\t\t\t};\n\t\t}\n\t}\n}\n\n/**\n * Transform a non-streaming OpenAI chat completion into a stream of TextGenerationStreamOutput\n */\nexport async function* openAIChatToTextGenerationSingle(\n\tcompletion: OpenAI.Chat.Completions.ChatCompletion,\n\tgetRouterMetadata?: () => { route?: string; model?: string; provider?: string }\n) {\n\tconst message: NonNullable<OpenAI.Chat.Completions.ChatCompletion.Choice>[\"message\"] & {\n\t\treasoning?: string;\n\t\treasoning_content?: string;\n\t} = completion.choices?.[0]?.message ?? {};\n\tlet content: string = message?.content || \"\";\n\t// Provider-dependent reasoning shapes (non-streaming)\n\tconst r: string =\n\t\ttypeof message?.reasoning === \"string\"\n\t\t\t? (message.reasoning as string)\n\t\t\t: typeof message?.reasoning_content === \"string\"\n\t\t\t\t? (message.reasoning_content as string)\n\t\t\t\t: \"\";\n\tif (r && r.length > 0) {\n\t\tcontent = `<think>${r}</think>` + content;\n\t}\n\tconst tokenId = 0;\n\n\t// Yield the content as a single token\n\tyield {\n\t\ttoken: {\n\t\t\tid: tokenId,\n\t\t\ttext: content,\n\t\t\tlogprob: 0,\n\t\t\tspecial: false,\n\t\t},\n\t\tgenerated_text: content,\n\t\tdetails: null,\n\t\t...(getRouterMetadata\n\t\t\t? (() => {\n\t\t\t\t\tconst metadata = getRouterMetadata();\n\t\t\t\t\treturn (metadata && metadata.route && metadata.model) || metadata?.provider\n\t\t\t\t\t\t? { routerMetadata: metadata }\n\t\t\t\t\t\t: {};\n\t\t\t\t})()\n\t\t\t: {}),\n\t} as TextGenerationStreamOutput & {\n\t\trouterMetadata?: { route?: string; model?: string; provider?: string };\n\t};\n}\n"
  },
  {
    "path": "src/lib/server/endpoints/openai/openAICompletionToTextGenerationStream.ts",
    "content": "import type { TextGenerationStreamOutput } from \"@huggingface/inference\";\nimport type OpenAI from \"openai\";\nimport type { Stream } from \"openai/streaming\";\n\n/**\n * Transform a stream of OpenAI.Completions.Completion into a stream of TextGenerationStreamOutput\n */\nexport async function* openAICompletionToTextGenerationStream(\n\tcompletionStream: Stream<OpenAI.Completions.Completion>\n) {\n\tlet generatedText = \"\";\n\tlet tokenId = 0;\n\tfor await (const completion of completionStream) {\n\t\tconst { choices } = completion;\n\t\tconst text = choices?.[0]?.text ?? \"\";\n\t\tconst last = choices?.[0]?.finish_reason === \"stop\" || choices?.[0]?.finish_reason === \"length\";\n\t\tif (text) {\n\t\t\tgeneratedText = generatedText + text;\n\t\t}\n\t\tconst output: TextGenerationStreamOutput = {\n\t\t\ttoken: {\n\t\t\t\tid: tokenId++,\n\t\t\t\ttext,\n\t\t\t\tlogprob: 0,\n\t\t\t\tspecial: last,\n\t\t\t},\n\t\t\tgenerated_text: last ? generatedText : null,\n\t\t\tdetails: null,\n\t\t};\n\t\tyield output;\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/endpoints/preprocessMessages.ts",
    "content": "import type { Message } from \"$lib/types/Message\";\nimport type { EndpointMessage } from \"./endpoints\";\nimport { downloadFile } from \"../files/downloadFile\";\nimport type { ObjectId } from \"mongodb\";\n\nexport async function preprocessMessages(\n\tmessages: Message[],\n\tconvId: ObjectId\n): Promise<EndpointMessage[]> {\n\treturn Promise.resolve(messages)\n\t\t.then((msgs) => downloadFiles(msgs, convId))\n\t\t.then((msgs) => injectClipboardFiles(msgs))\n\t\t.then(stripEmptyInitialSystemMessage);\n}\n\nasync function downloadFiles(messages: Message[], convId: ObjectId): Promise<EndpointMessage[]> {\n\treturn Promise.all(\n\t\tmessages.map<Promise<EndpointMessage>>((message) =>\n\t\t\tPromise.all((message.files ?? []).map((file) => downloadFile(file.value, convId))).then(\n\t\t\t\t(files) => ({ ...message, files })\n\t\t\t)\n\t\t)\n\t);\n}\n\nasync function injectClipboardFiles(messages: EndpointMessage[]) {\n\treturn Promise.all(\n\t\tmessages.map((message) => {\n\t\t\tconst plaintextFiles = message.files\n\t\t\t\t?.filter((file) => file.mime === \"application/vnd.chatui.clipboard\")\n\t\t\t\t.map((file) => Buffer.from(file.value, \"base64\").toString(\"utf-8\"));\n\n\t\t\tif (!plaintextFiles || plaintextFiles.length === 0) return message;\n\n\t\t\treturn {\n\t\t\t\t...message,\n\t\t\t\tcontent: `${plaintextFiles.join(\"\\n\\n\")}\\n\\n${message.content}`,\n\t\t\t\tfiles: message.files?.filter((file) => file.mime !== \"application/vnd.chatui.clipboard\"),\n\t\t\t};\n\t\t})\n\t);\n}\n\n/**\n * Remove an initial system message if its content is empty/whitespace only.\n * This prevents sending an empty system prompt to any provider.\n */\nfunction stripEmptyInitialSystemMessage(messages: EndpointMessage[]): EndpointMessage[] {\n\tif (!messages?.length) return messages;\n\tconst first = messages[0];\n\tif (first?.from !== \"system\") return messages;\n\n\tconst content = first?.content as unknown;\n\tconst isEmpty = typeof content === \"string\" ? content.trim().length === 0 : false;\n\n\tif (isEmpty) {\n\t\treturn messages.slice(1);\n\t}\n\n\treturn messages;\n}\n"
  },
  {
    "path": "src/lib/server/exitHandler.ts",
    "content": "import { randomUUID } from \"$lib/utils/randomUuid\";\nimport { timeout } from \"$lib/utils/timeout\";\nimport { logger } from \"./logger\";\n\ntype ExitHandler = () => void | Promise<void>;\ntype ExitHandlerUnsubscribe = () => void;\n\nconst listeners = new Map<string, ExitHandler>();\n\nexport function onExit(cb: ExitHandler): ExitHandlerUnsubscribe {\n\tconst uuid = randomUUID();\n\tlisteners.set(uuid, cb);\n\treturn () => {\n\t\tlisteners.delete(uuid);\n\t};\n}\n\nasync function runExitHandler(handler: ExitHandler): Promise<void> {\n\treturn timeout(Promise.resolve().then(handler), 30_000).catch((err) => {\n\t\tlogger.error(err, \"Exit handler failed to run\");\n\t});\n}\n\nexport function initExitHandler() {\n\tlet signalCount = 0;\n\tconst exitHandler = async () => {\n\t\tif (signalCount === 1) {\n\t\t\tlogger.info(\"Received signal... Exiting\");\n\t\t\tawait Promise.all(Array.from(listeners.values()).map(runExitHandler));\n\t\t\tlogger.info(\"All exit handlers ran... Waiting for svelte server to exit\");\n\t\t}\n\t};\n\n\tprocess.on(\"SIGINT\", () => {\n\t\tsignalCount++;\n\n\t\tif (signalCount >= 2) {\n\t\t\tprocess.kill(process.pid, \"SIGKILL\");\n\t\t} else {\n\t\t\texitHandler().catch((err) => {\n\t\t\t\tlogger.error(err, \"Error in exit handler on SIGINT:\");\n\t\t\t\tprocess.kill(process.pid, \"SIGKILL\");\n\t\t\t});\n\t\t}\n\t});\n\n\tprocess.on(\"SIGTERM\", () => {\n\t\tsignalCount++;\n\n\t\tif (signalCount >= 2) {\n\t\t\tprocess.kill(process.pid, \"SIGKILL\");\n\t\t} else {\n\t\t\texitHandler().catch((err) => {\n\t\t\t\tlogger.error(err, \"Error in exit handler on SIGTERM:\");\n\t\t\t\tprocess.kill(process.pid, \"SIGKILL\");\n\t\t\t});\n\t\t}\n\t});\n}\n"
  },
  {
    "path": "src/lib/server/files/downloadFile.ts",
    "content": "import { error } from \"@sveltejs/kit\";\nimport { collections } from \"$lib/server/database\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport type { SharedConversation } from \"$lib/types/SharedConversation\";\nimport type { MessageFile } from \"$lib/types/Message\";\n\nexport async function downloadFile(\n\tsha256: string,\n\tconvId: Conversation[\"_id\"] | SharedConversation[\"_id\"]\n): Promise<MessageFile & { type: \"base64\" }> {\n\tconst fileId = collections.bucket.find({ filename: `${convId.toString()}-${sha256}` });\n\n\tconst file = await fileId.next();\n\tif (!file) {\n\t\terror(404, \"File not found\");\n\t}\n\tif (file.metadata?.conversation !== convId.toString()) {\n\t\terror(403, \"You don't have access to this file.\");\n\t}\n\n\tconst mime = file.metadata?.mime;\n\tconst name = file.filename;\n\n\tconst fileStream = collections.bucket.openDownloadStream(file._id);\n\n\tconst buffer = await new Promise<Buffer>((resolve, reject) => {\n\t\tconst chunks: Uint8Array[] = [];\n\t\tfileStream.on(\"data\", (chunk) => chunks.push(chunk));\n\t\tfileStream.on(\"error\", reject);\n\t\tfileStream.on(\"end\", () => resolve(Buffer.concat(chunks)));\n\t});\n\n\treturn { type: \"base64\", name, value: buffer.toString(\"base64\"), mime };\n}\n"
  },
  {
    "path": "src/lib/server/files/uploadFile.ts",
    "content": "import type { Conversation } from \"$lib/types/Conversation\";\nimport type { MessageFile } from \"$lib/types/Message\";\nimport { sha256 } from \"$lib/utils/sha256\";\nimport { fileTypeFromBuffer } from \"file-type\";\nimport { collections } from \"$lib/server/database\";\n\nexport async function uploadFile(file: File, conv: Conversation): Promise<MessageFile> {\n\tconst sha = await sha256(await file.text());\n\tconst buffer = await file.arrayBuffer();\n\n\t// Attempt to detect the mime type of the file, fallback to the uploaded mime\n\tconst mime = await fileTypeFromBuffer(buffer).then((fileType) => fileType?.mime ?? file.type);\n\n\tconst upload = collections.bucket.openUploadStream(`${conv._id}-${sha}`, {\n\t\tmetadata: { conversation: conv._id.toString(), mime },\n\t});\n\n\tupload.write((await file.arrayBuffer()) as unknown as Buffer);\n\tupload.end();\n\n\t// only return the filename when upload throws a finish event or a 20s time out occurs\n\treturn new Promise((resolve, reject) => {\n\t\tupload.once(\"finish\", () =>\n\t\t\tresolve({ type: \"hash\", value: sha, mime: file.type, name: file.name })\n\t\t);\n\t\tupload.once(\"error\", reject);\n\t\tsetTimeout(() => reject(new Error(\"Upload timed out\")), 20_000);\n\t});\n}\n"
  },
  {
    "path": "src/lib/server/findRepoRoot.ts",
    "content": "import { existsSync } from \"fs\";\nimport { join, dirname } from \"path\";\n\nexport function findRepoRoot(startPath: string): string {\n\tlet currentPath = startPath;\n\twhile (currentPath !== \"/\") {\n\t\tif (existsSync(join(currentPath, \"package.json\"))) {\n\t\t\treturn currentPath;\n\t\t}\n\t\tcurrentPath = dirname(currentPath);\n\t}\n\tthrow new Error(\"Could not find repository root (no package.json found)\");\n}\n"
  },
  {
    "path": "src/lib/server/generateFromDefaultEndpoint.ts",
    "content": "import { taskModel, models } from \"$lib/server/models\";\nimport { MessageUpdateType, type MessageUpdate } from \"$lib/types/MessageUpdate\";\nimport type { EndpointMessage } from \"./endpoints/endpoints\";\n\nexport async function* generateFromDefaultEndpoint({\n\tmessages,\n\tpreprompt,\n\tgenerateSettings,\n\tmodelId,\n\tlocals,\n}: {\n\tmessages: EndpointMessage[];\n\tpreprompt?: string;\n\tgenerateSettings?: Record<string, unknown>;\n\t/** Optional: use this model instead of the default task model */\n\tmodelId?: string;\n\tlocals: App.Locals | undefined;\n}): AsyncGenerator<MessageUpdate, string, undefined> {\n\ttry {\n\t\t// Choose endpoint based on provided modelId, else fall back to taskModel\n\t\tconst model = modelId ? (models.find((m) => m.id === modelId) ?? taskModel) : taskModel;\n\t\tconst endpoint = await model.getEndpoint();\n\t\tconst tokenStream = await endpoint({ messages, preprompt, generateSettings, locals });\n\n\t\tfor await (const output of tokenStream) {\n\t\t\t// if not generated_text is here it means the generation is not done\n\t\t\tif (output.generated_text) {\n\t\t\t\tlet generated_text = output.generated_text;\n\t\t\t\tfor (const stop of [...(model.parameters?.stop ?? []), \"<|endoftext|>\"]) {\n\t\t\t\t\tif (generated_text.endsWith(stop)) {\n\t\t\t\t\t\tgenerated_text = generated_text.slice(0, -stop.length).trimEnd();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn generated_text;\n\t\t\t}\n\t\t\tyield {\n\t\t\t\ttype: MessageUpdateType.Stream,\n\t\t\t\ttoken: output.token.text,\n\t\t\t};\n\t\t}\n\t} catch (error) {\n\t\treturn \"\";\n\t}\n\n\treturn \"\";\n}\n"
  },
  {
    "path": "src/lib/server/hooks/error.ts",
    "content": "import type { HandleServerError } from \"@sveltejs/kit\";\nimport { logger } from \"$lib/server/logger\";\n\ntype HandleServerErrorInput = Parameters<HandleServerError>[0];\n\nexport async function handleServerError({\n\terror,\n\tevent,\n\tstatus,\n\tmessage,\n}: HandleServerErrorInput): Promise<App.Error> {\n\t// handle 404\n\tif (event.route.id === null) {\n\t\treturn {\n\t\t\tmessage: `Page ${event.url.pathname} not found`,\n\t\t};\n\t}\n\n\tconst errorId = crypto.randomUUID();\n\n\tlogger.error({\n\t\tlocals: event.locals,\n\t\turl: event.request.url,\n\t\tparams: event.params,\n\t\trequest: event.request,\n\t\tmessage,\n\t\terror,\n\t\terrorId,\n\t\tstatus,\n\t\tstack: error instanceof Error ? error.stack : undefined,\n\t});\n\n\treturn {\n\t\tmessage: \"An error occurred\",\n\t\terrorId,\n\t};\n}\n"
  },
  {
    "path": "src/lib/server/hooks/fetch.ts",
    "content": "import type { HandleFetch } from \"@sveltejs/kit\";\nimport { isHostLocalhost } from \"$lib/server/isURLLocal\";\n\ntype HandleFetchInput = Parameters<HandleFetch>[0];\n\nexport async function handleFetchRequest({\n\tevent,\n\trequest,\n\tfetch,\n}: HandleFetchInput): Promise<Response> {\n\tif (isHostLocalhost(new URL(request.url).hostname)) {\n\t\tconst cookieHeader = event.request.headers.get(\"cookie\");\n\t\tif (cookieHeader) {\n\t\t\tconst headers = new Headers(request.headers);\n\t\t\theaders.set(\"cookie\", cookieHeader);\n\n\t\t\treturn fetch(new Request(request, { headers }));\n\t\t}\n\t}\n\n\treturn fetch(request);\n}\n"
  },
  {
    "path": "src/lib/server/hooks/handle.ts",
    "content": "import type { Handle, RequestEvent } from \"@sveltejs/kit\";\nimport { collections } from \"$lib/server/database\";\nimport { base } from \"$app/paths\";\nimport { dev } from \"$app/environment\";\nimport {\n\tauthenticateRequest,\n\tloginEnabled,\n\trefreshSessionCookie,\n\ttriggerOauthFlow,\n} from \"$lib/server/auth\";\nimport { ERROR_MESSAGES } from \"$lib/stores/errors\";\nimport { addWeeks } from \"date-fns\";\nimport { logger } from \"$lib/server/logger\";\nimport { adminTokenManager } from \"$lib/server/adminToken\";\nimport { isHostLocalhost } from \"$lib/server/isURLLocal\";\nimport { runWithRequestContext, updateRequestContext } from \"$lib/server/requestContext\";\nimport { config, ready } from \"$lib/server/config\";\n\ntype HandleInput = Parameters<Handle>[0];\n\nfunction getClientAddressSafe(event: RequestEvent): string | undefined {\n\ttry {\n\t\treturn event.getClientAddress();\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nexport async function handleRequest({ event, resolve }: HandleInput): Promise<Response> {\n\t// Generate a unique request ID for this request\n\tconst requestId = crypto.randomUUID();\n\n\t// Run the entire request handling within the request context\n\treturn runWithRequestContext(\n\t\tasync () => {\n\t\t\tawait ready.then(() => {\n\t\t\t\tconfig.checkForUpdates();\n\t\t\t});\n\n\t\t\tlogger.debug(\n\t\t\t\t{\n\t\t\t\t\tlocals: event.locals,\n\t\t\t\t\turl: event.url.pathname,\n\t\t\t\t\tparams: event.params,\n\t\t\t\t\trequest: event.request,\n\t\t\t\t},\n\t\t\t\t\"Request received\"\n\t\t\t);\n\n\t\t\tfunction errorResponse(status: number, message: string) {\n\t\t\t\tconst sendJson =\n\t\t\t\t\tevent.request.headers.get(\"accept\")?.includes(\"application/json\") ||\n\t\t\t\t\tevent.request.headers.get(\"content-type\")?.includes(\"application/json\");\n\t\t\t\treturn new Response(sendJson ? JSON.stringify({ error: message }) : message, {\n\t\t\t\t\tstatus,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t\"content-type\": sendJson ? \"application/json\" : \"text/plain\",\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tevent.url.pathname.startsWith(`${base}/admin/`) ||\n\t\t\t\tevent.url.pathname === `${base}/admin`\n\t\t\t) {\n\t\t\t\tconst ADMIN_SECRET = config.ADMIN_API_SECRET || config.PARQUET_EXPORT_SECRET;\n\n\t\t\t\tif (!ADMIN_SECRET) {\n\t\t\t\t\treturn errorResponse(500, \"Admin API is not configured\");\n\t\t\t\t}\n\n\t\t\t\tif (event.request.headers.get(\"Authorization\") !== `Bearer ${ADMIN_SECRET}`) {\n\t\t\t\t\treturn errorResponse(401, \"Unauthorized\");\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst isApi = event.url.pathname.startsWith(`${base}/api/`);\n\t\t\tconst auth = await authenticateRequest(\n\t\t\t\tevent.request.headers,\n\t\t\t\tevent.cookies,\n\t\t\t\tevent.url,\n\t\t\t\tisApi\n\t\t\t);\n\n\t\t\tevent.locals.sessionId = auth.sessionId;\n\n\t\t\tif (loginEnabled && !auth.user && !event.url.pathname.startsWith(`${base}/.well-known/`)) {\n\t\t\t\tif (config.AUTOMATIC_LOGIN === \"true\") {\n\t\t\t\t\t// AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages)\n\t\t\t\t\tif (\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/login`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/healthcheck`)\n\t\t\t\t\t) {\n\t\t\t\t\t\t// To get the same CSRF token after callback\n\t\t\t\t\t\trefreshSessionCookie(event.cookies, auth.secretSessionId);\n\t\t\t\t\t\treturn await triggerOauthFlow(event);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails)\n\t\t\t\t\tif (\n\t\t\t\t\t\tevent.url.pathname !== `${base}/` &&\n\t\t\t\t\t\tevent.url.pathname !== `${base}` &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/login`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/login/callback`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/healthcheck`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/r/`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/conversation/`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/models/`) &&\n\t\t\t\t\t\t!event.url.pathname.startsWith(`${base}/api`)\n\t\t\t\t\t) {\n\t\t\t\t\t\trefreshSessionCookie(event.cookies, auth.secretSessionId);\n\t\t\t\t\t\treturn triggerOauthFlow(event);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tevent.locals.user = auth.user || undefined;\n\t\t\tevent.locals.token = auth.token;\n\n\t\t\t// Update request context with user after authentication\n\t\t\tif (auth.user?.username) {\n\t\t\t\tupdateRequestContext({ user: auth.user.username });\n\t\t\t}\n\n\t\t\tevent.locals.isAdmin =\n\t\t\t\tevent.locals.user?.isAdmin || adminTokenManager.isAdmin(event.locals.sessionId);\n\n\t\t\t// CSRF protection\n\t\t\tconst requestContentType = event.request.headers.get(\"content-type\")?.split(\";\")[0] ?? \"\";\n\t\t\t/** https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype */\n\t\t\tconst nativeFormContentTypes = [\n\t\t\t\t\"multipart/form-data\",\n\t\t\t\t\"application/x-www-form-urlencoded\",\n\t\t\t\t\"text/plain\",\n\t\t\t];\n\n\t\t\tif (event.request.method === \"POST\") {\n\t\t\t\tif (nativeFormContentTypes.includes(requestContentType)) {\n\t\t\t\t\tconst origin = event.request.headers.get(\"origin\");\n\n\t\t\t\t\tif (!origin) {\n\t\t\t\t\t\treturn errorResponse(403, \"Non-JSON form requests need to have an origin\");\n\t\t\t\t\t}\n\n\t\t\t\t\tconst validOrigins = [\n\t\t\t\t\t\tnew URL(event.request.url).host,\n\t\t\t\t\t\t...(config.PUBLIC_ORIGIN ? [new URL(config.PUBLIC_ORIGIN).host] : []),\n\t\t\t\t\t];\n\n\t\t\t\t\tif (!validOrigins.includes(new URL(origin).host)) {\n\t\t\t\t\t\treturn errorResponse(403, \"Invalid referer for POST request\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tevent.request.method === \"POST\" ||\n\t\t\t\tevent.url.pathname.startsWith(`${base}/login`) ||\n\t\t\t\tevent.url.pathname.startsWith(`${base}/login/callback`)\n\t\t\t) {\n\t\t\t\t// if the request is a POST request or login-related we refresh the cookie\n\t\t\t\trefreshSessionCookie(event.cookies, auth.secretSessionId);\n\n\t\t\t\tawait collections.sessions.updateOne(\n\t\t\t\t\t{ sessionId: auth.sessionId },\n\t\t\t\t\t{ $set: { updatedAt: new Date(), expiresAt: addWeeks(new Date(), 2) } }\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tloginEnabled &&\n\t\t\t\t!event.locals.user &&\n\t\t\t\t!event.url.pathname.startsWith(`${base}/login`) &&\n\t\t\t\t!event.url.pathname.startsWith(`${base}/admin`) &&\n\t\t\t\t!event.url.pathname.startsWith(`${base}/settings`) &&\n\t\t\t\t![\"GET\", \"OPTIONS\", \"HEAD\"].includes(event.request.method)\n\t\t\t) {\n\t\t\t\treturn errorResponse(401, ERROR_MESSAGES.authOnly);\n\t\t\t}\n\n\t\t\tlet replaced = false;\n\n\t\t\tconst response = await resolve(event, {\n\t\t\t\ttransformPageChunk: (chunk) => {\n\t\t\t\t\t// For some reason, Sveltekit doesn't let us load env variables from .env in the app.html template\n\t\t\t\t\tif (replaced || !chunk.html.includes(\"%gaId%\")) {\n\t\t\t\t\t\treturn chunk.html;\n\t\t\t\t\t}\n\t\t\t\t\treplaced = true;\n\n\t\t\t\t\treturn chunk.html.replace(\"%gaId%\", config.PUBLIC_GOOGLE_ANALYTICS_ID);\n\t\t\t\t},\n\t\t\t\tfilterSerializedResponseHeaders: (header) => {\n\t\t\t\t\treturn header.includes(\"content-type\");\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Update request context with status code\n\t\t\tupdateRequestContext({ statusCode: response.status });\n\n\t\t\t// Add CSP header to control iframe embedding\n\t\t\t// Always allow huggingface.co; when ALLOW_IFRAME=true, allow all domains\n\t\t\tif (config.ALLOW_IFRAME !== \"true\") {\n\t\t\t\tresponse.headers.append(\n\t\t\t\t\t\"Content-Security-Policy\",\n\t\t\t\t\t\"frame-ancestors https://huggingface.co;\"\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tevent.url.pathname.startsWith(`${base}/login/callback`) ||\n\t\t\t\tevent.url.pathname.startsWith(`${base}/login`)\n\t\t\t) {\n\t\t\t\tresponse.headers.append(\"Cache-Control\", \"no-store\");\n\t\t\t}\n\n\t\t\tif (event.url.pathname.startsWith(`${base}/api/`)) {\n\t\t\t\t// get origin from the request\n\t\t\t\tconst requestOrigin = event.request.headers.get(\"origin\");\n\n\t\t\t\t// get origin from the config if its defined\n\t\t\t\tlet allowedOrigin = config.PUBLIC_ORIGIN ? new URL(config.PUBLIC_ORIGIN).origin : undefined;\n\n\t\t\t\tif (\n\t\t\t\t\tdev || // if we're in dev mode\n\t\t\t\t\t!requestOrigin || // or the origin is null (SSR)\n\t\t\t\t\tisHostLocalhost(new URL(requestOrigin).hostname) // or the origin is localhost\n\t\t\t\t) {\n\t\t\t\t\tallowedOrigin = \"*\"; // allow all origins\n\t\t\t\t} else if (allowedOrigin === requestOrigin) {\n\t\t\t\t\tallowedOrigin = requestOrigin; // echo back the caller\n\t\t\t\t}\n\n\t\t\t\tif (allowedOrigin) {\n\t\t\t\t\tresponse.headers.set(\"Access-Control-Allow-Origin\", allowedOrigin);\n\t\t\t\t\tresponse.headers.set(\n\t\t\t\t\t\t\"Access-Control-Allow-Methods\",\n\t\t\t\t\t\t\"GET, POST, PUT, PATCH, DELETE, OPTIONS\"\n\t\t\t\t\t);\n\t\t\t\t\tresponse.headers.set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogger.info(\"Request completed\");\n\n\t\t\treturn response;\n\t\t},\n\t\t{ requestId, url: event.url.pathname, ip: getClientAddressSafe(event) }\n\t);\n}\n"
  },
  {
    "path": "src/lib/server/hooks/init.ts",
    "content": "import { config, ready } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport { initExitHandler } from \"$lib/server/exitHandler\";\nimport { checkAndRunMigrations } from \"$lib/migrations/migrations\";\nimport { refreshConversationStats } from \"$lib/jobs/refresh-conversation-stats\";\nimport { loadMcpServersOnStartup } from \"$lib/server/mcp/registry\";\nimport { AbortedGenerations } from \"$lib/server/abortedGenerations\";\nimport { adminTokenManager } from \"$lib/server/adminToken\";\nimport { MetricsServer } from \"$lib/server/metrics\";\n\nexport async function initServer(): Promise<void> {\n\t// Wait for config to be fully loaded\n\tawait ready;\n\n\t// Ensure legacy env expected by some libs: map OPENAI_API_KEY -> HF_TOKEN if absent\n\tconst canonicalToken = config.OPENAI_API_KEY || config.HF_TOKEN;\n\tif (canonicalToken) {\n\t\tprocess.env.HF_TOKEN ??= canonicalToken;\n\t}\n\n\t// Warn if legacy-only var is used\n\tif (!config.OPENAI_API_KEY && config.HF_TOKEN) {\n\t\tlogger.warn(\n\t\t\t\"HF_TOKEN is deprecated in favor of OPENAI_API_KEY. Please migrate to OPENAI_API_KEY.\"\n\t\t);\n\t}\n\n\tlogger.info(\"Starting server...\");\n\tinitExitHandler();\n\n\tif (config.METRICS_ENABLED === \"true\") {\n\t\tMetricsServer.getInstance();\n\t}\n\n\tcheckAndRunMigrations();\n\trefreshConversationStats();\n\n\t// Load MCP servers at startup\n\tloadMcpServersOnStartup();\n\n\t// Init AbortedGenerations refresh process\n\tAbortedGenerations.getInstance();\n\n\tadminTokenManager.displayToken();\n\n\tif (config.EXPOSE_API) {\n\t\tlogger.warn(\n\t\t\t\"The EXPOSE_API flag has been deprecated. The API is now required for chat-ui to work.\"\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/isURLLocal.spec.ts",
    "content": "import { isURLLocal } from \"./isURLLocal\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"isURLLocal\", async () => {\n\tit(\"should return true for localhost\", async () => {\n\t\texpect(await isURLLocal(new URL(\"http://localhost\"))).toBe(true);\n\t});\n\tit(\"should return true for 127.0.0.1\", async () => {\n\t\texpect(await isURLLocal(new URL(\"http://127.0.0.1\"))).toBe(true);\n\t});\n\tit(\"should return true for 127.254.254.254\", async () => {\n\t\texpect(await isURLLocal(new URL(\"http://127.254.254.254\"))).toBe(true);\n\t});\n\tit(\"should return false for huggingface.co\", async () => {\n\t\texpect(await isURLLocal(new URL(\"https://huggingface.co/\"))).toBe(false);\n\t});\n\tit(\"should return true for 127.0.0.1.nip.io\", async () => {\n\t\texpect(await isURLLocal(new URL(\"http://127.0.0.1.nip.io\"))).toBe(true);\n\t});\n\tit(\"should fail on ipv6\", async () => {\n\t\tawait expect(isURLLocal(new URL(\"http://[::1]\"))).rejects.toThrow();\n\t});\n\tit(\"should fail on ipv6 --1.sslip.io\", async () => {\n\t\tawait expect(isURLLocal(new URL(\"http://--1.sslip.io\"))).rejects.toThrow();\n\t});\n\tit(\"should fail on invalid domain names\", async () => {\n\t\tawait expect(\n\t\t\tisURLLocal(new URL(\"http://34329487239847329874923948732984.com/\"))\n\t\t).rejects.toThrow();\n\t});\n});\n"
  },
  {
    "path": "src/lib/server/isURLLocal.ts",
    "content": "import { Address6, Address4 } from \"ip-address\";\nimport dns from \"node:dns\";\nimport { isIP } from \"node:net\";\n\nconst dnsLookup = (hostname: string): Promise<{ address: string; family: number }> => {\n\treturn new Promise((resolve, reject) => {\n\t\tdns.lookup(hostname, (err, address, family) => {\n\t\t\tif (err) return reject(err);\n\t\t\tresolve({ address, family });\n\t\t});\n\t});\n};\n\nfunction assertValidHostname(hostname: string): void {\n\tif (!hostname || hostname.length > 253) {\n\t\tthrow new Error(\"Invalid hostname\");\n\t}\n\n\tconst labels = hostname.split(\".\");\n\n\tfor (const label of labels) {\n\t\tif (!label || label.length > 63) {\n\t\t\tthrow new Error(\"Invalid hostname\");\n\t\t}\n\n\t\tif (!/^[A-Za-z0-9-]+$/.test(label)) {\n\t\t\tthrow new Error(\"Invalid hostname\");\n\t\t}\n\n\t\tif (label.startsWith(\"-\") || label.endsWith(\"-\")) {\n\t\t\tthrow new Error(\"Invalid hostname\");\n\t\t}\n\t}\n}\n\nexport async function isURLLocal(URL: URL): Promise<boolean> {\n\tif (!isIP(URL.hostname)) {\n\t\tassertValidHostname(URL.hostname);\n\t}\n\n\tconst { address, family } = await dnsLookup(URL.hostname);\n\n\tif (family === 4) {\n\t\tconst addr = new Address4(address);\n\t\tconst localSubnet = new Address4(\"127.0.0.0/8\");\n\t\treturn addr.isInSubnet(localSubnet);\n\t}\n\n\tif (family === 6) {\n\t\tconst addr = new Address6(address);\n\t\treturn addr.isLoopback() || addr.isInSubnet(new Address6(\"::1/128\")) || addr.isLinkLocal();\n\t}\n\n\tthrow Error(\"Unknown IP family\");\n}\n\nexport function isURLStringLocal(url: string) {\n\ttry {\n\t\tconst urlObj = new URL(url);\n\t\treturn isURLLocal(urlObj);\n\t} catch (e) {\n\t\t// assume local if URL parsing fails\n\t\treturn true;\n\t}\n}\n\nexport function isHostLocalhost(host: string): boolean {\n\tif (host === \"localhost\") return true;\n\tif (host === \"::1\" || host === \"[::1]\") return true;\n\tif (host.startsWith(\"127.\") && isIP(host)) return true;\n\tif (host.endsWith(\".localhost\")) return true;\n\n\treturn false;\n}\n"
  },
  {
    "path": "src/lib/server/logger.ts",
    "content": "import pino from \"pino\";\nimport { dev } from \"$app/environment\";\nimport { config } from \"$lib/server/config\";\nimport { getRequestContext } from \"$lib/server/requestContext\";\n\nlet options: pino.LoggerOptions = {};\n\nif (dev) {\n\toptions = {\n\t\ttransport: {\n\t\t\ttarget: \"pino-pretty\",\n\t\t\toptions: {\n\t\t\t\tcolorize: true,\n\t\t\t},\n\t\t},\n\t};\n}\n\nconst baseLogger = pino({\n\t...options,\n\tmessageKey: \"message\",\n\tlevel: config.LOG_LEVEL || \"info\",\n\tformatters: {\n\t\tlevel: (label) => {\n\t\t\treturn { level: label };\n\t\t},\n\t},\n\tmixin() {\n\t\tconst ctx = getRequestContext();\n\t\tif (!ctx) return {};\n\n\t\tconst result: Record<string, string | number> = {};\n\t\tif (ctx.requestId) result.request_id = ctx.requestId;\n\t\tif (ctx.url) result.url = ctx.url;\n\t\tif (ctx.ip) result.ip = ctx.ip;\n\t\tif (ctx.user) result.user = ctx.user;\n\t\tif (ctx.statusCode) result.status_code = ctx.statusCode;\n\t\treturn result;\n\t},\n});\n\nexport const logger = baseLogger;\n"
  },
  {
    "path": "src/lib/server/mcp/clientPool.ts",
    "content": "import { Client } from \"@modelcontextprotocol/sdk/client\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport type { McpServerConfig } from \"./httpClient\";\n\nconst pool = new Map<string, Client>();\n\nfunction keyOf(server: McpServerConfig) {\n\tconst headers = Object.entries(server.headers ?? {})\n\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t.map(([k, v]) => `${k}:${v}`)\n\t\t.join(\"|\\u0000|\");\n\treturn `${server.url}|${headers}`;\n}\n\nexport async function getClient(server: McpServerConfig, signal?: AbortSignal): Promise<Client> {\n\tconst key = keyOf(server);\n\tconst existing = pool.get(key);\n\tif (existing) return existing;\n\n\tlet firstError: unknown;\n\tconst client = new Client({ name: \"chat-ui-mcp\", version: \"0.1.0\" });\n\tconst url = new URL(server.url);\n\tconst requestInit: RequestInit = { headers: server.headers, signal };\n\ttry {\n\t\ttry {\n\t\t\tawait client.connect(new StreamableHTTPClientTransport(url, { requestInit }));\n\t\t} catch (httpErr) {\n\t\t\t// Remember the original HTTP transport error so we can surface it if the fallback also fails.\n\t\t\t// Today we always show the SSE message, which is misleading when the real failure was HTTP (e.g. 500).\n\t\t\tfirstError = httpErr;\n\t\t\tawait client.connect(new SSEClientTransport(url, { requestInit }));\n\t\t}\n\t} catch (err) {\n\t\ttry {\n\t\t\tawait client.close?.();\n\t\t} catch {}\n\t\t// Prefer the HTTP error if both transports fail; otherwise fall back to the last error.\n\t\tif (firstError) {\n\t\t\tconst message =\n\t\t\t\t\"HTTP transport failed: \" +\n\t\t\t\tString(firstError instanceof Error ? firstError.message : firstError) +\n\t\t\t\t\"; SSE fallback failed: \" +\n\t\t\t\tString(err instanceof Error ? err.message : err);\n\t\t\tthrow new Error(message, { cause: err instanceof Error ? err : undefined });\n\t\t}\n\t\tthrow err;\n\t}\n\n\tpool.set(key, client);\n\treturn client;\n}\n\nexport async function drainPool() {\n\tfor (const [key, client] of pool) {\n\t\ttry {\n\t\t\tawait client.close?.();\n\t\t} catch {}\n\t\tpool.delete(key);\n\t}\n}\n\nexport function evictFromPool(server: McpServerConfig): Client | undefined {\n\tconst key = keyOf(server);\n\tconst client = pool.get(key);\n\tif (client) {\n\t\tpool.delete(key);\n\t}\n\treturn client;\n}\n"
  },
  {
    "path": "src/lib/server/mcp/hf.ts",
    "content": "// Minimal shared helpers for HF MCP token forwarding\n\nexport const hasAuthHeader = (h?: Record<string, string>) =>\n\t!!h && Object.keys(h).some((k) => k.toLowerCase() === \"authorization\");\n\nexport const isStrictHfMcpLogin = (urlString: string) => {\n\ttry {\n\t\tconst u = new URL(urlString);\n\t\tconst host = u.hostname.toLowerCase();\n\t\tconst allowedHosts = new Set([\"hf.co\", \"huggingface.co\"]);\n\t\treturn (\n\t\t\tu.protocol === \"https:\" &&\n\t\t\tallowedHosts.has(host) &&\n\t\t\tu.pathname === \"/mcp\" &&\n\t\t\tu.search === \"?login\"\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n};\n\nexport const hasNonEmptyToken = (tok: unknown): tok is string =>\n\ttypeof tok === \"string\" && tok.trim().length > 0;\n\nexport const isExaMcpServer = (urlString: string): boolean => {\n\ttry {\n\t\tconst u = new URL(urlString);\n\t\treturn u.protocol === \"https:\" && u.hostname.toLowerCase() === \"mcp.exa.ai\";\n\t} catch {\n\t\treturn false;\n\t}\n};\n"
  },
  {
    "path": "src/lib/server/mcp/httpClient.ts",
    "content": "import { Client } from \"@modelcontextprotocol/sdk/client\";\nimport { getClient, evictFromPool } from \"./clientPool\";\nimport { config } from \"$lib/server/config\";\n\nfunction isConnectionClosedError(err: unknown): boolean {\n\tconst message = err instanceof Error ? err.message : String(err);\n\treturn message.includes(\"-32000\") || message.toLowerCase().includes(\"connection closed\");\n}\n\nexport interface McpServerConfig {\n\tname: string;\n\turl: string;\n\theaders?: Record<string, string>;\n}\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\n\nexport function getMcpToolTimeoutMs(): number {\n\tconst envValue = config.MCP_TOOL_TIMEOUT_MS;\n\tif (envValue) {\n\t\tconst parsed = parseInt(envValue, 10);\n\t\tif (!isNaN(parsed) && parsed > 0) {\n\t\t\treturn parsed;\n\t\t}\n\t}\n\treturn DEFAULT_TIMEOUT_MS;\n}\n\nexport type McpToolTextResponse = {\n\ttext: string;\n\t/** If the server returned structuredContent, include it raw */\n\tstructured?: unknown;\n\t/** Raw content blocks returned by the server, if any */\n\tcontent?: unknown[];\n};\n\nexport type McpToolProgress = {\n\tprogress: number;\n\ttotal?: number;\n\tmessage?: string;\n};\n\nexport async function callMcpTool(\n\tserver: McpServerConfig,\n\ttool: string,\n\targs: unknown = {},\n\t{\n\t\ttimeoutMs = DEFAULT_TIMEOUT_MS,\n\t\tsignal,\n\t\tclient,\n\t\tonProgress,\n\t}: {\n\t\ttimeoutMs?: number;\n\t\tsignal?: AbortSignal;\n\t\tclient?: Client;\n\t\tonProgress?: (progress: McpToolProgress) => void;\n\t} = {}\n): Promise<McpToolTextResponse> {\n\tconst normalizedArgs =\n\t\ttypeof args === \"object\" && args !== null && !Array.isArray(args)\n\t\t\t? (args as Record<string, unknown>)\n\t\t\t: undefined;\n\n\t// Get a (possibly pooled) client. The client itself was connected with a signal\n\t// that already composes outer cancellation. We still enforce a per-call timeout here.\n\tlet activeClient = client ?? (await getClient(server, signal));\n\n\tconst callToolOptions = {\n\t\tsignal,\n\t\ttimeout: timeoutMs,\n\t\t// Enable progress tokens so long-running tools keep extending the timeout.\n\t\tonprogress: (progress: McpToolProgress) => {\n\t\t\tonProgress?.({\n\t\t\t\tprogress: progress.progress,\n\t\t\t\ttotal: progress.total,\n\t\t\t\tmessage: progress.message,\n\t\t\t});\n\t\t},\n\t\tresetTimeoutOnProgress: true,\n\t};\n\n\tlet response;\n\ttry {\n\t\tresponse = await activeClient.callTool(\n\t\t\t{ name: tool, arguments: normalizedArgs },\n\t\t\tundefined,\n\t\t\tcallToolOptions\n\t\t);\n\t} catch (err) {\n\t\tif (!isConnectionClosedError(err)) {\n\t\t\tthrow err;\n\t\t}\n\n\t\t// Evict stale client and close it\n\t\tconst stale = evictFromPool(server);\n\t\tstale?.close?.().catch(() => {});\n\n\t\t// Retry with fresh client\n\t\tactiveClient = await getClient(server, signal);\n\t\tresponse = await activeClient.callTool(\n\t\t\t{ name: tool, arguments: normalizedArgs },\n\t\t\tundefined,\n\t\t\tcallToolOptions\n\t\t);\n\t}\n\n\tconst parts = Array.isArray(response?.content) ? (response.content as Array<unknown>) : [];\n\tconst textParts = parts\n\t\t.filter((part): part is { type: \"text\"; text: string } => {\n\t\t\tif (typeof part !== \"object\" || part === null) return false;\n\t\t\tconst obj = part as Record<string, unknown>;\n\t\t\treturn obj[\"type\"] === \"text\" && typeof obj[\"text\"] === \"string\";\n\t\t})\n\t\t.map((p) => p.text);\n\n\tconst text = textParts.join(\"\\n\");\n\tconst structured = (response as unknown as { structuredContent?: unknown })?.structuredContent;\n\tconst contentBlocks = Array.isArray(response?.content)\n\t\t? (response.content as unknown[])\n\t\t: undefined;\n\treturn { text, structured, content: contentBlocks };\n}\n"
  },
  {
    "path": "src/lib/server/mcp/registry.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport type { McpServerConfig } from \"./httpClient\";\nimport { resetMcpToolsCache } from \"./tools\";\n\nlet cachedRaw: string | null = null;\nlet cachedServers: McpServerConfig[] = [];\n\nfunction parseServers(raw: string): McpServerConfig[] {\n\tif (!raw) return [];\n\n\ttry {\n\t\tconst parsed = JSON.parse(raw);\n\t\tif (!Array.isArray(parsed)) return [];\n\n\t\treturn parsed\n\t\t\t.map((entry) => {\n\t\t\t\tif (!entry || typeof entry !== \"object\") return undefined;\n\t\t\t\tconst name = (entry as Record<string, unknown>).name;\n\t\t\t\tconst url = (entry as Record<string, unknown>).url;\n\t\t\t\tif (typeof name !== \"string\" || !name.trim()) return undefined;\n\t\t\t\tif (typeof url !== \"string\" || !url.trim()) return undefined;\n\n\t\t\t\tconst headersRaw = (entry as Record<string, unknown>).headers;\n\t\t\t\tlet headers: Record<string, string> | undefined;\n\t\t\t\tif (headersRaw && typeof headersRaw === \"object\" && !Array.isArray(headersRaw)) {\n\t\t\t\t\tconst headerEntries = Object.entries(headersRaw as Record<string, unknown>).filter(\n\t\t\t\t\t\t(entry): entry is [string, string] => typeof entry[1] === \"string\"\n\t\t\t\t\t);\n\t\t\t\t\theaders = Object.fromEntries(headerEntries);\n\t\t\t\t}\n\n\t\t\t\treturn headers ? { name, url, headers } : { name, url };\n\t\t\t})\n\t\t\t.filter((server): server is McpServerConfig => Boolean(server));\n\t} catch (error) {\n\t\tlogger.warn({ err: error }, \"[mcp] failed to parse MCP_SERVERS env\");\n\t\treturn [];\n\t}\n}\n\nfunction setServers(raw: string) {\n\tcachedServers = parseServers(raw);\n\tcachedRaw = raw;\n\tresetMcpToolsCache();\n\tlogger.debug({ count: cachedServers.length }, \"[mcp] loaded server configuration\");\n\tconsole.log(\n\t\t`[MCP] Loaded ${cachedServers.length} server(s):`,\n\t\tcachedServers.map((s) => s.name).join(\", \") || \"none\"\n\t);\n}\n\nexport function loadMcpServersOnStartup(): McpServerConfig[] {\n\tconst raw = config.MCP_SERVERS || \"[]\";\n\tsetServers(raw);\n\treturn cachedServers;\n}\n\nexport function refreshMcpServersIfChanged(): void {\n\tconst currentRaw = config.MCP_SERVERS || \"[]\";\n\tif (cachedRaw === null) {\n\t\tsetServers(currentRaw);\n\t\treturn;\n\t}\n\n\tif (currentRaw !== cachedRaw) {\n\t\tsetServers(currentRaw);\n\t}\n}\n\nexport function getMcpServers(): McpServerConfig[] {\n\tif (cachedRaw === null) {\n\t\tloadMcpServersOnStartup();\n\t}\n\treturn cachedServers;\n}\n"
  },
  {
    "path": "src/lib/server/mcp/tools.ts",
    "content": "import { Client } from \"@modelcontextprotocol/sdk/client\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport type { McpServerConfig } from \"./httpClient\";\nimport { logger } from \"$lib/server/logger\";\n// use console.* for lightweight diagnostics in production logs\n\nexport type OpenAiTool = {\n\ttype: \"function\";\n\tfunction: { name: string; description?: string; parameters?: Record<string, unknown> };\n};\n\nexport interface McpToolMapping {\n\tfnName: string;\n\tserver: string;\n\ttool: string;\n}\n\ninterface CacheEntry {\n\tfetchedAt: number;\n\tttlMs: number;\n\ttools: OpenAiTool[];\n\tmapping: Record<string, McpToolMapping>;\n}\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, CacheEntry>();\n\n// Per OpenAI tool/function name guidelines most providers enforce:\n//   ^[a-zA-Z0-9_-]{1,64}$\n// Dots are not universally accepted (e.g., MiniMax via HF router rejects them).\n// Normalize any disallowed characters (including \".\") to underscore and trim to 64 chars.\nfunction sanitizeName(name: string) {\n\treturn name.replace(/[^a-zA-Z0-9_-]/g, \"_\").slice(0, 64);\n}\n\nfunction buildCacheKey(servers: McpServerConfig[]): string {\n\tconst normalized = servers\n\t\t.map((server) => ({\n\t\t\tname: server.name,\n\t\t\turl: server.url,\n\t\t\theaders: server.headers\n\t\t\t\t? Object.entries(server.headers)\n\t\t\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t\t\t.map(([key, value]) => [key, value])\n\t\t\t\t: [],\n\t\t}))\n\t\t.sort((a, b) => {\n\t\t\tconst byName = a.name.localeCompare(b.name);\n\t\t\tif (byName !== 0) return byName;\n\t\t\treturn a.url.localeCompare(b.url);\n\t\t});\n\n\treturn JSON.stringify(normalized);\n}\n\ntype ListedTool = {\n\tname?: string;\n\tinputSchema?: Record<string, unknown>;\n\tdescription?: string;\n\tannotations?: { title?: string };\n};\n\nasync function listServerTools(\n\tserver: McpServerConfig,\n\topts: { signal?: AbortSignal } = {}\n): Promise<ListedTool[]> {\n\tconst url = new URL(server.url);\n\tconst client = new Client({ name: \"chat-ui-mcp\", version: \"0.1.0\" });\n\ttry {\n\t\ttry {\n\t\t\tconst transport = new StreamableHTTPClientTransport(url, {\n\t\t\t\trequestInit: { headers: server.headers, signal: opts.signal },\n\t\t\t});\n\t\t\tawait client.connect(transport);\n\t\t} catch {\n\t\t\tconst transport = new SSEClientTransport(url, {\n\t\t\t\trequestInit: { headers: server.headers, signal: opts.signal },\n\t\t\t});\n\t\t\tawait client.connect(transport);\n\t\t}\n\n\t\tconst response = await client.listTools({});\n\t\tconst tools = Array.isArray(response?.tools) ? (response.tools as ListedTool[]) : [];\n\t\ttry {\n\t\t\tlogger.debug(\n\t\t\t\t{\n\t\t\t\t\tserver: server.name,\n\t\t\t\t\turl: server.url,\n\t\t\t\t\tcount: tools.length,\n\t\t\t\t\ttoolNames: tools.map((t) => t?.name).filter(Boolean),\n\t\t\t\t},\n\t\t\t\t\"[mcp] listed tools from server\"\n\t\t\t);\n\t\t} catch {}\n\t\treturn tools;\n\t} finally {\n\t\ttry {\n\t\t\tawait client.close?.();\n\t\t} catch {\n\t\t\t// ignore close errors\n\t\t}\n\t}\n}\n\nexport async function getOpenAiToolsForMcp(\n\tservers: McpServerConfig[],\n\t{ ttlMs = DEFAULT_TTL_MS, signal }: { ttlMs?: number; signal?: AbortSignal } = {}\n): Promise<{ tools: OpenAiTool[]; mapping: Record<string, McpToolMapping> }> {\n\tconst now = Date.now();\n\tconst cacheKey = buildCacheKey(servers);\n\tconst cached = cache.get(cacheKey);\n\tif (cached && now - cached.fetchedAt < cached.ttlMs) {\n\t\treturn { tools: cached.tools, mapping: cached.mapping };\n\t}\n\n\tconst tools: OpenAiTool[] = [];\n\tconst mapping: Record<string, McpToolMapping> = {};\n\n\tconst seenNames = new Set<string>();\n\n\tconst pushToolDefinition = (\n\t\tname: string,\n\t\tdescription: string | undefined,\n\t\tparameters: Record<string, unknown> | undefined\n\t) => {\n\t\tif (seenNames.has(name)) return;\n\t\ttools.push({\n\t\t\ttype: \"function\",\n\t\t\tfunction: {\n\t\t\t\tname,\n\t\t\t\tdescription,\n\t\t\t\tparameters,\n\t\t\t},\n\t\t});\n\t\tseenNames.add(name);\n\t};\n\n\t// Fetch tools in parallel; tolerate individual failures\n\tconst tasks = servers.map((server) => listServerTools(server, { signal }));\n\tconst results = await Promise.allSettled(tasks);\n\n\tfor (let i = 0; i < results.length; i++) {\n\t\tconst server = servers[i];\n\t\tconst r = results[i];\n\t\tif (r.status === \"fulfilled\") {\n\t\t\tconst serverTools = r.value;\n\t\t\tfor (const tool of serverTools) {\n\t\t\t\tif (typeof tool.name !== \"string\" || tool.name.trim().length === 0) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst parameters =\n\t\t\t\t\ttool.inputSchema && typeof tool.inputSchema === \"object\" ? tool.inputSchema : undefined;\n\t\t\t\tconst description = tool.description ?? tool.annotations?.title;\n\t\t\t\tconst toolName = tool.name;\n\n\t\t\t\t// Emit a collision-aware function name.\n\t\t\t\t// Prefer the plain tool name; on conflict, suffix with server name.\n\t\t\t\tlet plainName = sanitizeName(toolName);\n\t\t\t\tif (plainName in mapping) {\n\t\t\t\t\tconst suffix = sanitizeName(server.name);\n\t\t\t\t\tconst candidate = `${plainName}_${suffix}`.slice(0, 64);\n\t\t\t\t\tif (!(candidate in mapping)) {\n\t\t\t\t\t\tplainName = candidate;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlet i = 2;\n\t\t\t\t\t\tlet next = `${candidate}_${i}`;\n\t\t\t\t\t\twhile (i < 10 && next in mapping) {\n\t\t\t\t\t\t\ti += 1;\n\t\t\t\t\t\t\tnext = `${candidate}_${i}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tplainName = next.slice(0, 64);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tpushToolDefinition(plainName, description, parameters);\n\t\t\t\tmapping[plainName] = {\n\t\t\t\t\tfnName: plainName,\n\t\t\t\t\tserver: server.name,\n\t\t\t\t\ttool: toolName,\n\t\t\t\t};\n\t\t\t}\n\t\t} else {\n\t\t\t// ignore failure for this server\n\t\t\tcontinue;\n\t\t}\n\t}\n\n\tcache.set(cacheKey, { fetchedAt: now, ttlMs, tools, mapping });\n\treturn { tools, mapping };\n}\n\nexport function resetMcpToolsCache() {\n\tcache.clear();\n}\n"
  },
  {
    "path": "src/lib/server/metrics.ts",
    "content": "import { collectDefaultMetrics, Counter, Registry, Summary } from \"prom-client\";\nimport { logger } from \"$lib/server/logger\";\nimport { config } from \"$lib/server/config\";\nimport { createServer, type Server as HttpServer } from \"http\";\nimport { onExit } from \"./exitHandler\";\n\ntype ModelLabel = \"model\";\ntype ToolLabel = \"tool\";\n\ninterface Metrics {\n\tmodel: {\n\t\tconversationsTotal: Counter<ModelLabel>;\n\t\tmessagesTotal: Counter<ModelLabel>;\n\t\ttokenCountTotal: Counter<ModelLabel>;\n\t\ttimePerOutputToken: Summary<ModelLabel>;\n\t\ttimeToFirstToken: Summary<ModelLabel>;\n\t\tlatency: Summary<ModelLabel>;\n\t\tvotesPositive: Counter<ModelLabel>;\n\t\tvotesNegative: Counter<ModelLabel>;\n\t};\n\twebSearch: {\n\t\trequestCount: Counter;\n\t\tpageFetchCount: Counter;\n\t\tpageFetchCountError: Counter;\n\t\tpageFetchDuration: Summary;\n\t\tembeddingDuration: Summary;\n\t};\n\ttool: {\n\t\ttoolUseCount: Counter<ToolLabel>;\n\t\ttoolUseCountError: Counter<ToolLabel>;\n\t\ttoolUseDuration: Summary<ToolLabel>;\n\t\ttimeToChooseTools: Summary<ModelLabel>;\n\t};\n}\n\nexport class MetricsServer {\n\tprivate static instance: MetricsServer | undefined;\n\tprivate readonly enabled: boolean;\n\tprivate readonly register: Registry;\n\tprivate readonly metrics: Metrics;\n\tprivate httpServer: HttpServer | undefined;\n\n\tprivate constructor() {\n\t\tthis.enabled = config.METRICS_ENABLED === \"true\";\n\t\tthis.register = new Registry();\n\n\t\tif (this.enabled) {\n\t\t\tcollectDefaultMetrics({ register: this.register });\n\t\t}\n\n\t\tthis.metrics = this.createMetrics();\n\n\t\tif (this.enabled) {\n\t\t\tthis.startStandaloneServer();\n\t\t}\n\t}\n\n\tpublic static getInstance(): MetricsServer {\n\t\tif (!MetricsServer.instance) {\n\t\t\tMetricsServer.instance = new MetricsServer();\n\t\t}\n\t\treturn MetricsServer.instance;\n\t}\n\n\tpublic static getMetrics(): Metrics {\n\t\treturn MetricsServer.getInstance().metrics;\n\t}\n\n\tpublic static isEnabled(): boolean {\n\t\treturn config.METRICS_ENABLED === \"true\";\n\t}\n\n\tpublic async render(): Promise<string> {\n\t\tif (!this.enabled) {\n\t\t\treturn \"\";\n\t\t}\n\n\t\treturn this.register.metrics();\n\t}\n\n\tprivate createMetrics(): Metrics {\n\t\tconst labelNames: ModelLabel[] = [\"model\"];\n\t\tconst toolLabelNames: ToolLabel[] = [\"tool\"];\n\n\t\tconst noopRegistry = new Registry();\n\n\t\tconst registry = this.enabled ? this.register : noopRegistry;\n\n\t\treturn {\n\t\t\tmodel: {\n\t\t\t\tconversationsTotal: new Counter<ModelLabel>({\n\t\t\t\t\tname: \"model_conversations_total\",\n\t\t\t\t\thelp: \"Total number of conversations\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\tmessagesTotal: new Counter<ModelLabel>({\n\t\t\t\t\tname: \"model_messages_total\",\n\t\t\t\t\thelp: \"Total number of messages\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\ttokenCountTotal: new Counter<ModelLabel>({\n\t\t\t\t\tname: \"model_token_count_total\",\n\t\t\t\t\thelp: \"Total number of tokens emitted by the model\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\ttimePerOutputToken: new Summary<ModelLabel>({\n\t\t\t\t\tname: \"model_time_per_output_token_ms\",\n\t\t\t\t\thelp: \"Per-token latency in milliseconds\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t\ttimeToFirstToken: new Summary<ModelLabel>({\n\t\t\t\t\tname: \"model_time_to_first_token_ms\",\n\t\t\t\t\thelp: \"Time to first token in milliseconds\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t\tlatency: new Summary<ModelLabel>({\n\t\t\t\t\tname: \"model_latency_ms\",\n\t\t\t\t\thelp: \"Total time to complete a response in milliseconds\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t\tvotesPositive: new Counter<ModelLabel>({\n\t\t\t\t\tname: \"model_votes_positive_total\",\n\t\t\t\t\thelp: \"Total number of positive votes on model messages\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\tvotesNegative: new Counter<ModelLabel>({\n\t\t\t\t\tname: \"model_votes_negative_total\",\n\t\t\t\t\thelp: \"Total number of negative votes on model messages\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t},\n\t\t\twebSearch: {\n\t\t\t\trequestCount: new Counter({\n\t\t\t\t\tname: \"web_search_request_count\",\n\t\t\t\t\thelp: \"Total number of web search requests\",\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\tpageFetchCount: new Counter({\n\t\t\t\t\tname: \"web_search_page_fetch_count\",\n\t\t\t\t\thelp: \"Total number of web search page fetches\",\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\tpageFetchCountError: new Counter({\n\t\t\t\t\tname: \"web_search_page_fetch_count_error\",\n\t\t\t\t\thelp: \"Total number of web search page fetch errors\",\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\tpageFetchDuration: new Summary({\n\t\t\t\t\tname: \"web_search_page_fetch_duration_ms\",\n\t\t\t\t\thelp: \"Duration of web search page fetches in milliseconds\",\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t\tembeddingDuration: new Summary({\n\t\t\t\t\tname: \"web_search_embedding_duration_ms\",\n\t\t\t\t\thelp: \"Duration of web search embeddings in milliseconds\",\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t},\n\t\t\ttool: {\n\t\t\t\ttoolUseCount: new Counter<ToolLabel>({\n\t\t\t\t\tname: \"tool_use_count\",\n\t\t\t\t\thelp: \"Total number of tool invocations\",\n\t\t\t\t\tlabelNames: toolLabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\ttoolUseCountError: new Counter<ToolLabel>({\n\t\t\t\t\tname: \"tool_use_count_error\",\n\t\t\t\t\thelp: \"Total number of tool invocation errors\",\n\t\t\t\t\tlabelNames: toolLabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t}),\n\t\t\t\ttoolUseDuration: new Summary<ToolLabel>({\n\t\t\t\t\tname: \"tool_use_duration_ms\",\n\t\t\t\t\thelp: \"Duration of tool invocations in milliseconds\",\n\t\t\t\t\tlabelNames: toolLabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 30 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t\ttimeToChooseTools: new Summary<ModelLabel>({\n\t\t\t\t\tname: \"time_to_choose_tools_ms\",\n\t\t\t\t\thelp: \"Time spent selecting tools in milliseconds\",\n\t\t\t\t\tlabelNames,\n\t\t\t\t\tregisters: [registry],\n\t\t\t\t\tmaxAgeSeconds: 5 * 60,\n\t\t\t\t\tageBuckets: 5,\n\t\t\t\t}),\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate startStandaloneServer() {\n\t\tconst port = Number(config.METRICS_PORT || \"5565\");\n\n\t\tif (!Number.isInteger(port) || port < 0 || port > 65535) {\n\t\t\tlogger.warn(`Invalid METRICS_PORT value: ${config.METRICS_PORT}`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.httpServer = createServer(async (req, res) => {\n\t\t\tif (req.method !== \"GET\") {\n\t\t\t\tres.statusCode = 405;\n\t\t\t\tres.end(\"Method Not Allowed\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst payload = await this.render();\n\t\t\t\tres.setHeader(\"Content-Type\", \"text/plain; version=0.0.4\");\n\t\t\t\tres.end(payload);\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error(error, \"Failed to render metrics\");\n\t\t\t\tres.statusCode = 500;\n\t\t\t\tres.end(\"Failed to render metrics\");\n\t\t\t}\n\t\t});\n\n\t\tthis.httpServer.listen(port, () => {\n\t\t\tlogger.info(`Metrics server listening on port ${port}`);\n\t\t});\n\n\t\tonExit(async () => {\n\t\t\tif (!this.httpServer) return;\n\t\t\tlogger.info(\"Shutting down metrics server...\");\n\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\tthis.httpServer?.close((err) => {\n\t\t\t\t\tif (err) {\n\t\t\t\t\t\treject(err);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t}).catch((error) => logger.error(error, \"Failed to close metrics server\"));\n\t\t\tthis.httpServer = undefined;\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/models.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport type { ChatTemplateInput } from \"$lib/types/Template\";\nimport { z } from \"zod\";\nimport endpoints, { endpointSchema, type Endpoint } from \"./endpoints/endpoints\";\n\nimport JSON5 from \"json5\";\nimport { logger } from \"$lib/server/logger\";\nimport { makeRouterEndpoint } from \"$lib/server/router/endpoint\";\n\ntype Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;\n\nconst sanitizeJSONEnv = (val: string, fallback: string) => {\n\tconst raw = (val ?? \"\").trim();\n\tconst unquoted = raw.startsWith(\"`\") && raw.endsWith(\"`\") ? raw.slice(1, -1) : raw;\n\treturn unquoted || fallback;\n};\n\nconst modelConfig = z.object({\n\t/** Used as an identifier in DB */\n\tid: z.string().optional(),\n\t/** Used to link to the model page, and for inference */\n\tname: z.string().default(\"\"),\n\tdisplayName: z.string().min(1).optional(),\n\tdescription: z.string().min(1).optional(),\n\tlogoUrl: z.string().url().optional(),\n\twebsiteUrl: z.string().url().optional(),\n\tmodelUrl: z.string().url().optional(),\n\ttokenizer: z.never().optional(),\n\tdatasetName: z.string().min(1).optional(),\n\tdatasetUrl: z.string().url().optional(),\n\tpreprompt: z.string().default(\"\"),\n\tprepromptUrl: z.string().url().optional(),\n\tchatPromptTemplate: z.never().optional(),\n\tpromptExamples: z\n\t\t.array(\n\t\t\tz.object({\n\t\t\t\ttitle: z.string().min(1),\n\t\t\t\tprompt: z.string().min(1),\n\t\t\t})\n\t\t)\n\t\t.optional(),\n\tendpoints: z.array(endpointSchema).optional(),\n\tproviders: z.array(z.object({ supports_tools: z.boolean().optional() }).passthrough()).optional(),\n\tparameters: z\n\t\t.object({\n\t\t\ttemperature: z.number().min(0).max(2).optional(),\n\t\t\ttruncate: z.number().int().positive().optional(),\n\t\t\tmax_tokens: z.number().int().positive().optional(),\n\t\t\tstop: z.array(z.string()).optional(),\n\t\t\ttop_p: z.number().positive().optional(),\n\t\t\ttop_k: z.number().positive().optional(),\n\t\t\tfrequency_penalty: z.number().min(-2).max(2).optional(),\n\t\t\tpresence_penalty: z.number().min(-2).max(2).optional(),\n\t\t})\n\t\t.passthrough()\n\t\t.optional(),\n\tmultimodal: z.boolean().default(false),\n\tmultimodalAcceptedMimetypes: z.array(z.string()).optional(),\n\t// Aggregated tool-calling capability across providers (HF router)\n\tsupportsTools: z.boolean().default(false),\n\tunlisted: z.boolean().default(false),\n\tembeddingModel: z.never().optional(),\n\t/** Used to enable/disable system prompt usage */\n\tsystemRoleSupported: z.boolean().default(true),\n});\n\ntype ModelConfig = z.infer<typeof modelConfig>;\n\nconst overrideEntrySchema = modelConfig\n\t.partial()\n\t.extend({\n\t\tid: z.string().optional(),\n\t\tname: z.string().optional(),\n\t})\n\t.refine((value) => Boolean((value.id ?? value.name)?.trim()), {\n\t\tmessage: \"Model override entry must provide an id or name\",\n\t});\n\ntype ModelOverride = z.infer<typeof overrideEntrySchema>;\n\nconst openaiBaseUrl = config.OPENAI_BASE_URL\n\t? config.OPENAI_BASE_URL.replace(/\\/$/, \"\")\n\t: undefined;\nconst isHFRouter = openaiBaseUrl === \"https://router.huggingface.co/v1\";\n\nconst listSchema = z\n\t.object({\n\t\tdata: z.array(\n\t\t\tz.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tdescription: z.string().optional(),\n\t\t\t\tproviders: z\n\t\t\t\t\t.array(z.object({ supports_tools: z.boolean().optional() }).passthrough())\n\t\t\t\t\t.optional(),\n\t\t\t\tarchitecture: z\n\t\t\t\t\t.object({\n\t\t\t\t\t\tinput_modalities: z.array(z.string()).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.passthrough()\n\t\t\t\t\t.optional(),\n\t\t\t})\n\t\t),\n\t})\n\t.passthrough();\n\nfunction getChatPromptRender(_m: ModelConfig): (inputs: ChatTemplateInput) => string {\n\t// Minimal template to support legacy \"completions\" flow if ever used.\n\t// We avoid any tokenizer/Jinja usage in this build.\n\treturn ({ messages, preprompt }) => {\n\t\tconst parts: string[] = [];\n\t\tif (preprompt) parts.push(`[SYSTEM]\\n${preprompt}`);\n\t\tfor (const msg of messages) {\n\t\t\tconst role = msg.from === \"assistant\" ? \"ASSISTANT\" : msg.from.toUpperCase();\n\t\t\tparts.push(`[${role}]\\n${msg.content}`);\n\t\t}\n\t\tparts.push(`[ASSISTANT]`);\n\t\treturn parts.join(\"\\n\\n\");\n\t};\n}\n\nconst processModel = async (m: ModelConfig) => ({\n\t...m,\n\tchatPromptRender: await getChatPromptRender(m),\n\tid: m.id || m.name,\n\tdisplayName: m.displayName || m.name,\n\tpreprompt: m.prepromptUrl ? await fetch(m.prepromptUrl).then((r) => r.text()) : m.preprompt,\n\tparameters: { ...m.parameters, stop_sequences: m.parameters?.stop },\n\tunlisted: m.unlisted ?? false,\n});\n\nconst addEndpoint = (m: Awaited<ReturnType<typeof processModel>>) => ({\n\t...m,\n\tgetEndpoint: async (): Promise<Endpoint> => {\n\t\tif (!m.endpoints || m.endpoints.length === 0) {\n\t\t\tthrow new Error(\"No endpoints configured. This build requires OpenAI-compatible endpoints.\");\n\t\t}\n\t\t// Only support OpenAI-compatible endpoints in this build\n\t\tconst endpoint = m.endpoints[0];\n\t\tif (endpoint.type !== \"openai\") {\n\t\t\tthrow new Error(\"Only 'openai' endpoint type is supported in this build\");\n\t\t}\n\t\treturn await endpoints.openai({ ...endpoint, model: m });\n\t},\n});\n\ntype InternalProcessedModel = Awaited<ReturnType<typeof addEndpoint>> & {\n\tisRouter: boolean;\n\thasInferenceAPI: boolean;\n};\n\nconst inferenceApiIds: string[] = [];\n\nconst getModelOverrides = (): ModelOverride[] => {\n\tconst overridesEnv = (Reflect.get(config, \"MODELS\") as string | undefined) ?? \"\";\n\n\tif (!overridesEnv.trim()) {\n\t\treturn [];\n\t}\n\n\ttry {\n\t\treturn z.array(overrideEntrySchema).parse(JSON5.parse(sanitizeJSONEnv(overridesEnv, \"[]\")));\n\t} catch (error) {\n\t\tlogger.error(error, \"[models] Failed to parse MODELS overrides\");\n\t\treturn [];\n\t}\n};\n\nexport type ModelsRefreshSummary = {\n\trefreshedAt: Date;\n\tdurationMs: number;\n\tadded: string[];\n\tremoved: string[];\n\tchanged: string[];\n\ttotal: number;\n};\n\nexport type ProcessedModel = InternalProcessedModel;\n\nexport let models: ProcessedModel[] = [];\nexport let defaultModel!: ProcessedModel;\nexport let taskModel!: ProcessedModel;\nexport let validModelIdSchema: z.ZodType<string> = z.string();\nexport let lastModelRefresh = new Date(0);\nexport let lastModelRefreshDurationMs = 0;\nexport let lastModelRefreshSummary: ModelsRefreshSummary = {\n\trefreshedAt: new Date(0),\n\tdurationMs: 0,\n\tadded: [],\n\tremoved: [],\n\tchanged: [],\n\ttotal: 0,\n};\n\nlet inflightRefresh: Promise<ModelsRefreshSummary> | null = null;\n\nconst createValidModelIdSchema = (modelList: ProcessedModel[]): z.ZodType<string> => {\n\tif (modelList.length === 0) {\n\t\tthrow new Error(\"No models available to build validation schema\");\n\t}\n\tconst ids = new Set(modelList.map((m) => m.id));\n\treturn z.string().refine((value) => ids.has(value), \"Invalid model id\");\n};\n\nconst resolveTaskModel = (modelList: ProcessedModel[]) => {\n\tif (modelList.length === 0) {\n\t\tthrow new Error(\"No models available to select task model\");\n\t}\n\n\tif (config.TASK_MODEL) {\n\t\tconst preferred = modelList.find(\n\t\t\t(m) => m.name === config.TASK_MODEL || m.id === config.TASK_MODEL\n\t\t);\n\t\tif (preferred) {\n\t\t\treturn preferred;\n\t\t}\n\t}\n\n\treturn modelList[0];\n};\n\nconst signatureForModel = (model: ProcessedModel) =>\n\tJSON.stringify({\n\t\tdescription: model.description,\n\t\tdisplayName: model.displayName,\n\t\tproviders: model.providers,\n\t\tparameters: model.parameters,\n\t\tpreprompt: model.preprompt,\n\t\tprepromptUrl: model.prepromptUrl,\n\t\tendpoints:\n\t\t\tmodel.endpoints?.map((endpoint) => {\n\t\t\t\tif (endpoint.type === \"openai\") {\n\t\t\t\t\tconst { type, baseURL } = endpoint;\n\t\t\t\t\treturn { type, baseURL };\n\t\t\t\t}\n\t\t\t\treturn { type: endpoint.type };\n\t\t\t}) ?? null,\n\t\tmultimodal: model.multimodal,\n\t\tmultimodalAcceptedMimetypes: model.multimodalAcceptedMimetypes,\n\t\tsupportsTools: (model as unknown as { supportsTools?: boolean }).supportsTools ?? false,\n\t\tisRouter: model.isRouter,\n\t\thasInferenceAPI: model.hasInferenceAPI,\n\t});\n\nconst applyModelState = (newModels: ProcessedModel[], startedAt: number): ModelsRefreshSummary => {\n\tif (newModels.length === 0) {\n\t\tthrow new Error(\"Failed to load any models from upstream\");\n\t}\n\n\tconst previousIds = new Set(models.map((m) => m.id));\n\tconst previousSignatures = new Map(models.map((m) => [m.id, signatureForModel(m)]));\n\tconst refreshedAt = new Date();\n\tconst durationMs = Date.now() - startedAt;\n\n\tmodels = newModels;\n\tdefaultModel = models[0];\n\ttaskModel = resolveTaskModel(models);\n\tvalidModelIdSchema = createValidModelIdSchema(models);\n\tlastModelRefresh = refreshedAt;\n\tlastModelRefreshDurationMs = durationMs;\n\n\tconst added = newModels.map((m) => m.id).filter((id) => !previousIds.has(id));\n\tconst removed = Array.from(previousIds).filter(\n\t\t(id) => !newModels.some((model) => model.id === id)\n\t);\n\tconst changed = newModels\n\t\t.filter((model) => {\n\t\t\tconst previousSignature = previousSignatures.get(model.id);\n\t\t\treturn previousSignature !== undefined && previousSignature !== signatureForModel(model);\n\t\t})\n\t\t.map((model) => model.id);\n\n\tconst summary: ModelsRefreshSummary = {\n\t\trefreshedAt,\n\t\tdurationMs,\n\t\tadded,\n\t\tremoved,\n\t\tchanged,\n\t\ttotal: models.length,\n\t};\n\n\tlastModelRefreshSummary = summary;\n\n\tlogger.info(\n\t\t{\n\t\t\ttotal: summary.total,\n\t\t\tadded: summary.added,\n\t\t\tremoved: summary.removed,\n\t\t\tchanged: summary.changed,\n\t\t\tdurationMs: summary.durationMs,\n\t\t},\n\t\t\"[models] Model cache refreshed\"\n\t);\n\n\treturn summary;\n};\n\nconst buildModels = async (): Promise<ProcessedModel[]> => {\n\tif (!openaiBaseUrl) {\n\t\tlogger.error(\n\t\t\t\"OPENAI_BASE_URL is required. Set it to an OpenAI-compatible base (e.g., https://router.huggingface.co/v1).\"\n\t\t);\n\t\tthrow new Error(\"OPENAI_BASE_URL not set\");\n\t}\n\n\ttry {\n\t\tconst baseURL = openaiBaseUrl;\n\t\tlogger.info({ baseURL }, \"[models] Using OpenAI-compatible base URL\");\n\n\t\t// Canonical auth token is OPENAI_API_KEY; keep HF_TOKEN as legacy alias\n\t\tconst authToken = config.OPENAI_API_KEY || config.HF_TOKEN;\n\n\t\t// Use auth token from the start if available to avoid rate limiting issues\n\t\t// Some APIs rate-limit unauthenticated requests more aggressively\n\t\tconst response = await fetch(`${baseURL}/models`, {\n\t\t\theaders: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,\n\t\t});\n\t\tlogger.info({ status: response.status }, \"[models] First fetch status\");\n\t\tif (!response.ok && response.status === 401 && !authToken) {\n\t\t\t// If we get 401 and didn't have a token, there's nothing we can do\n\t\t\tthrow new Error(\n\t\t\t\t`Failed to fetch ${baseURL}/models: ${response.status} ${response.statusText} (no auth token available)`\n\t\t\t);\n\t\t}\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`Failed to fetch ${baseURL}/models: ${response.status} ${response.statusText}`\n\t\t\t);\n\t\t}\n\t\tconst json = await response.json();\n\t\tlogger.info({ keys: Object.keys(json || {}) }, \"[models] Response keys\");\n\n\t\tconst parsed = listSchema.parse(json);\n\t\tlogger.info({ count: parsed.data.length }, \"[models] Parsed models count\");\n\n\t\tlet modelsRaw = parsed.data.map((m) => {\n\t\t\tlet logoUrl: string | undefined = undefined;\n\t\t\tif (isHFRouter && m.id.includes(\"/\")) {\n\t\t\t\tconst org = m.id.split(\"/\")[0];\n\t\t\t\tlogoUrl = `https://huggingface.co/api/avatars/${encodeURIComponent(org)}`;\n\t\t\t}\n\n\t\t\tconst inputModalities = (m.architecture?.input_modalities ?? []).map((modality) =>\n\t\t\t\tmodality.toLowerCase()\n\t\t\t);\n\t\t\tconst supportsImageInput =\n\t\t\t\tinputModalities.includes(\"image\") || inputModalities.includes(\"vision\");\n\n\t\t\t// If any provider supports tools, consider the model as supporting tools\n\t\t\tconst supportsTools = Boolean((m.providers ?? []).some((p) => p?.supports_tools === true));\n\t\t\treturn {\n\t\t\t\tid: m.id,\n\t\t\t\tname: m.id,\n\t\t\t\tdisplayName: m.id,\n\t\t\t\tdescription: m.description,\n\t\t\t\tlogoUrl,\n\t\t\t\tproviders: m.providers,\n\t\t\t\tmultimodal: supportsImageInput,\n\t\t\t\tmultimodalAcceptedMimetypes: supportsImageInput ? [\"image/*\"] : undefined,\n\t\t\t\tsupportsTools,\n\t\t\t\tendpoints: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"openai\" as const,\n\t\t\t\t\t\tbaseURL,\n\t\t\t\t\t\t// apiKey will be taken from OPENAI_API_KEY or HF_TOKEN automatically\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t} as ModelConfig;\n\t\t}) as ModelConfig[];\n\n\t\tconst overrides = getModelOverrides();\n\n\t\tif (overrides.length) {\n\t\t\tconst overrideMap = new Map<string, ModelOverride>();\n\t\t\tfor (const override of overrides) {\n\t\t\t\tfor (const key of [override.id, override.name]) {\n\t\t\t\t\tconst trimmed = key?.trim();\n\t\t\t\t\tif (trimmed) overrideMap.set(trimmed, override);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmodelsRaw = modelsRaw.map((model) => {\n\t\t\t\tconst override = overrideMap.get(model.id ?? \"\") ?? overrideMap.get(model.name ?? \"\");\n\t\t\t\tif (!override) return model;\n\n\t\t\t\tconst { id, name, ...rest } = override;\n\t\t\t\tvoid id;\n\t\t\t\tvoid name;\n\n\t\t\t\treturn {\n\t\t\t\t\t...model,\n\t\t\t\t\t...rest,\n\t\t\t\t};\n\t\t\t});\n\t\t}\n\n\t\tconst builtModels = await Promise.all(\n\t\t\tmodelsRaw.map((e) =>\n\t\t\t\tprocessModel(e)\n\t\t\t\t\t.then(addEndpoint)\n\t\t\t\t\t.then(async (m) => ({\n\t\t\t\t\t\t...m,\n\t\t\t\t\t\thasInferenceAPI: inferenceApiIds.includes(m.id ?? m.name),\n\t\t\t\t\t\t// router decoration added later\n\t\t\t\t\t\tisRouter: false as boolean,\n\t\t\t\t\t}))\n\t\t\t)\n\t\t);\n\n\t\tconst archBase = (config.LLM_ROUTER_ARCH_BASE_URL || \"\").trim();\n\t\tconst routerLabel = (config.PUBLIC_LLM_ROUTER_DISPLAY_NAME || \"Omni\").trim() || \"Omni\";\n\t\tconst routerLogo = (config.PUBLIC_LLM_ROUTER_LOGO_URL || \"\").trim();\n\t\tconst routerAliasId = (config.PUBLIC_LLM_ROUTER_ALIAS_ID || \"omni\").trim() || \"omni\";\n\t\tconst routerMultimodalEnabled =\n\t\t\t(config.LLM_ROUTER_ENABLE_MULTIMODAL || \"\").toLowerCase() === \"true\";\n\t\tconst routerToolsEnabled = (config.LLM_ROUTER_ENABLE_TOOLS || \"\").toLowerCase() === \"true\";\n\n\t\tlet decorated = builtModels as ProcessedModel[];\n\n\t\tif (archBase) {\n\t\t\t// Build a minimal model config for the alias\n\t\t\tconst aliasRaw = {\n\t\t\t\tid: routerAliasId,\n\t\t\t\tname: routerAliasId,\n\t\t\t\tdisplayName: routerLabel,\n\t\t\t\tdescription: \"Automatically routes your messages to the best model for your request.\",\n\t\t\t\tlogoUrl: routerLogo || undefined,\n\t\t\t\tpreprompt: \"\",\n\t\t\t\tendpoints: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"openai\" as const,\n\t\t\t\t\t\tbaseURL: openaiBaseUrl,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t// Keep the alias visible\n\t\t\t\tunlisted: false,\n\t\t\t} as ModelConfig;\n\n\t\t\tif (routerMultimodalEnabled) {\n\t\t\t\taliasRaw.multimodal = true;\n\t\t\t\taliasRaw.multimodalAcceptedMimetypes = [\"image/*\"];\n\t\t\t}\n\n\t\t\tif (routerToolsEnabled) {\n\t\t\t\taliasRaw.supportsTools = true;\n\t\t\t}\n\n\t\t\tconst aliasBase = await processModel(aliasRaw);\n\t\t\t// Create a self-referential ProcessedModel for the router endpoint\n\t\t\tconst aliasModel: ProcessedModel = {\n\t\t\t\t...aliasBase,\n\t\t\t\tisRouter: true,\n\t\t\t\thasInferenceAPI: false,\n\t\t\t\t// getEndpoint uses the router wrapper regardless of the endpoints array\n\t\t\t\tgetEndpoint: async (): Promise<Endpoint> => makeRouterEndpoint(aliasModel),\n\t\t\t} as ProcessedModel;\n\n\t\t\t// Put alias first\n\t\t\tdecorated = [aliasModel, ...decorated];\n\t\t}\n\n\t\treturn decorated;\n\t} catch (e) {\n\t\tlogger.error(e, \"Failed to load models from OpenAI base URL\");\n\t\tthrow e;\n\t}\n};\n\nconst rebuildModels = async (): Promise<ModelsRefreshSummary> => {\n\tconst startedAt = Date.now();\n\tconst newModels = await buildModels();\n\treturn applyModelState(newModels, startedAt);\n};\n\nawait rebuildModels();\n\nexport const refreshModels = async (): Promise<ModelsRefreshSummary> => {\n\tif (inflightRefresh) {\n\t\treturn inflightRefresh;\n\t}\n\n\tinflightRefresh = rebuildModels().finally(() => {\n\t\tinflightRefresh = null;\n\t});\n\n\treturn inflightRefresh;\n};\n\nexport const validateModel = (_models: BackendModel[]) => {\n\t// Zod enum function requires 2 parameters\n\treturn z.enum([_models[0].id, ..._models.slice(1).map((m) => m.id)]);\n};\n\n// if `TASK_MODEL` is string & name of a model in `MODELS`, then we use `MODELS[TASK_MODEL]`, else we try to parse `TASK_MODEL` as a model config itself\n\nexport type BackendModel = Optional<\n\ttypeof defaultModel,\n\t\"preprompt\" | \"parameters\" | \"multimodal\" | \"unlisted\" | \"hasInferenceAPI\"\n>;\n"
  },
  {
    "path": "src/lib/server/requestContext.ts",
    "content": "import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { randomUUID } from \"node:crypto\";\n\nexport interface RequestContext {\n\trequestId: string;\n\turl?: string;\n\tip?: string;\n\tuser?: string;\n\tstatusCode?: number;\n}\n\nconst asyncLocalStorage = new AsyncLocalStorage<RequestContext>();\n\n/**\n * Run a function within a request context.\n * All logs within this context will automatically include the requestId.\n */\nexport function runWithRequestContext<T>(\n\tfn: () => T,\n\tcontext: Partial<RequestContext> & { requestId?: string } = {}\n): T {\n\tconst fullContext: RequestContext = {\n\t\trequestId: context.requestId ?? randomUUID(),\n\t\turl: context.url,\n\t\tip: context.ip,\n\t\tuser: context.user,\n\t\tstatusCode: context.statusCode,\n\t};\n\treturn asyncLocalStorage.run(fullContext, fn);\n}\n\n/**\n * Update the current request context with additional information.\n * Useful for adding user information after authentication.\n */\nexport function updateRequestContext(updates: Partial<Omit<RequestContext, \"requestId\">>): void {\n\tconst store = asyncLocalStorage.getStore();\n\tif (store) {\n\t\tObject.assign(store, updates);\n\t}\n}\n\n/**\n * Get the current request context, if any.\n */\nexport function getRequestContext(): RequestContext | undefined {\n\treturn asyncLocalStorage.getStore();\n}\n\n/**\n * Get the current request ID, or undefined if not in a request context.\n */\nexport function getRequestId(): string | undefined {\n\treturn asyncLocalStorage.getStore()?.requestId;\n}\n"
  },
  {
    "path": "src/lib/server/router/arch.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport type { EndpointMessage } from \"../endpoints/endpoints\";\nimport type { Route, RouteConfig, RouteSelection } from \"./types\";\nimport { getRoutes } from \"./policy\";\nimport { getApiToken } from \"$lib/server/apiToken\";\n\nconst DEFAULT_LAST_TURNS = 16;\n\n/**\n * Trim a message by keeping start and end, replacing middle with minimal indicator.\n * Uses simple ellipsis since router only needs context for intent classification, not exact content.\n * @param content - The message content to trim\n * @param maxLength - Maximum total length (including indicator)\n * @returns Trimmed content with start, ellipsis, and end\n */\nfunction trimMiddle(content: string, maxLength: number): string {\n\tif (content.length <= maxLength) return content;\n\n\tconst indicator = \"…\";\n\tconst availableLength = maxLength - indicator.length;\n\n\tif (availableLength <= 0) {\n\t\t// If no room even for indicator, just hard truncate\n\t\treturn content.slice(0, maxLength);\n\t}\n\n\t// Reserve more space for the start (typically contains context)\n\tconst startLength = Math.ceil(availableLength * 0.6);\n\tconst endLength = availableLength - startLength;\n\n\t// Bug fix: slice(-0) returns entire string, so check for endLength <= 0\n\tif (endLength <= 0) {\n\t\t// Not enough space for end portion, just use start + indicator\n\t\treturn content.slice(0, availableLength) + indicator;\n\t}\n\n\tconst start = content.slice(0, startLength);\n\tconst end = content.slice(-endLength);\n\n\treturn start + indicator + end;\n}\n\nconst PROMPT_TEMPLATE = `\nYou are a helpful assistant designed to find the best suited route.\nYou are provided with route description within <routes></routes> XML tags:\n\n<routes>\n\n{routes}\n\n</routes>\n\n<conversation>\n\n{conversation}\n\n</conversation>\n\nYour task is to decide which route is best suit with user intent on the conversation in <conversation></conversation> XML tags.\n\nFollow those instructions:\n1. Use prior turns to choose the best route for the current message if needed.\n2. If no route match the full conversation respond with other route {\"route\": \"other\"}.\n3. Analyze the route descriptions and find the best match route for user latest intent.\n4. Respond only with the route name that best matches the user's request, using the exact name in the <routes> block.\nBased on your analysis, provide your response in the following JSON format if you decide to match any route:\n{\"route\": \"route_name\"}\n`.trim();\n\nfunction lastNTurns<T>(arr: T[], n = DEFAULT_LAST_TURNS) {\n\tif (!Array.isArray(arr)) return [] as T[];\n\treturn arr.slice(-n);\n}\n\nfunction toRouterPrompt(messages: EndpointMessage[], routes: Route[]) {\n\tconst simpleRoutes: RouteConfig[] = routes.map((r) => ({\n\t\tname: r.name,\n\t\tdescription: r.description,\n\t}));\n\tconst maxAssistantLength = parseInt(config.LLM_ROUTER_MAX_ASSISTANT_LENGTH || \"1000\", 10);\n\tconst maxPrevUserLength = parseInt(config.LLM_ROUTER_MAX_PREV_USER_LENGTH || \"1000\", 10);\n\n\tconst convo = messages\n\t\t.map((m) => ({ role: m.from, content: m.content }))\n\t\t.filter((m) => typeof m.content === \"string\" && m.content.trim() !== \"\");\n\n\t// Find the last user message index to preserve its full content\n\tconst lastUserIndex = convo.findLastIndex((m) => m.role === \"user\");\n\n\tconst trimmedConvo = convo.map((m, idx) => {\n\t\tif (typeof m.content !== \"string\") return m;\n\n\t\t// Trim assistant messages to reduce routing prompt size and improve latency\n\t\t// Keep start and end for better context understanding\n\t\tif (m.role === \"assistant\") {\n\t\t\treturn {\n\t\t\t\t...m,\n\t\t\t\tcontent: trimMiddle(m.content, maxAssistantLength),\n\t\t\t};\n\t\t}\n\n\t\t// Trim previous user messages, but keep the latest user message full\n\t\t// Keep start and end to preserve both context and question\n\t\tif (m.role === \"user\" && idx !== lastUserIndex) {\n\t\t\treturn {\n\t\t\t\t...m,\n\t\t\t\tcontent: trimMiddle(m.content, maxPrevUserLength),\n\t\t\t};\n\t\t}\n\n\t\treturn m;\n\t});\n\n\treturn PROMPT_TEMPLATE.replace(\"{routes}\", JSON.stringify(simpleRoutes)).replace(\n\t\t\"{conversation}\",\n\t\tJSON.stringify(lastNTurns(trimmedConvo))\n\t);\n}\n\nfunction parseRouteName(text: string): string | undefined {\n\tif (!text) return;\n\ttry {\n\t\tconst obj = JSON.parse(text);\n\t\tif (typeof obj?.route === \"string\" && obj.route.trim()) return obj.route.trim();\n\t} catch {}\n\tconst m = text.match(/[\"']route[\"']\\s*:\\s*[\"']([^\"']+)[\"']/);\n\tif (m?.[1]) return m[1].trim();\n\ttry {\n\t\tconst obj = JSON.parse(text.replace(/'/g, '\"'));\n\t\tif (typeof obj?.route === \"string\" && obj.route.trim()) return obj.route.trim();\n\t} catch {}\n\treturn;\n}\n\nexport async function archSelectRoute(\n\tmessages: EndpointMessage[],\n\ttraceId: string | undefined,\n\tlocals: App.Locals | undefined\n): Promise<RouteSelection> {\n\tconst routes = await getRoutes();\n\tconst prompt = toRouterPrompt(messages, routes);\n\n\tconst baseURL = (config.LLM_ROUTER_ARCH_BASE_URL || \"\").replace(/\\/$/, \"\");\n\tconst archModel = config.LLM_ROUTER_ARCH_MODEL || \"router/omni\";\n\n\tif (!baseURL) {\n\t\tlogger.warn(\"LLM_ROUTER_ARCH_BASE_URL not set; routing will fail over to fallback.\");\n\t\treturn { routeName: \"arch_router_failure\" };\n\t}\n\n\tconst headers: HeadersInit = {\n\t\tAuthorization: `Bearer ${getApiToken(locals)}`,\n\t\t\"Content-Type\": \"application/json\",\n\t\t// Bill to organization if configured (HuggingChat only)\n\t\t...(config.isHuggingChat && locals?.billingOrganization\n\t\t\t? { \"X-HF-Bill-To\": locals.billingOrganization }\n\t\t\t: {}),\n\t};\n\tconst body = {\n\t\tmodel: archModel,\n\t\tmessages: [{ role: \"user\", content: prompt }],\n\t\ttemperature: 0,\n\t\tmax_tokens: 16,\n\t\tstream: false,\n\t};\n\n\tconst ctrl = new AbortController();\n\tconst timeoutMs = Number(config.LLM_ROUTER_ARCH_TIMEOUT_MS || 10000);\n\tconst to = setTimeout(() => ctrl.abort(), timeoutMs);\n\n\ttry {\n\t\tconst resp = await fetch(`${baseURL}/chat/completions`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders,\n\t\t\tbody: JSON.stringify(body),\n\t\t\tsignal: ctrl.signal,\n\t\t});\n\t\tclearTimeout(to);\n\t\tif (!resp.ok) {\n\t\t\t// Extract error message from response\n\t\t\tlet errorMessage = `arch-router ${resp.status}`;\n\t\t\ttry {\n\t\t\t\tconst errorData = await resp.json();\n\t\t\t\t// Try to extract message from OpenAI-style error format\n\t\t\t\tif (errorData.error?.message) {\n\t\t\t\t\terrorMessage = errorData.error.message;\n\t\t\t\t} else if (errorData.message) {\n\t\t\t\t\terrorMessage = errorData.message;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// If JSON parsing fails, use status text\n\t\t\t\terrorMessage = resp.statusText || errorMessage;\n\t\t\t}\n\n\t\t\tlogger.warn(\n\t\t\t\t{ status: resp.status, error: errorMessage, traceId },\n\t\t\t\t\"[arch] router returned error\"\n\t\t\t);\n\n\t\t\treturn {\n\t\t\t\trouteName: \"arch_router_failure\",\n\t\t\t\terror: {\n\t\t\t\t\tmessage: errorMessage,\n\t\t\t\t\tstatusCode: resp.status,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\tconst data: { choices: { message: { content: string } }[] } = await resp.json();\n\t\tconst text = (data?.choices?.[0]?.message?.content ?? \"\").toString().trim();\n\t\tconst raw = parseRouteName(text);\n\n\t\tconst other = config.LLM_ROUTER_OTHER_ROUTE || \"casual_conversation\";\n\t\tconst chosen = raw === \"other\" ? other : raw || \"casual_conversation\";\n\t\tconst exists = routes.some((r) => r.name === chosen);\n\t\treturn { routeName: exists ? chosen : \"casual_conversation\" };\n\t} catch (e) {\n\t\tclearTimeout(to);\n\t\tconst err = e as Error;\n\t\tlogger.warn({ err: String(e), traceId }, \"arch router selection failed\");\n\n\t\t// Return error with context but no status code (network/timeout errors)\n\t\treturn {\n\t\t\trouteName: \"arch_router_failure\",\n\t\t\terror: {\n\t\t\t\tmessage: err.message || String(e),\n\t\t\t},\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/router/endpoint.ts",
    "content": "import type {\n\tEndpoint,\n\tEndpointParameters,\n\tEndpointMessage,\n\tTextGenerationStreamOutputSimplified,\n} from \"../endpoints/endpoints\";\nimport endpoints from \"../endpoints/endpoints\";\nimport type { ProcessedModel } from \"../models\";\nimport { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport { archSelectRoute } from \"./arch\";\nimport { getRoutes, resolveRouteModels } from \"./policy\";\nimport { getApiToken } from \"$lib/server/apiToken\";\nimport { ROUTER_FAILURE } from \"./types\";\nimport {\n\thasActiveToolsSelection,\n\tisRouterToolsBypassEnabled,\n\tpickToolsCapableModel,\n\tROUTER_TOOLS_ROUTE,\n} from \"./toolsRoute\";\nimport { getConfiguredMultimodalModelId } from \"./multimodal\";\n\nconst REASONING_BLOCK_REGEX = /<think>[\\s\\S]*?(?:<\\/think>|$)/g;\n\nconst ROUTER_MULTIMODAL_ROUTE = \"multimodal\";\n\n// Cache models at module level to avoid redundant dynamic imports on every request\nlet cachedModels: ProcessedModel[] | undefined;\n\nasync function getModels(): Promise<ProcessedModel[]> {\n\tif (!cachedModels) {\n\t\tconst mod = await import(\"../models\");\n\t\tcachedModels = (mod as { models: ProcessedModel[] }).models;\n\t}\n\treturn cachedModels;\n}\n\n/**\n * Custom error class that preserves HTTP status codes\n */\nclass HTTPError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic statusCode?: number\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"HTTPError\";\n\t}\n}\n\n/**\n * Extract the actual error message and status from OpenAI SDK errors or other upstream errors\n */\nfunction extractUpstreamError(error: unknown): { message: string; statusCode?: number } {\n\t// Check if it's an OpenAI APIError with structured error info\n\tif (error && typeof error === \"object\") {\n\t\tconst err = error as Record<string, unknown>;\n\n\t\t// OpenAI SDK error with error.error.message and status\n\t\tif (\n\t\t\terr.error &&\n\t\t\ttypeof err.error === \"object\" &&\n\t\t\t\"message\" in err.error &&\n\t\t\ttypeof err.error.message === \"string\"\n\t\t) {\n\t\t\treturn {\n\t\t\t\tmessage: err.error.message,\n\t\t\t\tstatusCode: typeof err.status === \"number\" ? err.status : undefined,\n\t\t\t};\n\t\t}\n\n\t\t// HTTPError or error with statusCode\n\t\tif (typeof err.statusCode === \"number\" && typeof err.message === \"string\") {\n\t\t\treturn { message: err.message, statusCode: err.statusCode };\n\t\t}\n\n\t\t// Error with status field\n\t\tif (typeof err.status === \"number\" && typeof err.message === \"string\") {\n\t\t\treturn { message: err.message, statusCode: err.status };\n\t\t}\n\n\t\t// Direct error message\n\t\tif (typeof err.message === \"string\") {\n\t\t\treturn { message: err.message };\n\t\t}\n\t}\n\n\treturn { message: String(error) };\n}\n\n/**\n * Determines if an error is a policy/entitlement error that should be shown to users immediately\n * (vs transient errors that should trigger fallback)\n */\nfunction isPolicyError(statusCode?: number): boolean {\n\tif (!statusCode) return false;\n\t// 400: Bad Request, 402: Payment Required, 401: Unauthorized, 403: Forbidden\n\treturn statusCode === 400 || statusCode === 401 || statusCode === 402 || statusCode === 403;\n}\n\nfunction stripReasoningBlocks(text: string): string {\n\tconst stripped = text.replace(REASONING_BLOCK_REGEX, \"\");\n\treturn stripped === text ? text : stripped.trim();\n}\n\nfunction stripReasoningFromMessage(message: EndpointMessage): EndpointMessage {\n\tconst content =\n\t\ttypeof message.content === \"string\" ? stripReasoningBlocks(message.content) : message.content;\n\treturn {\n\t\t...message,\n\t\tcontent,\n\t};\n}\n\n/**\n * Create an Endpoint that performs route selection via Arch and then forwards\n * to the selected model (with fallbacks) using the OpenAI-compatible endpoint.\n */\nexport async function makeRouterEndpoint(routerModel: ProcessedModel): Promise<Endpoint> {\n\treturn async function routerEndpoint(params: EndpointParameters) {\n\t\tconst routes = await getRoutes();\n\t\tconst sanitizedMessages = params.messages.map(stripReasoningFromMessage);\n\t\tconst routerMultimodalEnabled =\n\t\t\t(config.LLM_ROUTER_ENABLE_MULTIMODAL || \"\").toLowerCase() === \"true\";\n\t\tconst routerToolsEnabled = isRouterToolsBypassEnabled();\n\t\tconst hasImageInput = sanitizedMessages.some((message) =>\n\t\t\t(message.files ?? []).some(\n\t\t\t\t(file) => typeof file?.mime === \"string\" && file.mime.startsWith(\"image/\")\n\t\t\t)\n\t\t);\n\t\t// Tools are considered \"active\" if the client indicated any enabled MCP server\n\t\tconst hasToolsActive = hasActiveToolsSelection(params.locals);\n\n\t\t// Helper to create an OpenAI endpoint for a specific candidate model id\n\t\tasync function createCandidateEndpoint(candidateModelId: string): Promise<Endpoint> {\n\t\t\t// Try to use the real candidate model config if present in chat-ui's model list\n\t\t\tlet modelForCall: ProcessedModel | undefined;\n\t\t\ttry {\n\t\t\t\tconst all = await getModels();\n\t\t\t\tmodelForCall = all?.find((m) => m.id === candidateModelId || m.name === candidateModelId);\n\t\t\t} catch (e) {\n\t\t\t\tlogger.warn({ err: String(e) }, \"[router] failed to load models for candidate lookup\");\n\t\t\t}\n\n\t\t\tif (!modelForCall) {\n\t\t\t\t// Fallback: clone router model with candidate id\n\t\t\t\tmodelForCall = {\n\t\t\t\t\t...routerModel,\n\t\t\t\t\tid: candidateModelId,\n\t\t\t\t\tname: candidateModelId,\n\t\t\t\t\tdisplayName: candidateModelId,\n\t\t\t\t} as ProcessedModel;\n\t\t\t}\n\n\t\t\treturn endpoints.openai({\n\t\t\t\ttype: \"openai\",\n\t\t\t\tbaseURL: (config.OPENAI_BASE_URL || \"https://router.huggingface.co/v1\").replace(/\\/$/, \"\"),\n\t\t\t\tapiKey: getApiToken(params.locals),\n\t\t\t\tmodel: modelForCall,\n\t\t\t\t// Ensure streaming path is used\n\t\t\t\tstreamingSupported: true,\n\t\t\t});\n\t\t}\n\n\t\t// Yield router metadata for immediate UI display, using the actual candidate\n\t\tasync function* metadataThenStream(\n\t\t\tgen: AsyncGenerator<TextGenerationStreamOutputSimplified>,\n\t\t\tactualModel: string,\n\t\t\tselectedRoute: string\n\t\t) {\n\t\t\tyield {\n\t\t\t\ttoken: { id: 0, text: \"\", special: true, logprob: 0 },\n\t\t\t\tgenerated_text: null,\n\t\t\t\tdetails: null,\n\t\t\t\trouterMetadata: { route: selectedRoute, model: actualModel },\n\t\t\t};\n\t\t\tfor await (const ev of gen) yield ev;\n\t\t}\n\n\t\tif (routerMultimodalEnabled && hasImageInput) {\n\t\t\tlet multimodalCandidate: string | undefined;\n\t\t\ttry {\n\t\t\t\tconst all = await getModels();\n\t\t\t\tmultimodalCandidate = getConfiguredMultimodalModelId(all);\n\t\t\t} catch (e) {\n\t\t\t\tlogger.warn({ err: String(e) }, \"[router] failed to load models for multimodal lookup\");\n\t\t\t}\n\t\t\tif (!multimodalCandidate) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"Router multimodal is enabled but LLM_ROUTER_MULTIMODAL_MODEL is not correctly configured. Remove the image or configure a multimodal model via LLM_ROUTER_MULTIMODAL_MODEL.\"\n\t\t\t\t);\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tlogger.info(\n\t\t\t\t\t{ route: ROUTER_MULTIMODAL_ROUTE, model: multimodalCandidate },\n\t\t\t\t\t\"[router] multimodal input detected; bypassing Arch selection\"\n\t\t\t\t);\n\t\t\t\tconst ep = await createCandidateEndpoint(multimodalCandidate);\n\t\t\t\tconst gen = await ep({ ...params });\n\t\t\t\treturn metadataThenStream(gen, multimodalCandidate, ROUTER_MULTIMODAL_ROUTE);\n\t\t\t} catch (e) {\n\t\t\t\tconst { message, statusCode } = extractUpstreamError(e);\n\t\t\t\tlogger.error(\n\t\t\t\t\t{\n\t\t\t\t\t\troute: ROUTER_MULTIMODAL_ROUTE,\n\t\t\t\t\t\tmodel: multimodalCandidate,\n\t\t\t\t\t\terr: message,\n\t\t\t\t\t\t...(statusCode && { status: statusCode }),\n\t\t\t\t\t},\n\t\t\t\t\t\"[router] multimodal fallback failed\"\n\t\t\t\t);\n\t\t\t\tthrow statusCode ? new HTTPError(message, statusCode) : new Error(message);\n\t\t\t}\n\t\t}\n\n\t\tasync function findToolsCandidateModel(): Promise<ProcessedModel | undefined> {\n\t\t\ttry {\n\t\t\t\tconst all = await getModels();\n\t\t\t\treturn pickToolsCapableModel(all);\n\t\t\t} catch (e) {\n\t\t\t\tlogger.warn({ err: String(e) }, \"[router] failed to load models for tools lookup\");\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\n\t\tif (routerToolsEnabled && hasToolsActive) {\n\t\t\tconst toolsModel = await findToolsCandidateModel();\n\t\t\tconst toolsCandidate = toolsModel?.id ?? toolsModel?.name;\n\t\t\tif (!toolsCandidate) {\n\t\t\t\t// No tool-capable model found — continue with normal routing instead of hard failing\n\t\t\t} else {\n\t\t\t\ttry {\n\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t{ route: ROUTER_TOOLS_ROUTE, model: toolsCandidate },\n\t\t\t\t\t\t\"[router] tools active; bypassing Arch selection\"\n\t\t\t\t\t);\n\t\t\t\t\tconst ep = await createCandidateEndpoint(toolsCandidate);\n\t\t\t\t\tconst gen = await ep({ ...params });\n\t\t\t\t\treturn metadataThenStream(gen, toolsCandidate, ROUTER_TOOLS_ROUTE);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconst { message, statusCode } = extractUpstreamError(e);\n\t\t\t\t\tconst logData = {\n\t\t\t\t\t\troute: ROUTER_TOOLS_ROUTE,\n\t\t\t\t\t\tmodel: toolsCandidate,\n\t\t\t\t\t\terr: message,\n\t\t\t\t\t\t...(statusCode && { status: statusCode }),\n\t\t\t\t\t};\n\t\t\t\t\tif (statusCode === 402) {\n\t\t\t\t\t\tlogger.warn(logData, \"[router] tools fallback failed due to payment required\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.error(logData, \"[router] tools fallback failed\");\n\t\t\t\t\t}\n\t\t\t\t\tthrow statusCode ? new HTTPError(message, statusCode) : new Error(message);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst routeSelection = await archSelectRoute(sanitizedMessages, undefined, params.locals);\n\n\t\t// If arch router failed with an error, only hard-fail for policy errors (402/401/403)\n\t\t// For transient errors (5xx, timeouts, network), allow fallback to continue\n\t\tif (routeSelection.routeName === ROUTER_FAILURE && routeSelection.error) {\n\t\t\tconst { message, statusCode } = routeSelection.error;\n\n\t\t\tif (isPolicyError(statusCode)) {\n\t\t\t\t// Policy errors should be surfaced to the user immediately (e.g., subscription required)\n\t\t\t\tlogger.error(\n\t\t\t\t\t{ err: message, ...(statusCode && { status: statusCode }) },\n\t\t\t\t\t\"[router] arch router failed with policy error, propagating to client\"\n\t\t\t\t);\n\t\t\t\tthrow statusCode ? new HTTPError(message, statusCode) : new Error(message);\n\t\t\t}\n\n\t\t\t// Transient errors: log and continue to fallback\n\t\t\tlogger.warn(\n\t\t\t\t{ err: message, ...(statusCode && { status: statusCode }) },\n\t\t\t\t\"[router] arch router failed with transient error, attempting fallback\"\n\t\t\t);\n\t\t}\n\n\t\tconst fallbackModel = config.LLM_ROUTER_FALLBACK_MODEL || routerModel.id;\n\t\tconst { candidates } = resolveRouteModels(routeSelection.routeName, routes, fallbackModel);\n\n\t\tlet lastErr: unknown = undefined;\n\t\tfor (const candidate of candidates) {\n\t\t\ttry {\n\t\t\t\tlogger.info(\n\t\t\t\t\t{ route: routeSelection.routeName, model: candidate },\n\t\t\t\t\t\"[router] trying candidate\"\n\t\t\t\t);\n\t\t\t\tconst ep = await createCandidateEndpoint(candidate);\n\t\t\t\tconst gen = await ep({ ...params });\n\t\t\t\treturn metadataThenStream(gen, candidate, routeSelection.routeName);\n\t\t\t} catch (e) {\n\t\t\t\tlastErr = e;\n\t\t\t\tconst { message: errMsg, statusCode: errStatus } = extractUpstreamError(e);\n\t\t\t\tlogger.warn(\n\t\t\t\t\t{\n\t\t\t\t\t\troute: routeSelection.routeName,\n\t\t\t\t\t\tmodel: candidate,\n\t\t\t\t\t\terr: errMsg,\n\t\t\t\t\t\t...(errStatus && { status: errStatus }),\n\t\t\t\t\t},\n\t\t\t\t\t\"[router] candidate failed\"\n\t\t\t\t);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\t// Exhausted all candidates — throw to signal upstream failure\n\t\t// Forward the upstream error to the client\n\t\tconst { message, statusCode } = extractUpstreamError(lastErr);\n\t\tthrow statusCode ? new HTTPError(message, statusCode) : new Error(message);\n\t};\n}\n"
  },
  {
    "path": "src/lib/server/router/multimodal.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport type { ProcessedModel } from \"../models\";\n\n/**\n * Returns the configured multimodal model when it exists and is valid.\n * - Requires LLM_ROUTER_MULTIMODAL_MODEL to be set (id or name).\n * - Ignores router aliases and non-multimodal models.\n */\nexport function findConfiguredMultimodalModel(\n\tmodels: ProcessedModel[] | undefined\n): ProcessedModel | undefined {\n\tconst preferredModelId = (config.LLM_ROUTER_MULTIMODAL_MODEL || \"\").trim();\n\tif (!preferredModelId || !models?.length) return undefined;\n\n\treturn models.find(\n\t\t(candidate) =>\n\t\t\t(candidate.id === preferredModelId || candidate.name === preferredModelId) &&\n\t\t\t!candidate.isRouter &&\n\t\t\tcandidate.multimodal\n\t);\n}\n\nexport function getConfiguredMultimodalModelId(\n\tmodels: ProcessedModel[] | undefined\n): string | undefined {\n\tconst model = findConfiguredMultimodalModel(models);\n\treturn model?.id ?? model?.name;\n}\n"
  },
  {
    "path": "src/lib/server/router/policy.ts",
    "content": "import { readFile } from \"node:fs/promises\";\nimport { config } from \"$lib/server/config\";\nimport type { Route } from \"./types\";\n\nlet ROUTES: Route[] = [];\nlet loaded = false;\n\nexport async function loadPolicy(): Promise<Route[]> {\n\tconst path = config.LLM_ROUTER_ROUTES_PATH;\n\tconst text = await readFile(path, \"utf8\");\n\tconst arr = JSON.parse(text) as Route[];\n\tif (!Array.isArray(arr)) {\n\t\tthrow new Error(\"Routes config must be a flat array of routes\");\n\t}\n\tconst seen = new Set<string>();\n\tfor (const r of arr) {\n\t\tif (!r?.name || !r?.description || !r?.primary_model) {\n\t\t\tthrow new Error(`Invalid route entry: ${JSON.stringify(r)}`);\n\t\t}\n\t\tif (seen.has(r.name)) {\n\t\t\tthrow new Error(`Duplicate route name: ${r.name}`);\n\t\t}\n\t\tseen.add(r.name);\n\t}\n\tROUTES = arr;\n\tloaded = true;\n\treturn ROUTES;\n}\n\nexport async function getRoutes(): Promise<Route[]> {\n\tif (!loaded) await loadPolicy();\n\treturn ROUTES;\n}\n\nexport function resolveRouteModels(\n\trouteName: string,\n\troutes: Route[],\n\tfallbackModel: string\n): { candidates: string[] } {\n\tif (routeName === \"arch_router_failure\") {\n\t\treturn { candidates: [fallbackModel] };\n\t}\n\tconst sel =\n\t\troutes.find((r) => r.name === routeName) ||\n\t\troutes.find((r) => r.name === \"casual_conversation\");\n\tif (!sel) return { candidates: [fallbackModel] };\n\tconst fallbacks = Array.isArray(sel.fallback_models) ? sel.fallback_models : [];\n\treturn { candidates: [sel.primary_model, ...fallbacks] };\n}\n"
  },
  {
    "path": "src/lib/server/router/toolsRoute.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport type { ProcessedModel } from \"../models\";\n\nexport const ROUTER_TOOLS_ROUTE = \"agentic\";\n\ntype LocalsWithMcp = App.Locals & {\n\tmcp?: {\n\t\tselectedServers?: unknown[];\n\t\tselectedServerNames?: unknown[];\n\t};\n};\n\nexport function isRouterToolsBypassEnabled(): boolean {\n\treturn (config.LLM_ROUTER_ENABLE_TOOLS || \"\").toLowerCase() === \"true\";\n}\n\nexport function hasActiveToolsSelection(locals: App.Locals | undefined): boolean {\n\ttry {\n\t\tconst reqMcp = (locals as LocalsWithMcp | undefined)?.mcp;\n\t\tconst byConfig =\n\t\t\tArray.isArray(reqMcp?.selectedServers) && (reqMcp?.selectedServers?.length ?? 0) > 0;\n\t\tconst byName =\n\t\t\tArray.isArray(reqMcp?.selectedServerNames) && (reqMcp?.selectedServerNames?.length ?? 0) > 0;\n\t\treturn Boolean(byConfig || byName);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nexport function pickToolsCapableModel(\n\tmodels: ProcessedModel[] | undefined\n): ProcessedModel | undefined {\n\tconst preferredRaw = (config as unknown as Record<string, string>).LLM_ROUTER_TOOLS_MODEL;\n\tconst preferred = preferredRaw?.trim();\n\tif (!preferred) {\n\t\tlogger.warn(\"[router] tools bypass requested but LLM_ROUTER_TOOLS_MODEL is not set\");\n\t\treturn undefined;\n\t}\n\tif (!models?.length) return undefined;\n\tconst found = models.find((m) => m.id === preferred || m.name === preferred);\n\tif (!found) {\n\t\tlogger.warn(\n\t\t\t{ configuredModel: preferred },\n\t\t\t\"[router] configured tools model not found; falling back to Arch routing\"\n\t\t);\n\t\treturn undefined;\n\t}\n\tlogger.info({ model: found.id ?? found.name }, \"[router] using configured tools model\");\n\treturn found;\n}\n"
  },
  {
    "path": "src/lib/server/router/types.ts",
    "content": "export interface Route {\n\tname: string;\n\tdescription: string;\n\tprimary_model: string;\n\tfallback_models?: string[];\n}\n\nexport interface RouteConfig {\n\tname: string;\n\tdescription: string;\n}\n\nexport interface RouteSelection {\n\trouteName: string;\n\terror?: {\n\t\tmessage: string;\n\t\tstatusCode?: number;\n\t};\n}\n\nexport const ROUTER_FAILURE = \"arch_router_failure\";\n"
  },
  {
    "path": "src/lib/server/sendSlack.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\n\nexport async function sendSlack(text: string) {\n\tif (!config.WEBHOOK_URL_REPORT_ASSISTANT) {\n\t\tlogger.warn(\"WEBHOOK_URL_REPORT_ASSISTANT is not set, tried to send a slack message.\");\n\t\treturn;\n\t}\n\n\tconst res = await fetch(config.WEBHOOK_URL_REPORT_ASSISTANT, {\n\t\tmethod: \"POST\",\n\t\theaders: {\n\t\t\t\"Content-type\": \"application/json\",\n\t\t},\n\t\tbody: JSON.stringify({\n\t\t\ttext,\n\t\t}),\n\t});\n\n\tif (!res.ok) {\n\t\tlogger.error(`Webhook message failed. ${res.statusText} ${res.text}`);\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/generate.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport {\n\tMessageReasoningUpdateType,\n\tMessageUpdateType,\n\ttype MessageUpdate,\n} from \"$lib/types/MessageUpdate\";\nimport { AbortedGenerations } from \"../abortedGenerations\";\nimport type { TextGenerationContext } from \"./types\";\nimport type { EndpointMessage } from \"../endpoints/endpoints\";\nimport { generateFromDefaultEndpoint } from \"../generateFromDefaultEndpoint\";\nimport { generateSummaryOfReasoning } from \"./reasoning\";\nimport { logger } from \"../logger\";\n\ntype GenerateContext = Omit<TextGenerationContext, \"messages\"> & { messages: EndpointMessage[] };\n\nexport async function* generate(\n\t{\n\t\tmodel,\n\t\tendpoint,\n\t\tconv,\n\t\tmessages,\n\t\tassistant,\n\t\tpromptedAt,\n\t\tforceMultimodal,\n\t\tprovider,\n\t\tlocals,\n\t\tabortController,\n\t}: GenerateContext,\n\tpreprompt?: string\n): AsyncIterable<MessageUpdate> {\n\t// Reasoning mode support\n\tlet reasoning = false;\n\tlet reasoningBuffer = \"\";\n\tlet lastReasoningUpdate = new Date();\n\tlet status = \"\";\n\tconst startTime = new Date();\n\tconst modelReasoning = Reflect.get(model, \"reasoning\") as\n\t\t| { type: string; beginToken?: string; endToken?: string; regex?: string }\n\t\t| undefined;\n\tif (\n\t\tmodelReasoning &&\n\t\t(modelReasoning.type === \"regex\" ||\n\t\t\tmodelReasoning.type === \"summarize\" ||\n\t\t\t(modelReasoning.type === \"tokens\" && modelReasoning.beginToken === \"\"))\n\t) {\n\t\t// Starts in reasoning mode and we extract the answer from the reasoning\n\t\treasoning = true;\n\t\tyield {\n\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\tstatus: \"Started reasoning...\",\n\t\t};\n\t}\n\n\tconst stream = await endpoint({\n\t\tmessages,\n\t\tpreprompt,\n\t\tgenerateSettings: assistant?.generateSettings,\n\t\t// Allow user-level override to force multimodal\n\t\tisMultimodal: (forceMultimodal ?? false) || model.multimodal,\n\t\tconversationId: conv._id,\n\t\tlocals,\n\t\tabortSignal: abortController.signal,\n\t\tprovider,\n\t});\n\n\tfor await (const output of stream) {\n\t\t// Check if this output contains router metadata. Emit if either:\n\t\t// 1) route+model are present (router models), or\n\t\t// 2) provider-only is present (non-router models exposing x-inference-provider)\n\t\tif (\"routerMetadata\" in output && output.routerMetadata) {\n\t\t\tconst hasRouteModel = Boolean(output.routerMetadata.route && output.routerMetadata.model);\n\t\t\tconst hasProviderOnly = Boolean(output.routerMetadata.provider);\n\t\t\tif (hasRouteModel || hasProviderOnly) {\n\t\t\t\tyield {\n\t\t\t\t\ttype: MessageUpdateType.RouterMetadata,\n\t\t\t\t\troute: output.routerMetadata.route || \"\",\n\t\t\t\t\tmodel: output.routerMetadata.model || \"\",\n\t\t\t\t\tprovider:\n\t\t\t\t\t\t(output.routerMetadata\n\t\t\t\t\t\t\t.provider as unknown as import(\"@huggingface/inference\").InferenceProvider) ||\n\t\t\t\t\t\tundefined,\n\t\t\t\t};\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\t\t// text generation completed\n\t\tif (output.generated_text) {\n\t\t\t// If an abort happened just before final output, stop here and let\n\t\t\t// the caller emit an interrupted final answer with partial text.\n\t\t\tconst abortTime = AbortedGenerations.getInstance().getAbortTime(conv._id.toString());\n\t\t\tif (abortController.signal.aborted || (abortTime && abortTime > promptedAt)) {\n\t\t\t\tif (!abortController.signal.aborted) {\n\t\t\t\t\tabortController.abort();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tlet interrupted =\n\t\t\t\t!output.token.special && !model.parameters.stop?.includes(output.token.text);\n\n\t\t\tlet text = output.generated_text.trimEnd();\n\t\t\tfor (const stopToken of model.parameters.stop ?? []) {\n\t\t\t\tif (!text.endsWith(stopToken)) continue;\n\n\t\t\t\tinterrupted = false;\n\t\t\t\ttext = text.slice(0, text.length - stopToken.length);\n\t\t\t}\n\n\t\t\tlet finalAnswer = text;\n\t\t\tif (modelReasoning && modelReasoning.type === \"regex\" && modelReasoning.regex) {\n\t\t\t\tconst regex = new RegExp(modelReasoning.regex);\n\t\t\t\tfinalAnswer = regex.exec(reasoningBuffer)?.[1] ?? text;\n\t\t\t} else if (modelReasoning && modelReasoning.type === \"summarize\") {\n\t\t\t\tyield {\n\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\t\t\tstatus: \"Summarizing reasoning...\",\n\t\t\t\t};\n\t\t\t\ttry {\n\t\t\t\t\tconst summary = yield* generateFromDefaultEndpoint({\n\t\t\t\t\t\tmessages: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tfrom: \"user\",\n\t\t\t\t\t\t\t\tcontent: `Question: ${messages[messages.length - 1].content}\\n\\nReasoning: ${reasoningBuffer}`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tpreprompt: `Your task is to summarize concisely all your reasoning steps and then give the final answer. Keep it short, one short paragraph at most. If the reasoning steps explicitly include a code solution, make sure to include it in your answer.`,\n\t\t\t\t\t\tmodelId: Reflect.get(model, \"id\") as string | undefined,\n\t\t\t\t\t\tlocals,\n\t\t\t\t\t});\n\t\t\t\t\tfinalAnswer = summary;\n\t\t\t\t\tyield {\n\t\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\t\t\t\tstatus: `Done in ${Math.round((new Date().getTime() - startTime.getTime()) / 1000)}s.`,\n\t\t\t\t\t};\n\t\t\t\t} catch (e) {\n\t\t\t\t\tfinalAnswer = text;\n\t\t\t\t\tlogger.error(e, \"Error generating summary of reasoning\");\n\t\t\t\t}\n\t\t\t} else if (modelReasoning && modelReasoning.type === \"tokens\") {\n\t\t\t\t// Remove the reasoning segment from final answer to avoid duplication\n\t\t\t\tconst beginIndex = modelReasoning.beginToken\n\t\t\t\t\t? reasoningBuffer.indexOf(modelReasoning.beginToken)\n\t\t\t\t\t: 0;\n\t\t\t\tconst endIndex = modelReasoning.endToken\n\t\t\t\t\t? reasoningBuffer.lastIndexOf(modelReasoning.endToken)\n\t\t\t\t\t: -1;\n\n\t\t\t\tif (beginIndex !== -1 && endIndex !== -1 && modelReasoning.endToken) {\n\t\t\t\t\tfinalAnswer =\n\t\t\t\t\t\ttext.slice(0, beginIndex) + text.slice(endIndex + modelReasoning.endToken.length);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tyield { type: MessageUpdateType.FinalAnswer, text: finalAnswer, interrupted };\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (modelReasoning && modelReasoning.type === \"tokens\") {\n\t\t\tif (output.token.text === modelReasoning.beginToken) {\n\t\t\t\treasoning = true;\n\t\t\t\treasoningBuffer += output.token.text;\n\t\t\t\tcontinue;\n\t\t\t} else if (modelReasoning.endToken && output.token.text === modelReasoning.endToken) {\n\t\t\t\treasoning = false;\n\t\t\t\treasoningBuffer += output.token.text;\n\t\t\t\tyield {\n\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\t\t\tstatus: `Done in ${Math.round((new Date().getTime() - startTime.getTime()) / 1000)}s.`,\n\t\t\t\t};\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\t// ignore special tokens\n\t\tif (output.token.special) continue;\n\n\t\t// pass down normal token\n\t\tif (reasoning) {\n\t\t\treasoningBuffer += output.token.text;\n\n\t\t\tif (modelReasoning && modelReasoning.type === \"tokens\" && modelReasoning.endToken) {\n\t\t\t\tif (reasoningBuffer.lastIndexOf(modelReasoning.endToken) !== -1) {\n\t\t\t\t\tconst endTokenIndex = reasoningBuffer.lastIndexOf(modelReasoning.endToken);\n\t\t\t\t\tconst textBuffer = reasoningBuffer.slice(endTokenIndex + modelReasoning.endToken.length);\n\t\t\t\t\treasoningBuffer = reasoningBuffer.slice(\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tendTokenIndex + modelReasoning.endToken.length + 1\n\t\t\t\t\t);\n\n\t\t\t\t\tyield {\n\t\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\t\tsubtype: MessageReasoningUpdateType.Stream,\n\t\t\t\t\t\ttoken: output.token.text,\n\t\t\t\t\t};\n\t\t\t\t\tyield { type: MessageUpdateType.Stream, token: textBuffer };\n\t\t\t\t\tyield {\n\t\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\t\t\t\tstatus: `Done in ${Math.round((new Date().getTime() - startTime.getTime()) / 1000)}s.`,\n\t\t\t\t\t};\n\t\t\t\t\treasoning = false;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// yield status update if it has changed\n\t\t\tif (status !== \"\") {\n\t\t\t\tyield {\n\t\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\t\tsubtype: MessageReasoningUpdateType.Status,\n\t\t\t\t\tstatus,\n\t\t\t\t};\n\t\t\t\tstatus = \"\";\n\t\t\t}\n\n\t\t\t// create a new status every ~4s (optional)\n\t\t\tif (\n\t\t\t\tReflect.get(config, \"REASONING_SUMMARY\") === \"true\" &&\n\t\t\t\tnew Date().getTime() - lastReasoningUpdate.getTime() > 4000\n\t\t\t) {\n\t\t\t\tlastReasoningUpdate = new Date();\n\t\t\t\ttry {\n\t\t\t\t\tgenerateSummaryOfReasoning(reasoningBuffer, model.id, locals).then((summary) => {\n\t\t\t\t\t\tstatus = summary;\n\t\t\t\t\t});\n\t\t\t\t} catch (e) {\n\t\t\t\t\tlogger.error(e, \"Error generating summary of reasoning\");\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tyield {\n\t\t\t\ttype: MessageUpdateType.Reasoning,\n\t\t\t\tsubtype: MessageReasoningUpdateType.Stream,\n\t\t\t\ttoken: output.token.text,\n\t\t\t};\n\t\t} else {\n\t\t\tyield { type: MessageUpdateType.Stream, token: output.token.text };\n\t\t}\n\n\t\t// abort check\n\t\tconst date = AbortedGenerations.getInstance().getAbortTime(conv._id.toString());\n\n\t\tif (date && date > promptedAt) {\n\t\t\tlogger.info(`Aborting generation for conversation ${conv._id}`);\n\t\t\tif (!abortController.signal.aborted) {\n\t\t\t\tabortController.abort();\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\n\t\t// no output check\n\t\tif (!output) break;\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/index.ts",
    "content": "import { preprocessMessages } from \"../endpoints/preprocessMessages\";\n\nimport { generateTitleForConversation } from \"./title\";\nimport {\n\ttype MessageUpdate,\n\tMessageUpdateType,\n\tMessageUpdateStatus,\n} from \"$lib/types/MessageUpdate\";\nimport { generate } from \"./generate\";\nimport { runMcpFlow } from \"./mcp/runMcpFlow\";\nimport { mergeAsyncGenerators } from \"$lib/utils/mergeAsyncGenerators\";\nimport type { TextGenerationContext } from \"./types\";\n\nasync function* keepAlive(done: AbortSignal): AsyncGenerator<MessageUpdate, undefined, undefined> {\n\twhile (!done.aborted) {\n\t\tyield {\n\t\t\ttype: MessageUpdateType.Status,\n\t\t\tstatus: MessageUpdateStatus.KeepAlive,\n\t\t};\n\t\tawait new Promise((resolve) => setTimeout(resolve, 100));\n\t}\n}\n\nexport async function* textGeneration(ctx: TextGenerationContext) {\n\tconst done = new AbortController();\n\n\tconst titleGen = generateTitleForConversation(ctx.conv, ctx.locals);\n\tconst textGen = textGenerationWithoutTitle(ctx, done);\n\tconst keepAliveGen = keepAlive(done.signal);\n\n\t// keep alive until textGen is done\n\n\tyield* mergeAsyncGenerators([titleGen, textGen, keepAliveGen]);\n}\n\nasync function* textGenerationWithoutTitle(\n\tctx: TextGenerationContext,\n\tdone: AbortController\n): AsyncGenerator<MessageUpdate, undefined, undefined> {\n\tyield {\n\t\ttype: MessageUpdateType.Status,\n\t\tstatus: MessageUpdateStatus.Started,\n\t};\n\n\tconst { conv, messages } = ctx;\n\tconst convId = conv._id;\n\n\tconst preprompt = conv.preprompt;\n\n\tconst processedMessages = await preprocessMessages(messages, convId);\n\n\t// Try MCP tool flow first; fall back to default generation if not selected/available\n\ttry {\n\t\tconst mcpGen = runMcpFlow({\n\t\t\tmodel: ctx.model,\n\t\t\tconv,\n\t\t\tmessages: processedMessages,\n\t\t\tassistant: ctx.assistant,\n\t\t\tforceMultimodal: ctx.forceMultimodal,\n\t\t\tforceTools: ctx.forceTools,\n\t\t\tprovider: ctx.provider,\n\t\t\tlocals: ctx.locals,\n\t\t\tpreprompt,\n\t\t\tabortSignal: ctx.abortController.signal,\n\t\t\tabortController: ctx.abortController,\n\t\t\tpromptedAt: ctx.promptedAt,\n\t\t});\n\n\t\tlet step = await mcpGen.next();\n\t\twhile (!step.done) {\n\t\t\tyield step.value;\n\t\t\tstep = await mcpGen.next();\n\t\t}\n\t\tconst mcpResult = step.value;\n\t\tif (mcpResult === \"not_applicable\") {\n\t\t\t// fallback to normal text generation\n\t\t\tyield* generate({ ...ctx, messages: processedMessages }, preprompt);\n\t\t}\n\t\t// If mcpResult is \"completed\" or \"aborted\", don't fall back\n\t} catch (err) {\n\t\t// Don't fall back on abort errors - user intentionally stopped\n\t\tconst isAbort =\n\t\t\tctx.abortController.signal.aborted ||\n\t\t\t(err instanceof Error &&\n\t\t\t\t(err.name === \"AbortError\" ||\n\t\t\t\t\terr.name === \"APIUserAbortError\" ||\n\t\t\t\t\terr.message.includes(\"Request was aborted\")));\n\t\tif (!isAbort) {\n\t\t\t// On non-abort MCP error, fall back to normal generation\n\t\t\tyield* generate({ ...ctx, messages: processedMessages }, preprompt);\n\t\t}\n\t}\n\tdone.abort();\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/mcp/fileRefs.ts",
    "content": "import type { EndpointMessage } from \"../../endpoints/endpoints\";\n\nexport type FileRefPayload = {\n\tname: string;\n\tmime: string;\n\tbase64: string;\n};\n\nexport type RefKind = {\n\tprefix: string;\n\tmatches: (mime: string) => boolean;\n\ttoDataUrl?: (payload: FileRefPayload) => string;\n};\n\nexport type ResolvedFileRef = FileRefPayload & { refKind: RefKind };\nexport type FileRefResolver = (ref: string) => ResolvedFileRef | undefined;\n\nconst IMAGE_REF_KIND: RefKind = {\n\tprefix: \"image\",\n\tmatches: (mime) => typeof mime === \"string\" && mime.startsWith(\"image/\"),\n\ttoDataUrl: (payload) => `data:${payload.mime};base64,${payload.base64}`,\n};\n\nconst DEFAULT_REF_KINDS: RefKind[] = [IMAGE_REF_KIND];\n\n/**\n * Build a resolver that maps short ref strings (e.g. \"image_1\", \"image_2\") to the\n * corresponding file payload across the whole conversation in chronological\n * order of user uploads. (image_1 = first user-uploaded image, image_2 = second, etc.)\n * Currently only images are exposed to end users, but the plumbing supports\n * additional kinds later.\n */\nexport function buildFileRefResolver(\n\tmessages: EndpointMessage[],\n\trefKinds: RefKind[] = DEFAULT_REF_KINDS\n): FileRefResolver | undefined {\n\tif (!Array.isArray(refKinds) || refKinds.length === 0) return undefined;\n\n\t// Bucket matched files by ref kind preserving conversation order (oldest -> newest)\n\tconst buckets = new Map<RefKind, FileRefPayload[]>();\n\tfor (const msg of messages) {\n\t\tif (msg.from !== \"user\") continue;\n\t\tfor (const file of msg.files ?? []) {\n\t\t\tconst mime = file?.mime ?? \"\";\n\t\t\tconst kind = refKinds.find((k) => k.matches(mime));\n\t\t\tif (!kind) continue;\n\t\t\tconst payload: FileRefPayload = { name: file.name, mime, base64: file.value };\n\t\t\tconst arr = buckets.get(kind) ?? [];\n\t\t\tarr.push(payload);\n\t\t\tbuckets.set(kind, arr);\n\t\t}\n\t}\n\n\tif (buckets.size === 0) return undefined;\n\n\tconst resolver: FileRefResolver = (ref) => {\n\t\tif (!ref || typeof ref !== \"string\") return undefined;\n\t\tconst trimmed = ref.trim().toLowerCase();\n\t\tfor (const kind of refKinds) {\n\t\t\tconst match = new RegExp(`^${kind.prefix}_(\\\\d+)$`).exec(trimmed);\n\t\t\tif (!match) continue;\n\t\t\tconst idx = Number(match[1]) - 1;\n\t\t\tconst files = buckets.get(kind) ?? [];\n\t\t\tif (Number.isFinite(idx) && idx >= 0 && idx < files.length) {\n\t\t\t\tconst payload = files[idx];\n\t\t\t\treturn payload ? { ...payload, refKind: kind } : undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t};\n\n\treturn resolver;\n}\n\nexport function buildImageRefResolver(messages: EndpointMessage[]): FileRefResolver | undefined {\n\treturn buildFileRefResolver(messages, [IMAGE_REF_KIND]);\n}\n\ntype FieldRule = {\n\tkeys: string[];\n\taction: \"attachPayload\" | \"replaceWithDataUrl\";\n\tattachKey?: string;\n\tallowedPrefixes?: string[]; // limit to specific ref kinds (e.g. [\"image\"])\n};\n\nconst DEFAULT_FIELD_RULES: FieldRule[] = [\n\t{\n\t\tkeys: [\"image_ref\"],\n\t\taction: \"attachPayload\",\n\t\tattachKey: \"image\",\n\t\tallowedPrefixes: [\"image\"],\n\t},\n\t{\n\t\tkeys: [\"input_image\", \"image\", \"image_url\"],\n\t\taction: \"replaceWithDataUrl\",\n\t\tallowedPrefixes: [\"image\"],\n\t},\n];\n\n/**\n * Walk tool args and hydrate known ref fields while keeping logging lightweight.\n * Only image refs are recognized for now to preserve current behavior.\n */\nexport function attachFileRefsToArgs(\n\targsObj: Record<string, unknown>,\n\tresolveRef?: FileRefResolver,\n\tfieldRules: FieldRule[] = DEFAULT_FIELD_RULES\n): void {\n\tif (!resolveRef) return;\n\n\tconst visit = (node: unknown): void => {\n\t\tif (!node || typeof node !== \"object\") return;\n\t\tif (Array.isArray(node)) {\n\t\t\tfor (const v of node) visit(v);\n\t\t\treturn;\n\t\t}\n\n\t\tconst obj = node as Record<string, unknown>;\n\t\tfor (const [key, value] of Object.entries(obj)) {\n\t\t\tif (typeof value !== \"string\") {\n\t\t\t\tif (value && typeof value === \"object\") visit(value);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst resolved = resolveRef(value);\n\t\t\tif (!resolved) continue;\n\n\t\t\tconst rule = fieldRules.find((r) => r.keys.includes(key));\n\t\t\tif (!rule) continue;\n\t\t\tif (rule.allowedPrefixes && !rule.allowedPrefixes.includes(resolved.refKind.prefix)) continue;\n\n\t\t\tif (rule.action === \"attachPayload\") {\n\t\t\t\tconst targetKey = rule.attachKey ?? \"file\";\n\t\t\t\tif (\n\t\t\t\t\ttypeof obj[targetKey] !== \"object\" ||\n\t\t\t\t\tobj[targetKey] === null ||\n\t\t\t\t\tArray.isArray(obj[targetKey])\n\t\t\t\t) {\n\t\t\t\t\tobj[targetKey] = {\n\t\t\t\t\t\tname: resolved.name,\n\t\t\t\t\t\tmime: resolved.mime,\n\t\t\t\t\t\tbase64: resolved.base64,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t} else if (rule.action === \"replaceWithDataUrl\") {\n\t\t\t\tconst toUrl =\n\t\t\t\t\tresolved.refKind.toDataUrl ??\n\t\t\t\t\t((p: FileRefPayload) => `data:${p.mime};base64,${p.base64}`);\n\t\t\t\tobj[key] = toUrl(resolved);\n\t\t\t}\n\t\t}\n\t};\n\n\tvisit(argsObj);\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/mcp/routerResolution.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { archSelectRoute } from \"$lib/server/router/arch\";\nimport { getRoutes, resolveRouteModels } from \"$lib/server/router/policy\";\nimport {\n\thasActiveToolsSelection,\n\tisRouterToolsBypassEnabled,\n\tpickToolsCapableModel,\n\tROUTER_TOOLS_ROUTE,\n} from \"$lib/server/router/toolsRoute\";\nimport { findConfiguredMultimodalModel } from \"$lib/server/router/multimodal\";\nimport type { EndpointMessage } from \"../../endpoints/endpoints\";\nimport { stripReasoningFromMessageForRouting } from \"../utils/routing\";\nimport type { ProcessedModel } from \"../../models\";\nimport { logger } from \"../../logger\";\n\nexport interface RouterResolutionInput {\n\tmodel: ProcessedModel;\n\tmessages: EndpointMessage[];\n\tconversationId: string;\n\thasImageInput: boolean;\n\tlocals: App.Locals | undefined;\n}\n\nexport interface RouterResolutionResult {\n\trunMcp: boolean;\n\ttargetModel: ProcessedModel;\n\tcandidateModelId?: string;\n\tresolvedRoute?: string;\n}\n\nexport async function resolveRouterTarget({\n\tmodel,\n\tmessages,\n\tconversationId,\n\thasImageInput,\n\tlocals,\n}: RouterResolutionInput): Promise<RouterResolutionResult> {\n\tlet targetModel = model;\n\tlet candidateModelId: string | undefined;\n\tlet resolvedRoute: string | undefined;\n\tlet runMcp = true;\n\n\tif (!model.isRouter) {\n\t\treturn { runMcp, targetModel };\n\t}\n\n\ttry {\n\t\tconst mod = await import(\"../../models\");\n\t\tconst allModels = mod.models as ProcessedModel[];\n\n\t\tif (hasImageInput) {\n\t\t\tconst multimodalCandidate = findConfiguredMultimodalModel(allModels);\n\t\t\tif (!multimodalCandidate) {\n\t\t\t\trunMcp = false;\n\t\t\t\tlogger.warn(\n\t\t\t\t\t{ configuredModel: config.LLM_ROUTER_MULTIMODAL_MODEL },\n\t\t\t\t\t\"[mcp] multimodal input but configured model missing or invalid; skipping MCP route\"\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\ttargetModel = multimodalCandidate;\n\t\t\t\tcandidateModelId = multimodalCandidate.id ?? multimodalCandidate.name;\n\t\t\t\tresolvedRoute = \"multimodal\";\n\t\t\t}\n\t\t} else {\n\t\t\t// If tools are enabled and at least one MCP server is active, prefer a tools-capable model\n\t\t\tconst toolsEnabled = isRouterToolsBypassEnabled();\n\t\t\tconst hasToolsActive = hasActiveToolsSelection(locals);\n\n\t\t\tif (toolsEnabled && hasToolsActive) {\n\t\t\t\tconst found = pickToolsCapableModel(allModels);\n\t\t\t\tif (found) {\n\t\t\t\t\ttargetModel = found;\n\t\t\t\t\tcandidateModelId = found.id ?? found.name;\n\t\t\t\t\tresolvedRoute = ROUTER_TOOLS_ROUTE;\n\t\t\t\t\t// Continue; runMcp remains true\n\t\t\t\t\treturn { runMcp, targetModel, candidateModelId, resolvedRoute };\n\t\t\t\t}\n\t\t\t\t// No tools-capable model found; fall back to normal Arch routing below\n\t\t\t}\n\t\t\tconst routes = await getRoutes();\n\t\t\tconst sanitized = messages.map(stripReasoningFromMessageForRouting);\n\t\t\tconst { routeName } = await archSelectRoute(sanitized, conversationId, locals);\n\t\t\tresolvedRoute = routeName;\n\t\t\tconst fallbackModel = config.LLM_ROUTER_FALLBACK_MODEL || model.id;\n\t\t\tconst { candidates } = resolveRouteModels(routeName, routes, fallbackModel);\n\t\t\tconst primaryCandidateId = candidates[0];\n\t\t\tif (!primaryCandidateId || primaryCandidateId === fallbackModel) {\n\t\t\t\trunMcp = false;\n\t\t\t} else {\n\t\t\t\tconst found = allModels?.find(\n\t\t\t\t\t(candidate) =>\n\t\t\t\t\t\tcandidate.id === primaryCandidateId || candidate.name === primaryCandidateId\n\t\t\t\t);\n\t\t\t\tif (found) {\n\t\t\t\t\ttargetModel = found;\n\t\t\t\t\tcandidateModelId = primaryCandidateId;\n\t\t\t\t} else {\n\t\t\t\t\trunMcp = false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\tlogger.warn({ err: String(error) }, \"[mcp] routing preflight failed\");\n\t\trunMcp = false;\n\t}\n\n\treturn { runMcp, targetModel, candidateModelId, resolvedRoute };\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/mcp/runMcpFlow.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { MessageUpdateType, type MessageUpdate } from \"$lib/types/MessageUpdate\";\nimport { getMcpServers } from \"$lib/server/mcp/registry\";\nimport { isValidUrl } from \"$lib/server/urlSafety\";\nimport { resetMcpToolsCache } from \"$lib/server/mcp/tools\";\nimport { getOpenAiToolsForMcp } from \"$lib/server/mcp/tools\";\nimport type {\n\tChatCompletionChunk,\n\tChatCompletionCreateParamsStreaming,\n\tChatCompletionMessageParam,\n\tChatCompletionMessageToolCall,\n} from \"openai/resources/chat/completions\";\nimport type { Stream } from \"openai/streaming\";\nimport { buildToolPreprompt } from \"../utils/toolPrompt\";\nimport type { EndpointMessage } from \"../../endpoints/endpoints\";\nimport { resolveRouterTarget } from \"./routerResolution\";\nimport { executeToolCalls, type NormalizedToolCall } from \"./toolInvocation\";\nimport { drainPool } from \"$lib/server/mcp/clientPool\";\nimport type { TextGenerationContext } from \"../types\";\nimport {\n\thasAuthHeader,\n\tisStrictHfMcpLogin,\n\thasNonEmptyToken,\n\tisExaMcpServer,\n} from \"$lib/server/mcp/hf\";\nimport { buildImageRefResolver } from \"./fileRefs\";\nimport { prepareMessagesWithFiles } from \"$lib/server/textGeneration/utils/prepareFiles\";\nimport { makeImageProcessor } from \"$lib/server/endpoints/images\";\nimport { logger } from \"$lib/server/logger\";\nimport { AbortedGenerations } from \"$lib/server/abortedGenerations\";\n\nexport type RunMcpFlowContext = Pick<\n\tTextGenerationContext,\n\t\"model\" | \"conv\" | \"assistant\" | \"forceMultimodal\" | \"forceTools\" | \"provider\" | \"locals\"\n> & { messages: EndpointMessage[] };\n\n// Return type: \"completed\" = MCP ran successfully, \"not_applicable\" = MCP didn't run, \"aborted\" = user aborted\nexport type McpFlowResult = \"completed\" | \"not_applicable\" | \"aborted\";\n\nexport async function* runMcpFlow({\n\tmodel,\n\tconv,\n\tmessages,\n\tassistant,\n\tforceMultimodal,\n\tforceTools,\n\tprovider,\n\tlocals,\n\tpreprompt,\n\tabortSignal,\n\tabortController,\n\tpromptedAt,\n}: RunMcpFlowContext & {\n\tpreprompt?: string;\n\tabortSignal?: AbortSignal;\n\tabortController?: AbortController;\n\tpromptedAt?: Date;\n}): AsyncGenerator<MessageUpdate, McpFlowResult, undefined> {\n\t// Helper to check if generation should be aborted via DB polling\n\t// Also triggers the abort controller to cancel active streams/requests\n\tconst checkAborted = (): boolean => {\n\t\tif (abortSignal?.aborted) return true;\n\t\tconst abortTime = AbortedGenerations.getInstance().getAbortTime(conv._id.toString());\n\t\tif (abortTime && promptedAt && abortTime > promptedAt) {\n\t\t\t// Trigger the abort controller to cancel active streams\n\t\t\tif (abortController && !abortController.signal.aborted) {\n\t\t\t\tabortController.abort();\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t};\n\t// Start from env-configured servers\n\tlet servers = getMcpServers();\n\ttry {\n\t\tlogger.debug(\n\t\t\t{ baseServers: servers.map((s) => ({ name: s.name, url: s.url })), count: servers.length },\n\t\t\t\"[mcp] base servers loaded\"\n\t\t);\n\t} catch {}\n\n\t// Merge in request-provided custom servers (if any)\n\ttry {\n\t\tconst reqMcp = (\n\t\t\tlocals as unknown as {\n\t\t\t\tmcp?: {\n\t\t\t\t\tselectedServers?: Array<{ name: string; url: string; headers?: Record<string, string> }>;\n\t\t\t\t\tselectedServerNames?: string[];\n\t\t\t\t};\n\t\t\t}\n\t\t)?.mcp;\n\t\tconst custom = Array.isArray(reqMcp?.selectedServers) ? reqMcp?.selectedServers : [];\n\t\tif (custom.length > 0) {\n\t\t\t// Invalidate cached tool list when the set of servers changes at request-time\n\t\t\tresetMcpToolsCache();\n\t\t\t// Deduplicate by server name (request takes precedence)\n\t\t\tconst byName = new Map<\n\t\t\t\tstring,\n\t\t\t\t{ name: string; url: string; headers?: Record<string, string> }\n\t\t\t>();\n\t\t\tfor (const s of servers) byName.set(s.name, s);\n\t\t\tfor (const s of custom) byName.set(s.name, s);\n\t\t\tservers = [...byName.values()];\n\t\t\ttry {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t{\n\t\t\t\t\t\tcustomProvidedCount: custom.length,\n\t\t\t\t\t\tmergedServers: servers.map((s) => ({\n\t\t\t\t\t\t\tname: s.name,\n\t\t\t\t\t\t\turl: s.url,\n\t\t\t\t\t\t\thasAuth: !!s.headers?.Authorization,\n\t\t\t\t\t\t})),\n\t\t\t\t\t},\n\t\t\t\t\t\"[mcp] merged request-provided servers\"\n\t\t\t\t);\n\t\t\t} catch {}\n\t\t}\n\n\t\t// If the client specified a selection by name, filter to those\n\t\tconst names = Array.isArray(reqMcp?.selectedServerNames)\n\t\t\t? reqMcp?.selectedServerNames\n\t\t\t: undefined;\n\t\tif (Array.isArray(names)) {\n\t\t\tconst before = servers.map((s) => s.name);\n\t\t\tservers = servers.filter((s) => names.includes(s.name));\n\t\t\ttry {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t{ selectedNames: names, before, after: servers.map((s) => s.name) },\n\t\t\t\t\t\"[mcp] applied name selection\"\n\t\t\t\t);\n\t\t\t} catch {}\n\t\t}\n\t} catch {\n\t\t// ignore selection merge errors and proceed with env servers\n\t}\n\n\t// If selection/merge yielded no servers, bail early with clearer log\n\tif (servers.length === 0) {\n\t\tlogger.warn({}, \"[mcp] no MCP servers selected after merge/name filter\");\n\t\treturn \"not_applicable\";\n\t}\n\n\t// Enforce server-side safety (public HTTPS only, no private ranges)\n\t{\n\t\tconst before = servers.slice();\n\t\tservers = servers.filter((s) => {\n\t\t\ttry {\n\t\t\t\treturn isValidUrl(s.url);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\t\ttry {\n\t\t\tconst rejected = before.filter((b) => !servers.includes(b));\n\t\t\tif (rejected.length > 0) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t{ rejected: rejected.map((r) => ({ name: r.name, url: r.url })) },\n\t\t\t\t\t\"[mcp] rejected servers by URL safety\"\n\t\t\t\t);\n\t\t\t}\n\t\t} catch {}\n\t}\n\tif (servers.length === 0) {\n\t\tlogger.warn({}, \"[mcp] all selected MCP servers rejected by URL safety guard\");\n\t\treturn \"not_applicable\";\n\t}\n\n\t// Optionally attach the logged-in user's HF token to the official HF MCP server only.\n\t// Never override an explicit Authorization header, and require token to look like an HF token.\n\ttry {\n\t\tconst shouldForward = config.MCP_FORWARD_HF_USER_TOKEN === \"true\";\n\t\tconst userToken =\n\t\t\t(locals as unknown as { hfAccessToken?: string } | undefined)?.hfAccessToken ??\n\t\t\t(locals as unknown as { token?: string } | undefined)?.token;\n\n\t\tif (shouldForward && hasNonEmptyToken(userToken)) {\n\t\t\tconst overlayApplied: string[] = [];\n\t\t\tservers = servers.map((s) => {\n\t\t\t\ttry {\n\t\t\t\t\tif (isStrictHfMcpLogin(s.url) && !hasAuthHeader(s.headers)) {\n\t\t\t\t\t\toverlayApplied.push(s.name);\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t...s,\n\t\t\t\t\t\t\theaders: { ...(s.headers ?? {}), Authorization: `Bearer ${userToken}` },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore URL parse errors and leave server unchanged\n\t\t\t\t}\n\t\t\t\treturn s;\n\t\t\t});\n\t\t\tif (overlayApplied.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tlogger.debug({ overlayApplied }, \"[mcp] forwarded HF token to servers\");\n\t\t\t\t} catch {}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// best-effort overlay; continue if anything goes wrong\n\t}\n\n\t// Inject Exa API key for mcp.exa.ai servers via URL param (mcp.exa.ai doesn't support headers)\n\ttry {\n\t\tconst exaApiKey = config.EXA_API_KEY;\n\t\tif (hasNonEmptyToken(exaApiKey)) {\n\t\t\tconst overlayApplied: string[] = [];\n\t\t\tservers = servers.map((s) => {\n\t\t\t\ttry {\n\t\t\t\t\tif (isExaMcpServer(s.url)) {\n\t\t\t\t\t\tconst url = new URL(s.url);\n\t\t\t\t\t\tif (!url.searchParams.has(\"exaApiKey\")) {\n\t\t\t\t\t\t\turl.searchParams.set(\"exaApiKey\", exaApiKey);\n\t\t\t\t\t\t\toverlayApplied.push(s.name);\n\t\t\t\t\t\t\treturn { ...s, url: url.toString() };\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} catch {}\n\t\t\t\treturn s;\n\t\t\t});\n\t\t\tif (overlayApplied.length > 0) {\n\t\t\t\tlogger.debug({ overlayApplied }, \"[mcp] injected Exa API key to servers\");\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// best-effort injection; continue if anything goes wrong\n\t}\n\n\tlogger.debug(\n\t\t{ count: servers.length, servers: servers.map((s) => s.name) },\n\t\t\"[mcp] servers configured\"\n\t);\n\tif (servers.length === 0) {\n\t\treturn \"not_applicable\";\n\t}\n\n\t// Gate MCP flow based on model tool support (aggregated) with user override\n\ttry {\n\t\tconst supportsTools = Boolean((model as unknown as { supportsTools?: boolean }).supportsTools);\n\t\tconst toolsEnabled = Boolean(forceTools) || supportsTools;\n\t\tlogger.debug(\n\t\t\t{\n\t\t\t\tmodel: model.id ?? model.name,\n\t\t\t\tsupportsTools,\n\t\t\t\tforceTools: Boolean(forceTools),\n\t\t\t\ttoolsEnabled,\n\t\t\t},\n\t\t\t\"[mcp] tools gate evaluation\"\n\t\t);\n\t\tif (!toolsEnabled) {\n\t\t\tlogger.info(\n\t\t\t\t{ model: model.id ?? model.name },\n\t\t\t\t\"[mcp] tools disabled for model; skipping MCP flow\"\n\t\t\t);\n\t\t\treturn \"not_applicable\";\n\t\t}\n\t} catch {\n\t\t// If anything goes wrong reading the flag, proceed (previous behavior)\n\t}\n\n\tconst resolveFileRef = buildImageRefResolver(messages);\n\tconst imageProcessor = makeImageProcessor({\n\t\tsupportedMimeTypes: [\"image/png\", \"image/jpeg\"],\n\t\tpreferredMimeType: \"image/jpeg\",\n\t\tmaxSizeInMB: 1,\n\t\tmaxWidth: 1024,\n\t\tmaxHeight: 1024,\n\t});\n\n\tconst hasImageInput = messages.some((msg) =>\n\t\t(msg.files ?? []).some(\n\t\t\t(file) => typeof file?.mime === \"string\" && file.mime.startsWith(\"image/\")\n\t\t)\n\t);\n\n\tconst { runMcp, targetModel, candidateModelId, resolvedRoute } = await resolveRouterTarget({\n\t\tmodel,\n\t\tmessages,\n\t\tconversationId: conv._id.toString(),\n\t\thasImageInput,\n\t\tlocals,\n\t});\n\n\tif (!runMcp) {\n\t\tlogger.info(\n\t\t\t{ model: targetModel.id ?? targetModel.name, resolvedRoute },\n\t\t\t\"[mcp] runMcp=false (routing chose non-tools candidate)\"\n\t\t);\n\t\treturn \"not_applicable\";\n\t}\n\n\ttry {\n\t\tconst { tools: oaTools, mapping } = await getOpenAiToolsForMcp(servers, {\n\t\t\tsignal: abortSignal,\n\t\t});\n\t\ttry {\n\t\t\tlogger.info(\n\t\t\t\t{ toolCount: oaTools.length, toolNames: oaTools.map((t) => t.function.name) },\n\t\t\t\t\"[mcp] openai tool defs built\"\n\t\t\t);\n\t\t} catch {}\n\t\tif (oaTools.length === 0) {\n\t\t\tlogger.warn({}, \"[mcp] zero tools available after listing; skipping MCP flow\");\n\t\t\treturn \"not_applicable\";\n\t\t}\n\n\t\tconst { OpenAI } = await import(\"openai\");\n\n\t\t// Capture provider header (x-inference-provider) from the upstream OpenAI-compatible server.\n\t\tlet providerHeader: string | undefined;\n\t\tconst captureProviderFetch = async (\n\t\t\tinput: RequestInfo | URL,\n\t\t\tinit?: RequestInit\n\t\t): Promise<Response> => {\n\t\t\tconst res = await fetch(input, init);\n\t\t\tconst p = res.headers.get(\"x-inference-provider\");\n\t\t\tif (p && !providerHeader) providerHeader = p;\n\t\t\treturn res;\n\t\t};\n\n\t\tconst openai = new OpenAI({\n\t\t\tapiKey: config.OPENAI_API_KEY || config.HF_TOKEN || \"sk-\",\n\t\t\tbaseURL: config.OPENAI_BASE_URL,\n\t\t\tfetch: captureProviderFetch,\n\t\t\tdefaultHeaders: {\n\t\t\t\t// Bill to organization if configured (HuggingChat only)\n\t\t\t\t...(config.isHuggingChat && locals?.billingOrganization\n\t\t\t\t\t? { \"X-HF-Bill-To\": locals.billingOrganization }\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t});\n\n\t\tconst mmEnabled = (forceMultimodal ?? false) || targetModel.multimodal;\n\t\tlogger.info(\n\t\t\t{\n\t\t\t\ttargetModel: targetModel.id ?? targetModel.name,\n\t\t\t\tmmEnabled,\n\t\t\t\troute: resolvedRoute,\n\t\t\t\tcandidateModelId,\n\t\t\t\ttoolCount: oaTools.length,\n\t\t\t\thasUserToken: Boolean((locals as unknown as { token?: string })?.token),\n\t\t\t},\n\t\t\t\"[mcp] starting completion with tools\"\n\t\t);\n\t\tlet messagesOpenAI: ChatCompletionMessageParam[] = await prepareMessagesWithFiles(\n\t\t\tmessages,\n\t\t\timageProcessor,\n\t\t\tmmEnabled\n\t\t);\n\t\tconst toolPreprompt = buildToolPreprompt(oaTools);\n\t\tconst prepromptPieces: string[] = [];\n\t\tif (toolPreprompt.trim().length > 0) {\n\t\t\tprepromptPieces.push(toolPreprompt);\n\t\t}\n\t\tif (typeof preprompt === \"string\" && preprompt.trim().length > 0) {\n\t\t\tprepromptPieces.push(preprompt);\n\t\t}\n\t\tconst mergedPreprompt = prepromptPieces.join(\"\\n\\n\");\n\t\tconst hasSystemMessage = messagesOpenAI.length > 0 && messagesOpenAI[0]?.role === \"system\";\n\t\tif (hasSystemMessage) {\n\t\t\tif (mergedPreprompt.length > 0) {\n\t\t\t\tconst existing = messagesOpenAI[0].content ?? \"\";\n\t\t\t\tconst existingText = typeof existing === \"string\" ? existing : \"\";\n\t\t\t\tmessagesOpenAI[0].content = mergedPreprompt + (existingText ? \"\\n\\n\" + existingText : \"\");\n\t\t\t}\n\t\t} else if (mergedPreprompt.length > 0) {\n\t\t\tmessagesOpenAI = [{ role: \"system\", content: mergedPreprompt }, ...messagesOpenAI];\n\t\t}\n\n\t\t// Work around servers that reject `system` role\n\t\tif (\n\t\t\ttypeof config.OPENAI_BASE_URL === \"string\" &&\n\t\t\tconfig.OPENAI_BASE_URL.length > 0 &&\n\t\t\t(config.OPENAI_BASE_URL.includes(\"hf.space\") ||\n\t\t\t\tconfig.OPENAI_BASE_URL.includes(\"gradio.app\")) &&\n\t\t\tmessagesOpenAI[0]?.role === \"system\"\n\t\t) {\n\t\t\tmessagesOpenAI[0] = { ...messagesOpenAI[0], role: \"user\" };\n\t\t}\n\n\t\tconst parameters = { ...targetModel.parameters, ...assistant?.generateSettings } as Record<\n\t\t\tstring,\n\t\t\tunknown\n\t\t>;\n\t\tconst maxTokens =\n\t\t\t(parameters?.max_tokens as number | undefined) ??\n\t\t\t(parameters?.max_new_tokens as number | undefined) ??\n\t\t\t(parameters?.max_completion_tokens as number | undefined);\n\n\t\tconst stopSequences =\n\t\t\ttypeof parameters?.stop === \"string\"\n\t\t\t\t? parameters.stop\n\t\t\t\t: Array.isArray(parameters?.stop)\n\t\t\t\t\t? (parameters.stop as string[])\n\t\t\t\t\t: undefined;\n\n\t\t// Build model ID with optional provider suffix (e.g., \"model:fastest\" or \"model:together\")\n\t\tconst baseModelId = targetModel.id ?? targetModel.name;\n\t\tconst modelIdWithProvider =\n\t\t\tprovider && provider !== \"auto\" ? `${baseModelId}:${provider}` : baseModelId;\n\n\t\tconst completionBase: Omit<ChatCompletionCreateParamsStreaming, \"messages\"> = {\n\t\t\tmodel: modelIdWithProvider,\n\t\t\tstream: true,\n\t\t\ttemperature: typeof parameters?.temperature === \"number\" ? parameters.temperature : undefined,\n\t\t\ttop_p: typeof parameters?.top_p === \"number\" ? parameters.top_p : undefined,\n\t\t\tfrequency_penalty:\n\t\t\t\ttypeof parameters?.frequency_penalty === \"number\"\n\t\t\t\t\t? parameters.frequency_penalty\n\t\t\t\t\t: typeof parameters?.repetition_penalty === \"number\"\n\t\t\t\t\t\t? parameters.repetition_penalty\n\t\t\t\t\t\t: undefined,\n\t\t\tpresence_penalty:\n\t\t\t\ttypeof parameters?.presence_penalty === \"number\" ? parameters.presence_penalty : undefined,\n\t\t\tstop: stopSequences,\n\t\t\tmax_tokens: typeof maxTokens === \"number\" ? maxTokens : undefined,\n\t\t\ttools: oaTools,\n\t\t\ttool_choice: \"auto\",\n\t\t};\n\n\t\tconst toPrimitive = (value: unknown) => {\n\t\t\tif (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\t\treturn value;\n\t\t\t}\n\t\t\treturn undefined;\n\t\t};\n\n\t\tconst parseArgs = (raw: unknown): Record<string, unknown> => {\n\t\t\tif (typeof raw !== \"string\" || raw.trim().length === 0) return {};\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(raw);\n\t\t\t} catch {\n\t\t\t\treturn {};\n\t\t\t}\n\t\t};\n\n\t\tconst processToolOutput = (\n\t\t\ttext: string\n\t\t): {\n\t\t\tannotated: string;\n\t\t\tsources: { index: number; link: string }[];\n\t\t} => ({ annotated: text, sources: [] });\n\n\t\tlet lastAssistantContent = \"\";\n\t\tlet streamedContent = false;\n\t\t// Track whether we're inside a <think> block when the upstream streams\n\t\t// provider-specific reasoning tokens (e.g. `reasoning` or `reasoning_content`).\n\t\tlet thinkOpen = false;\n\n\t\tif (resolvedRoute && candidateModelId) {\n\t\t\tyield {\n\t\t\t\ttype: MessageUpdateType.RouterMetadata,\n\t\t\t\troute: resolvedRoute,\n\t\t\t\tmodel: candidateModelId,\n\t\t\t};\n\t\t\tlogger.debug(\n\t\t\t\t{ route: resolvedRoute, model: candidateModelId },\n\t\t\t\t\"[mcp] router metadata emitted\"\n\t\t\t);\n\t\t}\n\n\t\tfor (let loop = 0; loop < 10; loop += 1) {\n\t\t\t// Check for abort at the start of each loop iteration\n\t\t\tif (checkAborted()) {\n\t\t\t\tlogger.info({ loop }, \"[mcp] aborting at start of loop iteration\");\n\t\t\t\treturn \"aborted\";\n\t\t\t}\n\n\t\t\tlastAssistantContent = \"\";\n\t\t\tstreamedContent = false;\n\n\t\t\tconst completionRequest: ChatCompletionCreateParamsStreaming = {\n\t\t\t\t...completionBase,\n\t\t\t\tmessages: messagesOpenAI,\n\t\t\t};\n\n\t\t\tconst completionStream: Stream<ChatCompletionChunk> = await openai.chat.completions.create(\n\t\t\t\tcompletionRequest,\n\t\t\t\t{\n\t\t\t\t\tsignal: abortSignal,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t\"ChatUI-Conversation-ID\": conv._id.toString(),\n\t\t\t\t\t\t\"X-use-cache\": \"false\",\n\t\t\t\t\t\t...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t);\n\n\t\t\t// If provider header was exposed, notify UI so it can render \"via {provider}\".\n\t\t\tif (providerHeader) {\n\t\t\t\tyield {\n\t\t\t\t\ttype: MessageUpdateType.RouterMetadata,\n\t\t\t\t\troute: \"\",\n\t\t\t\t\tmodel: \"\",\n\t\t\t\t\tprovider: providerHeader as unknown as import(\"@huggingface/inference\").InferenceProvider,\n\t\t\t\t};\n\t\t\t\tlogger.debug({ provider: providerHeader }, \"[mcp] provider metadata emitted\");\n\t\t\t}\n\n\t\t\tconst toolCallState: Record<number, { id?: string; name?: string; arguments: string }> = {};\n\t\t\tlet firstToolDeltaLogged = false;\n\t\t\tlet sawToolCall = false;\n\t\t\tlet tokenCount = 0;\n\t\t\tfor await (const chunk of completionStream) {\n\t\t\t\tconst choice = chunk.choices?.[0];\n\t\t\t\tconst delta = choice?.delta;\n\t\t\t\tif (!delta) continue;\n\n\t\t\t\tconst chunkToolCalls = delta.tool_calls ?? [];\n\t\t\t\tif (chunkToolCalls.length > 0) {\n\t\t\t\t\tsawToolCall = true;\n\t\t\t\t\tfor (const call of chunkToolCalls) {\n\t\t\t\t\t\tconst toolCall = call as unknown as {\n\t\t\t\t\t\t\tindex?: number;\n\t\t\t\t\t\t\tid?: string;\n\t\t\t\t\t\t\tfunction?: { name?: string; arguments?: string };\n\t\t\t\t\t\t};\n\t\t\t\t\t\tconst index = toolCall.index ?? 0;\n\t\t\t\t\t\tconst current = toolCallState[index] ?? { arguments: \"\" };\n\t\t\t\t\t\tif (toolCall.id) current.id = toolCall.id;\n\t\t\t\t\t\tif (toolCall.function?.name) current.name = toolCall.function.name;\n\t\t\t\t\t\tif (toolCall.function?.arguments) current.arguments += toolCall.function.arguments;\n\t\t\t\t\t\ttoolCallState[index] = current;\n\t\t\t\t\t}\n\t\t\t\t\tif (!firstToolDeltaLogged) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst first =\n\t\t\t\t\t\t\t\ttoolCallState[\n\t\t\t\t\t\t\t\t\tObject.keys(toolCallState)\n\t\t\t\t\t\t\t\t\t\t.map((k) => Number(k))\n\t\t\t\t\t\t\t\t\t\t.sort((a, b) => a - b)[0] ?? 0\n\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t\t{ firstCallName: first?.name, hasId: Boolean(first?.id) },\n\t\t\t\t\t\t\t\t\"[mcp] observed streamed tool_call delta\"\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tfirstToolDeltaLogged = true;\n\t\t\t\t\t\t} catch {}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst deltaContent = (() => {\n\t\t\t\t\tif (typeof delta.content === \"string\") return delta.content;\n\t\t\t\t\tconst maybeParts = delta.content as unknown;\n\t\t\t\t\tif (Array.isArray(maybeParts)) {\n\t\t\t\t\t\treturn maybeParts\n\t\t\t\t\t\t\t.map((part) =>\n\t\t\t\t\t\t\t\ttypeof part === \"object\" &&\n\t\t\t\t\t\t\t\tpart !== null &&\n\t\t\t\t\t\t\t\t\"text\" in part &&\n\t\t\t\t\t\t\t\ttypeof (part as Record<string, unknown>).text === \"string\"\n\t\t\t\t\t\t\t\t\t? String((part as Record<string, unknown>).text)\n\t\t\t\t\t\t\t\t\t: \"\"\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t}\n\t\t\t\t\treturn \"\";\n\t\t\t\t})();\n\n\t\t\t\t// Provider-dependent reasoning fields (e.g., `reasoning` or `reasoning_content`).\n\t\t\t\tconst deltaReasoning: string =\n\t\t\t\t\ttypeof (delta as unknown as Record<string, unknown>)?.reasoning === \"string\"\n\t\t\t\t\t\t? ((delta as unknown as { reasoning?: string }).reasoning as string)\n\t\t\t\t\t\t: typeof (delta as unknown as Record<string, unknown>)?.reasoning_content === \"string\"\n\t\t\t\t\t\t\t? ((delta as unknown as { reasoning_content?: string }).reasoning_content as string)\n\t\t\t\t\t\t\t: \"\";\n\n\t\t\t\t// Merge reasoning + content into a single combined token stream, mirroring\n\t\t\t\t// the OpenAI adapter so the UI can auto-detect <think> blocks.\n\t\t\t\tlet combined = \"\";\n\t\t\t\tif (deltaReasoning.trim().length > 0) {\n\t\t\t\t\tif (!thinkOpen) {\n\t\t\t\t\t\tcombined += \"<think>\" + deltaReasoning;\n\t\t\t\t\t\tthinkOpen = true;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcombined += deltaReasoning;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (deltaContent && deltaContent.length > 0) {\n\t\t\t\t\tif (thinkOpen) {\n\t\t\t\t\t\tcombined += \"</think>\" + deltaContent;\n\t\t\t\t\t\tthinkOpen = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcombined += deltaContent;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (combined.length > 0) {\n\t\t\t\t\tlastAssistantContent += combined;\n\t\t\t\t\tif (!sawToolCall) {\n\t\t\t\t\t\tstreamedContent = true;\n\t\t\t\t\t\tyield { type: MessageUpdateType.Stream, token: combined };\n\t\t\t\t\t\ttokenCount += combined.length;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Periodic abort check during streaming\n\t\t\t\tif (checkAborted()) {\n\t\t\t\t\tlogger.info({ loop, tokenCount }, \"[mcp] aborting during stream\");\n\t\t\t\t\treturn \"aborted\";\n\t\t\t\t}\n\t\t\t}\n\t\t\tlogger.info(\n\t\t\t\t{ sawToolCalls: Object.keys(toolCallState).length > 0, tokens: tokenCount, loop },\n\t\t\t\t\"[mcp] completion stream closed\"\n\t\t\t);\n\n\t\t\t// Check abort after stream completes\n\t\t\tif (checkAborted()) {\n\t\t\t\tlogger.info({ loop }, \"[mcp] aborting after stream completed\");\n\t\t\t\treturn \"aborted\";\n\t\t\t}\n\n\t\t\t// Auto-close any unclosed <think> block so reasoning from this loop\n\t\t\t// doesn't swallow content from subsequent iterations.  The client-side\n\t\t\t// regex matches `<think>` to end-of-string, so an unclosed block would\n\t\t\t// hide everything that follows.\n\t\t\tif (thinkOpen) {\n\t\t\t\tif (streamedContent) {\n\t\t\t\t\tyield { type: MessageUpdateType.Stream, token: \"</think>\" };\n\t\t\t\t}\n\t\t\t\tlastAssistantContent += \"</think>\";\n\t\t\t\tthinkOpen = false;\n\t\t\t}\n\n\t\t\tif (Object.keys(toolCallState).length > 0) {\n\t\t\t\t// If any streamed call is missing id, perform a quick non-stream retry to recover full tool_calls with ids\n\t\t\t\tconst missingId = Object.values(toolCallState).some((c) => c?.name && !c?.id);\n\t\t\t\tlet calls: NormalizedToolCall[];\n\t\t\t\tif (missingId) {\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t{ loop },\n\t\t\t\t\t\t\"[mcp] missing tool_call id in stream; retrying non-stream to recover ids\"\n\t\t\t\t\t);\n\t\t\t\t\tconst nonStream = await openai.chat.completions.create(\n\t\t\t\t\t\t{ ...completionBase, messages: messagesOpenAI, stream: false },\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tsignal: abortSignal,\n\t\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\t\"ChatUI-Conversation-ID\": conv._id.toString(),\n\t\t\t\t\t\t\t\t\"X-use-cache\": \"false\",\n\t\t\t\t\t\t\t\t...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t);\n\t\t\t\t\tconst tc = nonStream.choices?.[0]?.message?.tool_calls ?? [];\n\t\t\t\t\tcalls = tc.map((t) => ({\n\t\t\t\t\t\tid: t.id,\n\t\t\t\t\t\tname: t.function?.name ?? \"\",\n\t\t\t\t\t\targuments: t.function?.arguments ?? \"\",\n\t\t\t\t\t}));\n\t\t\t\t} else {\n\t\t\t\t\tcalls = Object.values(toolCallState)\n\t\t\t\t\t\t.map((c) => (c?.id && c?.name ? c : undefined))\n\t\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t\t.map((c) => ({\n\t\t\t\t\t\t\tid: c?.id ?? \"\",\n\t\t\t\t\t\t\tname: c?.name ?? \"\",\n\t\t\t\t\t\t\targuments: c?.arguments ?? \"\",\n\t\t\t\t\t\t})) as NormalizedToolCall[];\n\t\t\t\t}\n\n\t\t\t\t// Include the assistant message with tool_calls so the next round\n\t\t\t\t// sees both the calls and their outputs, matching MCP branch behavior.\n\t\t\t\tconst toolCalls: ChatCompletionMessageToolCall[] = calls.map((call) => ({\n\t\t\t\t\tid: call.id,\n\t\t\t\t\ttype: \"function\",\n\t\t\t\t\tfunction: { name: call.name, arguments: call.arguments },\n\t\t\t\t}));\n\n\t\t\t\t// Avoid sending <think> content back to the model alongside tool_calls\n\t\t\t\t// to prevent confusing follow-up reasoning. Strip any think blocks.\n\t\t\t\tconst assistantContentForToolMsg = lastAssistantContent.replace(\n\t\t\t\t\t/<think>[\\s\\S]*?(?:<\\/think>|$)/g,\n\t\t\t\t\t\"\"\n\t\t\t\t);\n\t\t\t\tconst assistantToolMessage: ChatCompletionMessageParam = {\n\t\t\t\t\trole: \"assistant\",\n\t\t\t\t\tcontent: assistantContentForToolMsg,\n\t\t\t\t\ttool_calls: toolCalls,\n\t\t\t\t};\n\n\t\t\t\tconst exec = executeToolCalls({\n\t\t\t\t\tcalls,\n\t\t\t\t\tmapping,\n\t\t\t\t\tservers,\n\t\t\t\t\tparseArgs,\n\t\t\t\t\tresolveFileRef,\n\t\t\t\t\ttoPrimitive,\n\t\t\t\t\tprocessToolOutput,\n\t\t\t\t\tabortSignal,\n\t\t\t\t});\n\t\t\t\tlet toolMsgCount = 0;\n\t\t\t\tlet toolRunCount = 0;\n\t\t\t\tfor await (const event of exec) {\n\t\t\t\t\tif (event.type === \"update\") {\n\t\t\t\t\t\tyield event.update;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tmessagesOpenAI = [\n\t\t\t\t\t\t\t...messagesOpenAI,\n\t\t\t\t\t\t\tassistantToolMessage,\n\t\t\t\t\t\t\t...(event.summary.toolMessages ?? []),\n\t\t\t\t\t\t];\n\t\t\t\t\t\ttoolMsgCount = event.summary.toolMessages?.length ?? 0;\n\t\t\t\t\t\ttoolRunCount = event.summary.toolRuns?.length ?? 0;\n\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t{ toolMsgCount, toolRunCount },\n\t\t\t\t\t\t\t\"[mcp] tools executed; continuing loop for follow-up completion\"\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Check abort during tool execution\n\t\t\t\t\tif (checkAborted()) {\n\t\t\t\t\t\tlogger.info({ loop, toolMsgCount }, \"[mcp] aborting during tool execution\");\n\t\t\t\t\t\treturn \"aborted\";\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Check abort after all tools complete before continuing loop\n\t\t\t\tif (checkAborted()) {\n\t\t\t\t\tlogger.info({ loop }, \"[mcp] aborting after tool execution\");\n\t\t\t\t\treturn \"aborted\";\n\t\t\t\t}\n\t\t\t\t// Continue loop: next iteration will use tool messages to get the final content\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// No tool calls: finalize and return\n\t\t\t// If a <think> block is still open, close it for the final output\n\t\t\tif (thinkOpen) {\n\t\t\t\tlastAssistantContent += \"</think>\";\n\t\t\t\tthinkOpen = false;\n\t\t\t}\n\t\t\tif (!streamedContent && lastAssistantContent.trim().length > 0) {\n\t\t\t\tyield { type: MessageUpdateType.Stream, token: lastAssistantContent };\n\t\t\t}\n\t\t\tyield {\n\t\t\t\ttype: MessageUpdateType.FinalAnswer,\n\t\t\t\ttext: lastAssistantContent,\n\t\t\t\tinterrupted: false,\n\t\t\t};\n\t\t\tlogger.info(\n\t\t\t\t{ length: lastAssistantContent.length, loop },\n\t\t\t\t\"[mcp] final answer emitted (no tool_calls)\"\n\t\t\t);\n\t\t\treturn \"completed\";\n\t\t}\n\t\tlogger.warn({}, \"[mcp] exceeded tool-followup loops; falling back\");\n\t} catch (err) {\n\t\tconst msg = String(err ?? \"\");\n\t\tconst isAbort =\n\t\t\t(abortSignal && abortSignal.aborted) ||\n\t\t\tmsg.includes(\"AbortError\") ||\n\t\t\tmsg.includes(\"APIUserAbortError\") ||\n\t\t\tmsg.includes(\"Request was aborted\");\n\t\tif (isAbort) {\n\t\t\t// Expected on user stop; keep logs quiet and do not treat as error\n\t\t\tlogger.debug({}, \"[mcp] aborted by user\");\n\t\t\treturn \"aborted\";\n\t\t}\n\t\tlogger.warn({ err: msg }, \"[mcp] flow failed, falling back to default endpoint\");\n\t} finally {\n\t\t// ensure MCP clients are closed after the turn\n\t\tawait drainPool();\n\t}\n\n\treturn \"not_applicable\";\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/mcp/toolInvocation.ts",
    "content": "import { randomUUID } from \"crypto\";\nimport { logger } from \"../../logger\";\nimport type { MessageUpdate } from \"$lib/types/MessageUpdate\";\nimport { MessageToolUpdateType, MessageUpdateType } from \"$lib/types/MessageUpdate\";\nimport { ToolResultStatus } from \"$lib/types/Tool\";\nimport type { ChatCompletionMessageParam } from \"openai/resources/chat/completions\";\nimport type { McpToolMapping } from \"$lib/server/mcp/tools\";\nimport type { McpServerConfig } from \"$lib/server/mcp/httpClient\";\nimport {\n\tcallMcpTool,\n\tgetMcpToolTimeoutMs,\n\ttype McpToolTextResponse,\n} from \"$lib/server/mcp/httpClient\";\nimport { getClient } from \"$lib/server/mcp/clientPool\";\nimport { attachFileRefsToArgs, type FileRefResolver } from \"./fileRefs\";\nimport type { Client } from \"@modelcontextprotocol/sdk/client\";\n\nexport type Primitive = string | number | boolean;\n\nexport type ToolRun = {\n\tname: string;\n\tparameters: Record<string, Primitive>;\n\toutput: string;\n};\n\nexport interface NormalizedToolCall {\n\tid: string;\n\tname: string;\n\targuments: string;\n}\n\nexport interface ExecuteToolCallsParams {\n\tcalls: NormalizedToolCall[];\n\tmapping: Record<string, McpToolMapping>;\n\tservers: McpServerConfig[];\n\tparseArgs: (raw: unknown) => Record<string, unknown>;\n\tresolveFileRef?: FileRefResolver;\n\ttoPrimitive: (value: unknown) => Primitive | undefined;\n\tprocessToolOutput: (text: string) => {\n\t\tannotated: string;\n\t\tsources: { index: number; link: string }[];\n\t};\n\tabortSignal?: AbortSignal;\n\ttoolTimeoutMs?: number;\n}\n\nexport interface ToolCallExecutionResult {\n\ttoolMessages: ChatCompletionMessageParam[];\n\ttoolRuns: ToolRun[];\n\tfinalAnswer?: { text: string; interrupted: boolean };\n}\n\nexport type ToolExecutionEvent =\n\t| { type: \"update\"; update: MessageUpdate }\n\t| { type: \"complete\"; summary: ToolCallExecutionResult };\n\nconst serverMap = (servers: McpServerConfig[]): Map<string, McpServerConfig> => {\n\tconst map = new Map<string, McpServerConfig>();\n\tfor (const server of servers) {\n\t\tif (server?.name) {\n\t\t\tmap.set(server.name, server);\n\t\t}\n\t}\n\treturn map;\n};\n\nexport async function* executeToolCalls({\n\tcalls,\n\tmapping,\n\tservers,\n\tparseArgs,\n\tresolveFileRef,\n\ttoPrimitive,\n\tprocessToolOutput,\n\tabortSignal,\n\ttoolTimeoutMs,\n}: ExecuteToolCallsParams): AsyncGenerator<ToolExecutionEvent, void, undefined> {\n\tconst effectiveTimeoutMs = toolTimeoutMs ?? getMcpToolTimeoutMs();\n\tconst toolMessages: ChatCompletionMessageParam[] = [];\n\tconst toolRuns: ToolRun[] = [];\n\tconst serverLookup = serverMap(servers);\n\t// Pre-emit call + ETA updates and prepare tasks\n\ttype TaskResult = {\n\t\tindex: number;\n\t\toutput?: string;\n\t\tstructured?: unknown;\n\t\tblocks?: unknown[];\n\t\terror?: string;\n\t\tuuid: string;\n\t\tparamsClean: Record<string, Primitive>;\n\t};\n\n\tconst prepared = calls.map((call) => {\n\t\tconst argsObj = parseArgs(call.arguments);\n\t\tconst paramsClean: Record<string, Primitive> = {};\n\t\tfor (const [k, v] of Object.entries(argsObj ?? {})) {\n\t\t\tconst prim = toPrimitive(v);\n\t\t\tif (prim !== undefined) paramsClean[k] = prim;\n\t\t}\n\t\t// Attach any resolved image payloads _after_ computing paramsClean so that\n\t\t// logging / status updates continue to show only the lightweight primitive\n\t\t// arguments (e.g. \"image_1\") while the full data: URLs or image blobs are\n\t\t// only sent to the MCP tool server.\n\t\tattachFileRefsToArgs(argsObj, resolveFileRef);\n\t\treturn { call, argsObj, paramsClean, uuid: randomUUID() };\n\t});\n\n\tfor (const p of prepared) {\n\t\tyield {\n\t\t\ttype: \"update\",\n\t\t\tupdate: {\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Call,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tcall: { name: p.call.name, parameters: p.paramsClean },\n\t\t\t},\n\t\t};\n\t\tyield {\n\t\t\ttype: \"update\",\n\t\t\tupdate: {\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.ETA,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\teta: 10,\n\t\t\t},\n\t\t};\n\t}\n\n\t// Preload clients per distinct server used in this batch\n\tconst distinctServerNames = Array.from(\n\t\tnew Set(prepared.map((p) => mapping[p.call.name]?.server).filter(Boolean) as string[])\n\t);\n\tconst clientMap = new Map<string, Client>();\n\tawait Promise.all(\n\t\tdistinctServerNames.map(async (name) => {\n\t\t\tconst cfg = serverLookup.get(name);\n\t\t\tif (!cfg) return;\n\t\t\ttry {\n\t\t\t\tconst client = await getClient(cfg, abortSignal);\n\t\t\t\tclientMap.set(name, client);\n\t\t\t} catch (e) {\n\t\t\t\tlogger.warn({ server: name, err: String(e) }, \"[mcp] failed to connect client\");\n\t\t\t}\n\t\t})\n\t);\n\n\t// Async queue to stream results in finish order\n\tfunction createQueue<T>() {\n\t\tconst items: T[] = [];\n\t\tconst waiters: Array<(v: IteratorResult<T>) => void> = [];\n\t\tlet closed = false;\n\t\treturn {\n\t\t\tpush(item: T) {\n\t\t\t\tconst waiter = waiters.shift();\n\t\t\t\tif (waiter) waiter({ value: item, done: false });\n\t\t\t\telse items.push(item);\n\t\t\t},\n\t\t\tclose() {\n\t\t\t\tclosed = true;\n\t\t\t\tlet waiter: ((v: IteratorResult<T>) => void) | undefined;\n\t\t\t\twhile ((waiter = waiters.shift())) {\n\t\t\t\t\twaiter({ value: undefined as unknown as T, done: true });\n\t\t\t\t}\n\t\t\t},\n\t\t\tasync *iterator() {\n\t\t\t\tfor (;;) {\n\t\t\t\t\tif (items.length) {\n\t\t\t\t\t\tconst first = items.shift();\n\t\t\t\t\t\tif (first !== undefined) yield first as T;\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (closed) return;\n\t\t\t\t\tconst value: IteratorResult<T> = await new Promise((res) => waiters.push(res));\n\t\t\t\t\tif (value.done) return;\n\t\t\t\t\tyield value.value as T;\n\t\t\t\t}\n\t\t\t},\n\t\t};\n\t}\n\n\tconst updatesQueue = createQueue<MessageUpdate>();\n\tconst results: TaskResult[] = [];\n\n\tconst tasks = prepared.map(async (p, index) => {\n\t\t// Check abort before starting each tool call\n\t\tif (abortSignal?.aborted) {\n\t\t\tconst message = \"Aborted by user\";\n\t\t\tresults.push({\n\t\t\t\tindex,\n\t\t\t\terror: message,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tparamsClean: p.paramsClean,\n\t\t\t});\n\t\t\tupdatesQueue.push({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Error,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tmessage,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst mappingEntry = mapping[p.call.name];\n\t\tif (!mappingEntry) {\n\t\t\tconst message = `Unknown MCP function: ${p.call.name}`;\n\t\t\tresults.push({\n\t\t\t\tindex,\n\t\t\t\terror: message,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tparamsClean: p.paramsClean,\n\t\t\t});\n\t\t\tupdatesQueue.push({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Error,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tmessage,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tconst serverCfg = serverLookup.get(mappingEntry.server);\n\t\tif (!serverCfg) {\n\t\t\tconst message = `Unknown MCP server: ${mappingEntry.server}`;\n\t\t\tresults.push({\n\t\t\t\tindex,\n\t\t\t\terror: message,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tparamsClean: p.paramsClean,\n\t\t\t});\n\t\t\tupdatesQueue.push({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Error,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tmessage,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tconst client = clientMap.get(mappingEntry.server);\n\t\ttry {\n\t\t\tlogger.debug(\n\t\t\t\t{ server: mappingEntry.server, tool: mappingEntry.tool, parameters: p.paramsClean },\n\t\t\t\t\"[mcp] invoking tool\"\n\t\t\t);\n\t\t\tconst toolResponse: McpToolTextResponse = await callMcpTool(\n\t\t\t\tserverCfg,\n\t\t\t\tmappingEntry.tool,\n\t\t\t\tp.argsObj,\n\t\t\t\t{\n\t\t\t\t\tclient,\n\t\t\t\t\tsignal: abortSignal,\n\t\t\t\t\ttimeoutMs: effectiveTimeoutMs,\n\t\t\t\t\tonProgress: (progress) => {\n\t\t\t\t\t\tupdatesQueue.push({\n\t\t\t\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\t\t\t\tsubtype: MessageToolUpdateType.Progress,\n\t\t\t\t\t\t\tuuid: p.uuid,\n\t\t\t\t\t\t\tprogress: progress.progress,\n\t\t\t\t\t\t\ttotal: progress.total,\n\t\t\t\t\t\t\tmessage: progress.message,\n\t\t\t\t\t\t});\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t);\n\t\t\tconst { annotated } = processToolOutput(toolResponse.text ?? \"\");\n\t\t\tlogger.debug(\n\t\t\t\t{ server: mappingEntry.server, tool: mappingEntry.tool },\n\t\t\t\t\"[mcp] tool call completed\"\n\t\t\t);\n\t\t\tresults.push({\n\t\t\t\tindex,\n\t\t\t\toutput: annotated,\n\t\t\t\tstructured: toolResponse.structured,\n\t\t\t\tblocks: toolResponse.content,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tparamsClean: p.paramsClean,\n\t\t\t});\n\t\t\tupdatesQueue.push({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Result,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tresult: {\n\t\t\t\t\tstatus: ToolResultStatus.Success,\n\t\t\t\t\tcall: { name: p.call.name, parameters: p.paramsClean },\n\t\t\t\t\toutputs: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttext: annotated ?? \"\",\n\t\t\t\t\t\t\tstructured: toolResponse.structured,\n\t\t\t\t\t\t\tcontent: toolResponse.content,\n\t\t\t\t\t\t} as unknown as Record<string, unknown>,\n\t\t\t\t\t],\n\t\t\t\t\tdisplay: true,\n\t\t\t\t},\n\t\t\t});\n\t\t} catch (err) {\n\t\t\tconst errMsg = err instanceof Error ? err.message : String(err);\n\t\t\tconst errName = err instanceof Error ? err.name : \"\";\n\t\t\tconst isAbortError =\n\t\t\t\tabortSignal?.aborted ||\n\t\t\t\terrName === \"AbortError\" ||\n\t\t\t\terrName === \"APIUserAbortError\" ||\n\t\t\t\terrMsg === \"Request was aborted.\" ||\n\t\t\t\terrMsg === \"This operation was aborted\";\n\t\t\tconst message = isAbortError ? \"Aborted by user\" : errMsg;\n\n\t\t\tif (isAbortError) {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t{ server: mappingEntry.server, tool: mappingEntry.tool },\n\t\t\t\t\t\"[mcp] tool call aborted by user\"\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t{ server: mappingEntry.server, tool: mappingEntry.tool, err: message },\n\t\t\t\t\t\"[mcp] tool call failed\"\n\t\t\t\t);\n\t\t\t}\n\t\t\tresults.push({ index, error: message, uuid: p.uuid, paramsClean: p.paramsClean });\n\t\t\tupdatesQueue.push({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Error,\n\t\t\t\tuuid: p.uuid,\n\t\t\t\tmessage,\n\t\t\t});\n\t\t}\n\t});\n\n\t// kick off and stream as they finish\n\tPromise.allSettled(tasks).then(() => updatesQueue.close());\n\n\tfor await (const update of updatesQueue.iterator()) {\n\t\tyield { type: \"update\", update };\n\t}\n\n\t// Collate outputs in original call order\n\tresults.sort((a, b) => a.index - b.index);\n\tfor (const r of results) {\n\t\tconst name = prepared[r.index].call.name;\n\t\tconst id = prepared[r.index].call.id;\n\t\tif (!r.error) {\n\t\t\tconst output = r.output ?? \"\";\n\t\t\ttoolRuns.push({ name, parameters: r.paramsClean, output });\n\t\t\t// For the LLM follow-up call, we keep only the textual output\n\t\t\ttoolMessages.push({ role: \"tool\", tool_call_id: id, content: output });\n\t\t} else {\n\t\t\t// Communicate error to LLM so it doesn't hallucinate success\n\t\t\ttoolMessages.push({ role: \"tool\", tool_call_id: id, content: `Error: ${r.error}` });\n\t\t}\n\t}\n\n\tyield { type: \"complete\", summary: { toolMessages, toolRuns } };\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/reasoning.ts",
    "content": "import { generateFromDefaultEndpoint } from \"$lib/server/generateFromDefaultEndpoint\";\nimport { MessageUpdateType } from \"$lib/types/MessageUpdate\";\n\nexport async function generateSummaryOfReasoning(\n\treasoning: string,\n\tmodelId: string | undefined,\n\tlocals: App.Locals | undefined\n): Promise<string> {\n\tconst prompt = `Summarize concisely the following reasoning for the user. Keep it short (one short paragraph).\\n\\n${reasoning}`;\n\tconst summary = await (async () => {\n\t\tconst it = generateFromDefaultEndpoint({\n\t\t\tmessages: [{ from: \"user\", content: prompt }],\n\t\t\tmodelId,\n\t\t\tlocals,\n\t\t});\n\t\tlet out = \"\";\n\t\tfor await (const update of it) {\n\t\t\tif (update.type === MessageUpdateType.Stream) out += update.token;\n\t\t}\n\t\treturn out;\n\t})();\n\treturn summary.trim();\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/title.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { generateFromDefaultEndpoint } from \"$lib/server/generateFromDefaultEndpoint\";\nimport { logger } from \"$lib/server/logger\";\nimport { MessageUpdateType, type MessageUpdate } from \"$lib/types/MessageUpdate\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport { getReturnFromGenerator } from \"$lib/utils/getReturnFromGenerator\";\n\nexport async function* generateTitleForConversation(\n\tconv: Conversation,\n\tlocals: App.Locals | undefined\n): AsyncGenerator<MessageUpdate, undefined, undefined> {\n\ttry {\n\t\tconst userMessage = conv.messages.find((m) => m.from === \"user\");\n\t\t// HACK: detect if the conversation is new\n\t\tif (conv.title !== \"New Chat\" || !userMessage) return;\n\n\t\tconst prompt = userMessage.content;\n\t\tconst modelForTitle = config.TASK_MODEL?.trim() ? config.TASK_MODEL : conv.model;\n\t\tconst title = (await generateTitle(prompt, modelForTitle, locals)) ?? \"New Chat\";\n\n\t\tyield {\n\t\t\ttype: MessageUpdateType.Title,\n\t\t\ttitle,\n\t\t};\n\t} catch (cause) {\n\t\tlogger.error(cause, \"Failed while generating title for conversation\");\n\t}\n}\n\nasync function generateTitle(\n\tprompt: string,\n\tmodelId: string | undefined,\n\tlocals: App.Locals | undefined\n) {\n\tif (config.LLM_SUMMARIZATION !== \"true\") {\n\t\t// When summarization is disabled, use the first five words without adding emojis\n\t\treturn prompt.split(/\\s+/g).slice(0, 5).join(\" \");\n\t}\n\n\t// Tools removed: no tool-based title path\n\n\treturn await getReturnFromGenerator(\n\t\tgenerateFromDefaultEndpoint({\n\t\t\tmessages: [{ from: \"user\", content: `User message: \"${prompt}\"` }],\n\t\t\tpreprompt: `You are a chat thread titling assistant.\nGoal: Produce a very short, descriptive title (2–4 words) that names the topic of the user's first message.\n\nRules:\n- Output ONLY the title text. No prefixes, labels, quotes, emojis, hashtags, or trailing punctuation.\n- Use the user's language.\n- Write a noun phrase that names the topic. Do not write instructions.\n- Never output just a pronoun (me/you/I/we/us/myself/yourself). Prefer a neutral subject (e.g., \"Assistant\", \"model\", or the concrete topic).\n- Never include meta-words: Summarize, Summary, Title, Prompt, Topic, Subject, About, Question, Request, Chat.\n\nExamples:\nUser: \"Summarize hello\" -> Hello\nUser: \"How do I reverse a string in Python?\" -> Python string reversal\nUser: \"help me plan a NYC weekend\" -> NYC weekend plan\nUser: \"请解释Transformer是如何工作的\" -> Transformer 工作原理\nUser: \"tell me more about you\" -> About the assistant\nReturn only the title text.`,\n\t\t\tgenerateSettings: {\n\t\t\t\tmax_tokens: 24,\n\t\t\t\ttemperature: 0,\n\t\t\t},\n\t\t\tmodelId,\n\t\t\tlocals,\n\t\t})\n\t)\n\t\t.then((summary) => {\n\t\t\tconst firstFive = prompt.split(/\\s+/g).slice(0, 5).join(\" \");\n\t\t\tconst trimmed = String(summary ?? \"\").trim();\n\t\t\t// Fallback: if empty, return first five words only (no emoji)\n\t\t\treturn trimmed || firstFive;\n\t\t})\n\t\t.catch((e) => {\n\t\t\tlogger.error(e, \"Error generating title\");\n\t\t\tconst firstFive = prompt.split(/\\s+/g).slice(0, 5).join(\" \");\n\t\t\treturn firstFive;\n\t\t});\n}\n\n// No post-processing: rely solely on prompt instructions above\n"
  },
  {
    "path": "src/lib/server/textGeneration/types.ts",
    "content": "import type { ProcessedModel } from \"../models\";\nimport type { Endpoint } from \"../endpoints/endpoints\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport type { Message } from \"$lib/types/Message\";\nimport type { Assistant } from \"$lib/types/Assistant\";\n\nexport interface TextGenerationContext {\n\tmodel: ProcessedModel;\n\tendpoint: Endpoint;\n\tconv: Conversation;\n\tmessages: Message[];\n\tassistant?: Pick<Assistant, \"dynamicPrompt\" | \"generateSettings\">;\n\tpromptedAt: Date;\n\tip: string;\n\tusername?: string;\n\t/** Force-enable multimodal handling for endpoints that support it */\n\tforceMultimodal?: boolean;\n\t/** Force-enable tool calling even if model does not advertise support */\n\tforceTools?: boolean;\n\t/** Inference provider preference: \"auto\", \"fastest\", \"cheapest\", or a specific provider name */\n\tprovider?: string;\n\tlocals: App.Locals | undefined;\n\tabortController: AbortController;\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/utils/prepareFiles.ts",
    "content": "import type { MessageFile } from \"$lib/types/Message\";\nimport type { EndpointMessage } from \"$lib/server/endpoints/endpoints\";\nimport type { OpenAI } from \"openai\";\nimport { TEXT_MIME_ALLOWLIST } from \"$lib/constants/mime\";\nimport type { makeImageProcessor } from \"$lib/server/endpoints/images\";\n\n/**\n * Prepare chat messages for OpenAI-compatible multimodal payloads.\n * - Processes images via the provided imageProcessor (resize/convert) when multimodal is enabled.\n * - Injects text-file content into the user message text.\n * - Leaves messages untouched when no files or multimodal disabled.\n */\nexport async function prepareMessagesWithFiles(\n\tmessages: EndpointMessage[],\n\timageProcessor: ReturnType<typeof makeImageProcessor>,\n\tisMultimodal: boolean\n): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {\n\treturn Promise.all(\n\t\tmessages.map(async (message) => {\n\t\t\tif (message.from === \"user\" && message.files && message.files.length > 0) {\n\t\t\t\tconst { imageParts, textContent } = await prepareFiles(\n\t\t\t\t\timageProcessor,\n\t\t\t\t\tmessage.files,\n\t\t\t\t\tisMultimodal\n\t\t\t\t);\n\n\t\t\t\tlet messageText = message.content;\n\t\t\t\tif (textContent.length > 0) {\n\t\t\t\t\tmessageText = textContent + \"\\n\\n\" + message.content;\n\t\t\t\t}\n\n\t\t\t\tif (imageParts.length > 0 && isMultimodal) {\n\t\t\t\t\tconst parts = [{ type: \"text\" as const, text: messageText }, ...imageParts];\n\t\t\t\t\treturn { role: message.from, content: parts };\n\t\t\t\t}\n\n\t\t\t\treturn { role: message.from, content: messageText };\n\t\t\t}\n\t\t\treturn { role: message.from, content: message.content };\n\t\t})\n\t);\n}\n\nasync function prepareFiles(\n\timageProcessor: ReturnType<typeof makeImageProcessor>,\n\tfiles: MessageFile[],\n\tisMultimodal: boolean\n): Promise<{\n\timageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[];\n\ttextContent: string;\n}> {\n\tconst imageFiles = files.filter((file) => file.mime.startsWith(\"image/\"));\n\tconst textFiles = files.filter((file) => {\n\t\tconst mime = (file.mime || \"\").toLowerCase();\n\t\tconst [fileType, fileSubtype] = mime.split(\"/\");\n\t\treturn TEXT_MIME_ALLOWLIST.some((allowed) => {\n\t\t\tconst [type, subtype] = allowed.toLowerCase().split(\"/\");\n\t\t\tconst typeOk = type === \"*\" || type === fileType;\n\t\t\tconst subOk = subtype === \"*\" || subtype === fileSubtype;\n\t\t\treturn typeOk && subOk;\n\t\t});\n\t});\n\n\tlet imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[] = [];\n\tif (isMultimodal && imageFiles.length > 0) {\n\t\tconst processedFiles = await Promise.all(imageFiles.map(imageProcessor));\n\t\timageParts = processedFiles.map((file) => ({\n\t\t\ttype: \"image_url\" as const,\n\t\t\timage_url: {\n\t\t\t\turl: `data:${file.mime};base64,${file.image.toString(\"base64\")}`,\n\t\t\t\tdetail: \"auto\",\n\t\t\t},\n\t\t}));\n\t}\n\n\tlet textContent = \"\";\n\tif (textFiles.length > 0) {\n\t\tconst textParts = await Promise.all(\n\t\t\ttextFiles.map(async (file) => {\n\t\t\t\tconst content = Buffer.from(file.value, \"base64\").toString(\"utf-8\");\n\t\t\t\treturn `<document name=\"${file.name}\" type=\"${file.mime}\">\\n${content}\\n</document>`;\n\t\t\t})\n\t\t);\n\t\ttextContent = textParts.join(\"\\n\\n\");\n\t}\n\n\treturn { imageParts, textContent };\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/utils/routing.ts",
    "content": "import type { EndpointMessage } from \"../../endpoints/endpoints\";\n\nconst ROUTER_REASONING_REGEX = /<think>[\\s\\S]*?(?:<\\/think>|$)/g;\n\nexport function stripReasoningBlocks(text: string): string {\n\tconst stripped = text.replace(ROUTER_REASONING_REGEX, \"\");\n\treturn stripped === text ? text : stripped.trim();\n}\n\nexport function stripReasoningFromMessageForRouting(message: EndpointMessage): EndpointMessage {\n\tconst clone = { ...message } as EndpointMessage & { reasoning?: string };\n\tif (\"reasoning\" in clone) {\n\t\tdelete clone.reasoning;\n\t}\n\tconst content =\n\t\ttypeof message.content === \"string\" ? stripReasoningBlocks(message.content) : message.content;\n\treturn {\n\t\t...clone,\n\t\tcontent,\n\t};\n}\n"
  },
  {
    "path": "src/lib/server/textGeneration/utils/toolPrompt.ts",
    "content": "import type { OpenAiTool } from \"$lib/server/mcp/tools\";\n\nexport function buildToolPreprompt(tools: OpenAiTool[]): string {\n\tif (!Array.isArray(tools) || tools.length === 0) return \"\";\n\tconst names = tools\n\t\t.map((t) => (t?.function?.name ? String(t.function.name) : \"\"))\n\t\t.filter((s) => s.length > 0);\n\tif (names.length === 0) return \"\";\n\tconst now = new Date();\n\tconst currentDate = now.toLocaleDateString(\"en-US\", {\n\t\tyear: \"numeric\",\n\t\tmonth: \"long\",\n\t\tday: \"numeric\",\n\t});\n\tconst isoDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, \"0\")}-${String(now.getDate()).padStart(2, \"0\")}`;\n\treturn [\n\t\t`You have access to these tools: ${names.join(\", \")}.`,\n\t\t`Today's date: ${currentDate} (${isoDate}).`,\n\t\t`IMPORTANT: Do NOT call a tool unless the user's request requires capabilities you lack (e.g., real-time data, image generation, code execution) or external information you do not have. For tasks like writing code, creative writing, math, or building apps, respond directly without tools. When in doubt, do not use a tool.`,\n\t\t`PARALLEL TOOL CALLS: When multiple tool calls are needed and they are independent of each other (i.e., one does not need the result of another), call them all at once in a single response instead of one at a time. Only chain tool calls sequentially when a later call depends on an earlier call's output.`,\n\t\t`SEARCH: Use 3-6 precise keywords. For historical events, include the year the event occurred. For recent or current topics, use today's year (${now.getFullYear()}). When a tool accepts date-range parameters (e.g., startPublishedDate, endPublishedDate), always use today's date (${isoDate}) as the end date unless the user specifies otherwise. For multi-part questions, search each part separately.`,\n\t\t`ANSWER: State only facts explicitly in the results. If info is missing or results conflict, say so. Never fabricate URLs or facts.`,\n\t\t`INTERACTIVE APPS: When asked to build an interactive application, game, or visualization without a specific language/framework preference, create a single self-contained HTML file with embedded CSS and JavaScript.`,\n\t\t`If a tool generates an image, you can inline it directly: ![alt text](image_url).`,\n\t\t`If a tool needs an image, set its image field (\"input_image\", \"image\", or \"image_url\") to a reference like \"image_1\", \"image_2\", etc. (ordered by when the user uploaded them).`,\n\t\t`Default to image references; only use a full http(s) URL when the tool description explicitly asks for one, or reuse a URL a previous tool returned.`,\n\t].join(\" \");\n}\n"
  },
  {
    "path": "src/lib/server/urlSafety.ts",
    "content": "import { Address4, Address6 } from \"ip-address\";\nimport { isIP } from \"node:net\";\n\nconst UNSAFE_IPV4_SUBNETS = [\n\t\"0.0.0.0/8\",\n\t\"100.64.0.0/10\",\n\t\"127.0.0.0/8\",\n\t\"169.254.0.0/16\",\n\t\"172.16.0.0/12\",\n\t\"192.168.0.0/16\",\n].map((s) => new Address4(s));\n\nfunction isUnsafeIp(address: string): boolean {\n\tconst family = isIP(address);\n\n\tif (family === 4) {\n\t\tconst addr = new Address4(address);\n\t\treturn UNSAFE_IPV4_SUBNETS.some((subnet) => addr.isInSubnet(subnet));\n\t}\n\n\tif (family === 6) {\n\t\tconst addr = new Address6(address);\n\t\t// Check IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)\n\t\tif (addr.is4()) {\n\t\t\tconst v4 = addr.to4();\n\t\t\treturn UNSAFE_IPV4_SUBNETS.some((subnet) => v4.isInSubnet(subnet));\n\t\t}\n\t\treturn addr.isLoopback() || addr.isLinkLocal();\n\t}\n\n\treturn true; // Unknown format → block\n}\n\n/**\n * Synchronous URL validation: checks protocol and hostname string.\n */\nexport function isValidUrl(urlString: string): boolean {\n\ttry {\n\t\tconst url = new URL(urlString.trim());\n\t\tif (url.protocol !== \"https:\") {\n\t\t\treturn false;\n\t\t}\n\t\tconst hostname = url.hostname.toLowerCase();\n\t\tif (hostname === \"localhost\") {\n\t\t\treturn false;\n\t\t}\n\t\t// If the hostname is a raw IP literal, validate it\n\t\tconst cleanHostname = hostname.replace(/^\\[|]$/g, \"\");\n\t\tif (isIP(cleanHostname)) {\n\t\t\treturn !isUnsafeIp(cleanHostname);\n\t\t}\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Assert that a resolved IP address is safe (not internal/private).\n * Throws if the IP is internal. Used in undici's custom DNS lookup\n * to validate IPs at connection time (prevents TOCTOU DNS rebinding).\n */\nexport function assertSafeIp(address: string, hostname: string): void {\n\tif (isUnsafeIp(address)) {\n\t\tthrow new Error(`Resolved IP for ${hostname} is internal (${address})`);\n\t}\n}\n"
  },
  {
    "path": "src/lib/server/usageLimits.ts",
    "content": "import { z } from \"zod\";\nimport { config } from \"$lib/server/config\";\nimport JSON5 from \"json5\";\n\nconst sanitizeJSONEnv = (val: string, fallback: string) => {\n\tconst raw = (val ?? \"\").trim();\n\tconst unquoted = raw.startsWith(\"`\") && raw.endsWith(\"`\") ? raw.slice(1, -1) : raw;\n\treturn unquoted || fallback;\n};\n\n// RATE_LIMIT is the legacy way to define messages per minute limit\nexport const usageLimitsSchema = z\n\t.object({\n\t\tconversations: z.coerce.number().optional(), // how many conversations\n\t\tmessages: z.coerce.number().optional(), // how many messages in a conversation\n\t\tmessageLength: z.coerce.number().optional(), // how long can a message be before we cut it off\n\t\tmessagesPerMinute: z\n\t\t\t.preprocess((val) => {\n\t\t\t\tif (val === undefined) {\n\t\t\t\t\treturn config.RATE_LIMIT;\n\t\t\t\t}\n\t\t\t\treturn val;\n\t\t\t}, z.coerce.number().optional())\n\t\t\t.optional(), // how many messages per minute\n\t})\n\t.optional();\n\nexport const usageLimits = usageLimitsSchema.parse(\n\tJSON5.parse(sanitizeJSONEnv(config.USAGE_LIMITS, \"{}\"))\n);\n"
  },
  {
    "path": "src/lib/stores/backgroundGenerations.svelte.ts",
    "content": "export type BackgroundGeneration = {\n\tid: string;\n\tstartedAt: number;\n};\n\nexport const backgroundGenerationEntries = $state<BackgroundGeneration[]>([]);\n\nexport function addBackgroundGeneration(entry: BackgroundGeneration) {\n\tconst index = backgroundGenerationEntries.findIndex(({ id }) => id === entry.id);\n\n\tif (index === -1) {\n\t\tbackgroundGenerationEntries.push(entry);\n\t\treturn;\n\t}\n\n\tbackgroundGenerationEntries[index] = entry;\n}\n\nexport function removeBackgroundGeneration(id: string) {\n\tconst index = backgroundGenerationEntries.findIndex((entry) => entry.id === id);\n\tif (index === -1) return;\n\n\tbackgroundGenerationEntries.splice(index, 1);\n}\n\nexport function clearBackgroundGenerations() {\n\tbackgroundGenerationEntries.length = 0;\n}\n\nexport function hasBackgroundGeneration(id: string) {\n\treturn backgroundGenerationEntries.some((entry) => entry.id === id);\n}\n"
  },
  {
    "path": "src/lib/stores/backgroundGenerations.ts",
    "content": "export * from \"./backgroundGenerations.svelte\";\n"
  },
  {
    "path": "src/lib/stores/errors.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport const ERROR_MESSAGES = {\n\tdefault: \"Oops, something went wrong.\",\n\tauthOnly: \"You have to be logged in.\",\n\trateLimited: \"You are sending too many messages. Try again later.\",\n};\n\nexport const error = writable<string | undefined>(undefined);\n"
  },
  {
    "path": "src/lib/stores/isAborted.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport const isAborted = writable<boolean>(false);\n"
  },
  {
    "path": "src/lib/stores/isPro.ts",
    "content": "import { writable } from \"svelte/store\";\n\n// null = unknown/loading, true = PRO, false = not PRO\nexport const isPro = writable<boolean | null>(null);\n"
  },
  {
    "path": "src/lib/stores/loading.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport const loading = writable(false);\n"
  },
  {
    "path": "src/lib/stores/mcpServers.ts",
    "content": "/**\n * MCP Servers Store\n * Manages base (env-configured) and custom (user-added) MCP servers\n * Stores custom servers and selection state in browser localStorage\n */\n\nimport { writable, derived, get } from \"svelte/store\";\nimport { base } from \"$app/paths\";\nimport { env as publicEnv } from \"$env/dynamic/public\";\nimport { browser } from \"$app/environment\";\nimport type { MCPServer, ServerStatus, MCPTool } from \"$lib/types/Tool\";\n\n// Namespace storage by app identity to avoid collisions across apps\nfunction toKeyPart(s: string | undefined): string {\n\treturn (s || \"\").toLowerCase().replace(/[^a-z0-9_-]+/g, \"-\");\n}\n\nconst appLabel = toKeyPart(publicEnv.PUBLIC_APP_ASSETS || publicEnv.PUBLIC_APP_NAME);\nconst baseLabel = toKeyPart(typeof base === \"string\" ? base : \"\");\n// Final prefix format requested: \"huggingchat:key\" (no mcp:/chat)\nconst KEY_PREFIX = appLabel || baseLabel || \"app\";\n\nconst STORAGE_KEYS = {\n\tCUSTOM_SERVERS: `${KEY_PREFIX}:mcp:custom-servers`,\n\tSELECTED_IDS: `${KEY_PREFIX}:mcp:selected-ids`,\n\tDISABLED_BASE_IDS: `${KEY_PREFIX}:mcp:disabled-base-ids`,\n} as const;\n\n// No migration needed per request — read/write only namespaced keys\n\n// Load custom servers from localStorage\nfunction loadCustomServers(): MCPServer[] {\n\tif (!browser) return [];\n\n\ttry {\n\t\tconst json = localStorage.getItem(STORAGE_KEYS.CUSTOM_SERVERS);\n\t\treturn json ? JSON.parse(json) : [];\n\t} catch (error) {\n\t\tconsole.error(\"Failed to load custom MCP servers from localStorage:\", error);\n\t\treturn [];\n\t}\n}\n\n// Load selected server IDs from localStorage\nfunction loadSelectedIds(): Set<string> {\n\tif (!browser) return new Set();\n\n\ttry {\n\t\tconst json = localStorage.getItem(STORAGE_KEYS.SELECTED_IDS);\n\t\tconst ids: string[] = json ? JSON.parse(json) : [];\n\t\treturn new Set(ids);\n\t} catch (error) {\n\t\tconsole.error(\"Failed to load selected MCP server IDs from localStorage:\", error);\n\t\treturn new Set();\n\t}\n}\n\n// Save custom servers to localStorage\nfunction saveCustomServers(servers: MCPServer[]) {\n\tif (!browser) return;\n\n\ttry {\n\t\tlocalStorage.setItem(STORAGE_KEYS.CUSTOM_SERVERS, JSON.stringify(servers));\n\t} catch (error) {\n\t\tconsole.error(\"Failed to save custom MCP servers to localStorage:\", error);\n\t}\n}\n\n// Save selected IDs to localStorage\nfunction saveSelectedIds(ids: Set<string>) {\n\tif (!browser) return;\n\n\ttry {\n\t\tlocalStorage.setItem(STORAGE_KEYS.SELECTED_IDS, JSON.stringify([...ids]));\n\t} catch (error) {\n\t\tconsole.error(\"Failed to save selected MCP server IDs to localStorage:\", error);\n\t}\n}\n\n// Load disabled base server IDs from localStorage (empty set if missing or on error)\nfunction loadDisabledBaseIds(): Set<string> {\n\tif (!browser) return new Set();\n\n\ttry {\n\t\tconst json = localStorage.getItem(STORAGE_KEYS.DISABLED_BASE_IDS);\n\t\treturn new Set(json ? JSON.parse(json) : []);\n\t} catch (error) {\n\t\tconsole.error(\"Failed to load disabled base MCP server IDs from localStorage:\", error);\n\t\treturn new Set();\n\t}\n}\n\n// Save disabled base server IDs to localStorage\nfunction saveDisabledBaseIds(ids: Set<string>) {\n\tif (!browser) return;\n\n\ttry {\n\t\tlocalStorage.setItem(STORAGE_KEYS.DISABLED_BASE_IDS, JSON.stringify([...ids]));\n\t} catch (error) {\n\t\tconsole.error(\"Failed to save disabled base MCP server IDs to localStorage:\", error);\n\t}\n}\n\n// Store for all servers (base + custom)\nexport const allMcpServers = writable<MCPServer[]>([]);\n\n// Track if initial server load has completed\nexport const mcpServersLoaded = writable<boolean>(false);\n\n// Store for selected server IDs\nexport const selectedServerIds = writable<Set<string>>(loadSelectedIds());\n\n// Auto-persist selected IDs when they change\nif (browser) {\n\tselectedServerIds.subscribe((ids) => {\n\t\tsaveSelectedIds(ids);\n\t});\n}\n\n// Derived store: only enabled servers\nexport const enabledServers = derived([allMcpServers, selectedServerIds], ([$all, $selected]) =>\n\t$all.filter((s) => $selected.has(s.id))\n);\n\n// Derived store: count of enabled servers\nexport const enabledServersCount = derived(enabledServers, ($enabled) => $enabled.length);\n\n// Derived store: true if all base servers are enabled\nexport const allBaseServersEnabled = derived(\n\t[allMcpServers, selectedServerIds],\n\t([$all, $selected]) => {\n\t\tconst baseServers = $all.filter((s) => s.type === \"base\");\n\t\treturn baseServers.length > 0 && baseServers.every((s) => $selected.has(s.id));\n\t}\n);\n\n// Note: Authorization overlay (with user's HF token) for the Hugging Face MCP host\n// is applied server-side when enabled via MCP_FORWARD_HF_USER_TOKEN.\n\n/**\n * Refresh base servers from API and merge with custom servers\n */\nexport async function refreshMcpServers() {\n\ttry {\n\t\tconst response = await fetch(`${base}/api/mcp/servers`);\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to fetch base servers: ${response.statusText}`);\n\t\t}\n\n\t\tconst baseServers: MCPServer[] = await response.json();\n\t\tconst customServers = loadCustomServers();\n\n\t\t// Merge base and custom servers\n\t\tconst merged = [...baseServers, ...customServers];\n\t\tallMcpServers.set(merged);\n\n\t\t// Load disabled base servers\n\t\tconst disabledBaseIds = loadDisabledBaseIds();\n\n\t\t// Auto-enable all base servers that aren't explicitly disabled\n\t\t// Plus keep any custom servers that were previously selected\n\t\tconst validIds = new Set(merged.map((s) => s.id));\n\t\tselectedServerIds.update(($currentIds) => {\n\t\t\tconst newSelection = new Set<string>();\n\n\t\t\t// Add all base servers that aren't disabled\n\t\t\tfor (const server of baseServers) {\n\t\t\t\tif (!disabledBaseIds.has(server.id)) {\n\t\t\t\t\tnewSelection.add(server.id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Keep custom servers that were selected and still exist\n\t\t\tfor (const id of $currentIds) {\n\t\t\t\tif (validIds.has(id) && !id.startsWith(\"base-\")) {\n\t\t\t\t\tnewSelection.add(id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn newSelection;\n\t\t});\n\t\tmcpServersLoaded.set(true);\n\t} catch (error) {\n\t\tconsole.error(\"Failed to refresh MCP servers:\", error);\n\t\t// On error, just use custom servers\n\t\tallMcpServers.set(loadCustomServers());\n\t\tmcpServersLoaded.set(true);\n\t}\n}\n\n/**\n * Toggle a server on/off\n */\nexport function toggleServer(id: string) {\n\tselectedServerIds.update(($ids) => {\n\t\tconst newSet = new Set($ids);\n\t\tif (newSet.has(id)) {\n\t\t\tnewSet.delete(id);\n\t\t\t// Track if this is a base server being disabled\n\t\t\tif (id.startsWith(\"base-\")) {\n\t\t\t\tconst disabled = loadDisabledBaseIds();\n\t\t\t\tdisabled.add(id);\n\t\t\t\tsaveDisabledBaseIds(disabled);\n\t\t\t}\n\t\t} else {\n\t\t\tnewSet.add(id);\n\t\t\t// Remove from disabled if re-enabling a base server\n\t\t\tif (id.startsWith(\"base-\")) {\n\t\t\t\tconst disabled = loadDisabledBaseIds();\n\t\t\t\tdisabled.delete(id);\n\t\t\t\tsaveDisabledBaseIds(disabled);\n\t\t\t}\n\t\t}\n\t\treturn newSet;\n\t});\n}\n\n/**\n * Disable all MCP servers (marks all base servers as disabled)\n */\nexport function disableAllServers() {\n\t// Get current base server IDs and mark them all as disabled\n\tconst servers = get(allMcpServers);\n\tconst baseServerIds = servers.filter((s) => s.type === \"base\").map((s) => s.id);\n\n\t// Save all base servers as disabled\n\tsaveDisabledBaseIds(new Set(baseServerIds));\n\n\t// Clear the selection\n\tselectedServerIds.set(new Set());\n}\n\n/**\n * Add a custom MCP server\n */\nexport function addCustomServer(server: Omit<MCPServer, \"id\" | \"type\" | \"status\">): string {\n\tconst newServer: MCPServer = {\n\t\t...server,\n\t\tid: crypto.randomUUID(),\n\t\ttype: \"custom\",\n\t\tstatus: \"disconnected\",\n\t};\n\n\tconst customServers = loadCustomServers();\n\tcustomServers.push(newServer);\n\tsaveCustomServers(customServers);\n\n\t// Refresh all servers to include the new one\n\trefreshMcpServers();\n\n\treturn newServer.id;\n}\n\n/**\n * Update an existing custom server\n */\nexport function updateCustomServer(id: string, updates: Partial<MCPServer>) {\n\tconst customServers = loadCustomServers();\n\tconst index = customServers.findIndex((s) => s.id === id);\n\n\tif (index !== -1) {\n\t\tcustomServers[index] = { ...customServers[index], ...updates };\n\t\tsaveCustomServers(customServers);\n\t\trefreshMcpServers();\n\t}\n}\n\n/**\n * Delete a custom server\n */\nexport function deleteCustomServer(id: string) {\n\tconst customServers = loadCustomServers();\n\tconst filtered = customServers.filter((s) => s.id !== id);\n\tsaveCustomServers(filtered);\n\n\t// Also remove from selected IDs\n\tselectedServerIds.update(($ids) => {\n\t\tconst newSet = new Set($ids);\n\t\tnewSet.delete(id);\n\t\treturn newSet;\n\t});\n\n\trefreshMcpServers();\n}\n\n/**\n * Update server status (from health check)\n */\nexport function updateServerStatus(\n\tid: string,\n\tstatus: ServerStatus,\n\terrorMessage?: string,\n\ttools?: MCPTool[],\n\tauthRequired?: boolean\n) {\n\tallMcpServers.update(($servers) =>\n\t\t$servers.map((s) =>\n\t\t\ts.id === id\n\t\t\t\t? {\n\t\t\t\t\t\t...s,\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t\terrorMessage,\n\t\t\t\t\t\ttools,\n\t\t\t\t\t\tauthRequired,\n\t\t\t\t\t}\n\t\t\t\t: s\n\t\t)\n\t);\n}\n\n/**\n * Run health check on a server\n */\nexport async function healthCheckServer(\n\tserver: MCPServer\n): Promise<{ ready: boolean; tools?: MCPTool[]; error?: string }> {\n\ttry {\n\t\tupdateServerStatus(server.id, \"connecting\");\n\n\t\tconst response = await fetch(`${base}/api/mcp/health`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({ url: server.url, headers: server.headers }),\n\t\t});\n\n\t\tconst result = await response.json();\n\n\t\tif (result.ready && result.tools) {\n\t\t\tupdateServerStatus(server.id, \"connected\", undefined, result.tools, false);\n\t\t\treturn { ready: true, tools: result.tools };\n\t\t} else {\n\t\t\tupdateServerStatus(server.id, \"error\", result.error, undefined, Boolean(result.authRequired));\n\t\t\treturn { ready: false, error: result.error };\n\t\t}\n\t} catch (error) {\n\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error\";\n\t\tupdateServerStatus(server.id, \"error\", errorMessage);\n\t\treturn { ready: false, error: errorMessage };\n\t}\n}\n\n// Initialize on module load\nif (browser) {\n\trefreshMcpServers();\n}\n"
  },
  {
    "path": "src/lib/stores/pendingChatInput.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport const pendingChatInput = writable<string | undefined>(undefined);\n"
  },
  {
    "path": "src/lib/stores/pendingMessage.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport const pendingMessage = writable<\n\t| {\n\t\t\tcontent: string;\n\t\t\tfiles: File[];\n\t  }\n\t| undefined\n>();\n"
  },
  {
    "path": "src/lib/stores/settings.ts",
    "content": "import { browser } from \"$app/environment\";\nimport { invalidate } from \"$app/navigation\";\nimport { base } from \"$app/paths\";\nimport type { StreamingMode } from \"$lib/types/Settings\";\nimport { UrlDependency } from \"$lib/types/UrlDependency\";\nimport { getContext, setContext } from \"svelte\";\nimport { type Writable, writable, get } from \"svelte/store\";\n\ntype SettingsStore = {\n\tshareConversationsWithModelAuthors: boolean;\n\twelcomeModalSeen: boolean;\n\twelcomeModalSeenAt: Date | null;\n\tactiveModel: string;\n\tcustomPrompts: Record<string, string>;\n\tmultimodalOverrides: Record<string, boolean>;\n\ttoolsOverrides: Record<string, boolean>;\n\thidePromptExamples: Record<string, boolean>;\n\tproviderOverrides: Record<string, string>;\n\trecentlySaved: boolean;\n\tstreamingMode: StreamingMode;\n\tdirectPaste: boolean;\n\thapticsEnabled: boolean;\n\tbillingOrganization?: string;\n};\n\ntype SettingsStoreWritable = Writable<SettingsStore> & {\n\tinstantSet: (settings: Partial<SettingsStore>) => Promise<void>;\n\tinitValue: <K extends keyof SettingsStore>(\n\t\tkey: K,\n\t\tnestedKey: string,\n\t\tvalue: string | boolean\n\t) => Promise<void>;\n};\n\nexport function useSettingsStore() {\n\treturn getContext<SettingsStoreWritable>(\"settings\");\n}\n\nexport function createSettingsStore(initialValue: Omit<SettingsStore, \"recentlySaved\">) {\n\tconst baseStore = writable({ ...initialValue, recentlySaved: false });\n\n\tlet timeoutId: NodeJS.Timeout;\n\tlet showSavedOnNextSync = false;\n\n\tasync function setSettings(settings: Partial<SettingsStore>) {\n\t\tbaseStore.update((s) => ({\n\t\t\t...s,\n\t\t\t...settings,\n\t\t}));\n\n\t\tif (browser) {\n\t\t\tshowSavedOnNextSync = true; // User edit, should show \"Saved\"\n\t\t\tclearTimeout(timeoutId);\n\t\t\ttimeoutId = setTimeout(async () => {\n\t\t\t\tawait fetch(`${base}/settings`, {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\t},\n\t\t\t\t\tbody: JSON.stringify(get(baseStore)),\n\t\t\t\t});\n\n\t\t\t\tinvalidate(UrlDependency.ConversationList);\n\n\t\t\t\tif (showSavedOnNextSync) {\n\t\t\t\t\t// set savedRecently to true for 3s\n\t\t\t\t\tbaseStore.update((s) => ({\n\t\t\t\t\t\t...s,\n\t\t\t\t\t\trecentlySaved: true,\n\t\t\t\t\t}));\n\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\tbaseStore.update((s) => ({\n\t\t\t\t\t\t\t...s,\n\t\t\t\t\t\t\trecentlySaved: false,\n\t\t\t\t\t\t}));\n\t\t\t\t\t}, 3000);\n\t\t\t\t}\n\n\t\t\t\tshowSavedOnNextSync = false;\n\t\t\t}, 300);\n\t\t\t// debounce server calls by 300ms\n\t\t}\n\t}\n\n\tasync function initValue<K extends keyof SettingsStore>(\n\t\tkey: K,\n\t\tnestedKey: string,\n\t\tvalue: string | boolean\n\t) {\n\t\tconst currentStore = get(baseStore);\n\t\tconst currentNestedObject = currentStore[key] as Record<string, string | boolean>;\n\n\t\t// Only initialize if undefined\n\t\tif (currentNestedObject?.[nestedKey] !== undefined) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Update the store\n\t\tconst newNestedObject = {\n\t\t\t...(currentNestedObject || {}),\n\t\t\t[nestedKey]: value,\n\t\t};\n\n\t\tbaseStore.update((s) => ({\n\t\t\t...s,\n\t\t\t[key]: newNestedObject,\n\t\t}));\n\n\t\t// Save to server (debounced) - note: we don't set showSavedOnNextSync\n\t\tif (browser) {\n\t\t\tclearTimeout(timeoutId);\n\t\t\ttimeoutId = setTimeout(async () => {\n\t\t\t\tawait fetch(`${base}/settings`, {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\t},\n\t\t\t\t\tbody: JSON.stringify(get(baseStore)),\n\t\t\t\t});\n\n\t\t\t\tinvalidate(UrlDependency.ConversationList);\n\n\t\t\t\tif (showSavedOnNextSync) {\n\t\t\t\t\tbaseStore.update((s) => ({\n\t\t\t\t\t\t...s,\n\t\t\t\t\t\trecentlySaved: true,\n\t\t\t\t\t}));\n\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\tbaseStore.update((s) => ({\n\t\t\t\t\t\t\t...s,\n\t\t\t\t\t\t\trecentlySaved: false,\n\t\t\t\t\t\t}));\n\t\t\t\t\t}, 3000);\n\t\t\t\t}\n\n\t\t\t\tshowSavedOnNextSync = false;\n\t\t\t}, 300);\n\t\t}\n\t}\n\tasync function instantSet(settings: Partial<SettingsStore>) {\n\t\tbaseStore.update((s) => ({\n\t\t\t...s,\n\t\t\t...settings,\n\t\t}));\n\n\t\tif (browser) {\n\t\t\tawait fetch(`${base}/settings`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t...get(baseStore),\n\t\t\t\t\t...settings,\n\t\t\t\t}),\n\t\t\t});\n\t\t\tinvalidate(UrlDependency.ConversationList);\n\t\t}\n\t}\n\n\tconst newStore = {\n\t\tsubscribe: baseStore.subscribe,\n\t\tset: setSettings,\n\t\tinstantSet,\n\t\tinitValue,\n\t\tupdate: (fn: (s: SettingsStore) => SettingsStore) => {\n\t\t\tsetSettings(fn(get(baseStore)));\n\t\t},\n\t} satisfies SettingsStoreWritable;\n\n\tsetContext(\"settings\", newStore);\n\n\treturn newStore;\n}\n"
  },
  {
    "path": "src/lib/stores/shareModal.ts",
    "content": "import { writable } from \"svelte/store\";\n\nfunction createShareModalStore() {\n\tconst { subscribe, set } = writable(false);\n\n\treturn {\n\t\tsubscribe,\n\t\topen: () => set(true),\n\t\tclose: () => set(false),\n\t};\n}\n\nexport const shareModal = createShareModalStore();\n"
  },
  {
    "path": "src/lib/stores/titleUpdate.ts",
    "content": "import { writable } from \"svelte/store\";\n\nexport interface TitleUpdate {\n\tconvId: string;\n\ttitle: string;\n}\n\nexport default writable<TitleUpdate | null>(null);\n"
  },
  {
    "path": "src/lib/switchTheme.ts",
    "content": "export type ThemePreference = \"light\" | \"dark\" | \"system\";\n\ntype ThemeState = {\n\tpreference: ThemePreference;\n\tisDark: boolean;\n};\n\ntype ThemeSubscriber = (state: ThemeState) => void;\n\nlet currentPreference: ThemePreference = \"system\";\nconst subscribers = new Set<ThemeSubscriber>();\n\nfunction notify(preference: ThemePreference, isDark: boolean) {\n\tfor (const subscriber of subscribers) {\n\t\tsubscriber({ preference, isDark });\n\t}\n}\n\nexport function subscribeToTheme(subscriber: ThemeSubscriber) {\n\tsubscribers.add(subscriber);\n\n\tif (typeof document !== \"undefined\") {\n\t\tconst preference = getThemePreference();\n\t\tconst isDark = document.documentElement.classList.contains(\"dark\");\n\t\tsubscriber({ preference, isDark });\n\t} else {\n\t\tsubscriber({ preference: \"system\", isDark: false });\n\t}\n\n\treturn () => {\n\t\tsubscribers.delete(subscriber);\n\t};\n}\n\nfunction setMetaThemeColor(isDark: boolean) {\n\tconst metaTheme = document.querySelector('meta[name=\"theme-color\"]') as HTMLMetaElement | null;\n\tif (!metaTheme) return;\n\tmetaTheme.setAttribute(\"content\", isDark ? \"rgb(26, 36, 50)\" : \"rgb(249, 250, 251)\");\n}\n\nfunction applyDarkClass(isDark: boolean) {\n\tconst { classList } = document.querySelector(\"html\") as HTMLElement;\n\tif (isDark) classList.add(\"dark\");\n\telse classList.remove(\"dark\");\n\tsetMetaThemeColor(isDark);\n\tnotify(currentPreference, isDark);\n}\n\nexport function getThemePreference(): ThemePreference {\n\tconst raw = typeof localStorage !== \"undefined\" ? localStorage.getItem(\"theme\") : null;\n\tif (raw === \"light\" || raw === \"dark\" || raw === \"system\") {\n\t\tcurrentPreference = raw;\n\t\treturn raw;\n\t}\n\tcurrentPreference = \"system\";\n\treturn \"system\";\n}\n\n/**\n * Explicitly set the theme preference and apply it immediately.\n * - \"light\": force light\n * - \"dark\": force dark\n * - \"system\": follow the OS preference\n */\nexport function setTheme(preference: ThemePreference) {\n\ttry {\n\t\tlocalStorage.theme = preference;\n\t} catch (_err) {\n\t\tvoid 0; // ignore write errors\n\t}\n\n\tconst mql = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\tcurrentPreference = preference;\n\tconst resolve = () =>\n\t\tapplyDarkClass(preference === \"dark\" || (preference === \"system\" && mql.matches));\n\n\t// Apply now\n\tresolve();\n\n\t// If following system, listen for changes; otherwise remove listener\n\tconst listener = () => resolve();\n\t// Store on window to allow replacing listener later\n\tconst key = \"__theme_mql_listener\" as const;\n\tconst w = window as unknown as {\n\t\t[key: string]: ((this: MediaQueryList, ev: MediaQueryListEvent) => void) | undefined;\n\t};\n\tconst existing = w[key];\n\tif (existing) {\n\t\ttry {\n\t\t\tmql.removeEventListener(\"change\", existing);\n\t\t} catch (_err) {\n\t\t\t// older Safari compatibility\n\t\t\tconst legacy = (\n\t\t\t\tmql as unknown as {\n\t\t\t\t\tremoveListener?: (l: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;\n\t\t\t\t}\n\t\t\t).removeListener;\n\t\t\tlegacy?.(existing);\n\t\t}\n\t\tw[key] = undefined;\n\t}\n\tif (preference === \"system\") {\n\t\ttry {\n\t\t\tmql.addEventListener(\"change\", listener);\n\t\t} catch (_err) {\n\t\t\t// older Safari compatibility\n\t\t\tconst legacy = (\n\t\t\t\tmql as unknown as {\n\t\t\t\t\taddListener?: (l: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;\n\t\t\t\t}\n\t\t\t).addListener;\n\t\t\tlegacy?.(listener);\n\t\t}\n\t\tw[key] = listener;\n\t}\n}\n\n// Backward-compatible toggle used by the sidebar button\nexport function switchTheme() {\n\tconst html = document.querySelector(\"html\") as HTMLElement;\n\tconst isDark = html.classList.contains(\"dark\");\n\tconst next: ThemePreference = isDark ? \"light\" : \"dark\";\n\tsetTheme(next);\n}\n"
  },
  {
    "path": "src/lib/types/AbortedGeneration.ts",
    "content": "// Ideally shouldn't be needed, see https://github.com/huggingface/chat-ui/pull/88#issuecomment-1523173850\n\nimport type { Conversation } from \"./Conversation\";\nimport type { Timestamps } from \"./Timestamps\";\n\nexport interface AbortedGeneration extends Timestamps {\n\tconversationId: Conversation[\"_id\"];\n}\n"
  },
  {
    "path": "src/lib/types/Assistant.ts",
    "content": "import type { ObjectId } from \"mongodb\";\nimport type { User } from \"./User\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { ReviewStatus } from \"./Review\";\n\nexport interface Assistant extends Timestamps {\n\t_id: ObjectId;\n\tcreatedById: User[\"_id\"] | string; // user id or session\n\tcreatedByName?: User[\"username\"];\n\tavatar?: string;\n\tname: string;\n\tdescription?: string;\n\tmodelId: string;\n\texampleInputs: string[];\n\tpreprompt: string;\n\tuserCount?: number;\n\treview: ReviewStatus;\n\t// Web search / RAG removed in this build\n\tgenerateSettings?: {\n\t\ttemperature?: number;\n\t\ttop_p?: number;\n\t\tfrequency_penalty?: number;\n\t\ttop_k?: number;\n\t};\n\tdynamicPrompt?: boolean;\n\tsearchTokens: string[];\n\tlast24HoursCount: number;\n}\n\n// eslint-disable-next-line no-shadow\n// Removed duplicate unused SortKey enum (shared enum exists elsewhere)\n"
  },
  {
    "path": "src/lib/types/AssistantStats.ts",
    "content": "import type { Timestamps } from \"./Timestamps\";\nimport type { Assistant } from \"./Assistant\";\n\nexport interface AssistantStats extends Timestamps {\n\tassistantId: Assistant[\"_id\"];\n\tdate: {\n\t\tat: Date;\n\t\tspan: \"hour\";\n\t};\n\tcount: number;\n}\n"
  },
  {
    "path": "src/lib/types/ConfigKey.ts",
    "content": "export interface ConfigKey {\n\tkey: string; // unique\n\tvalue: string;\n}\n"
  },
  {
    "path": "src/lib/types/ConvSidebar.ts",
    "content": "import type { ObjectId } from \"bson\";\n\nexport interface ConvSidebar {\n\tid: ObjectId | string;\n\ttitle: string;\n\tupdatedAt: Date;\n\tmodel?: string;\n\tavatarUrl?: string | Promise<string | undefined>;\n}\n"
  },
  {
    "path": "src/lib/types/Conversation.ts",
    "content": "import type { ObjectId } from \"mongodb\";\nimport type { Message } from \"./Message\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { User } from \"./User\";\nimport type { Assistant } from \"./Assistant\";\n\nexport interface Conversation extends Timestamps {\n\t_id: ObjectId;\n\n\tsessionId?: string;\n\tuserId?: User[\"_id\"];\n\n\tmodel: string;\n\n\ttitle: string;\n\trootMessageId?: Message[\"id\"];\n\tmessages: Message[];\n\n\tmeta?: {\n\t\tfromShareId?: string;\n\t};\n\n\tpreprompt?: string;\n\tassistantId?: Assistant[\"_id\"];\n\n\tuserAgent?: string;\n}\n"
  },
  {
    "path": "src/lib/types/ConversationStats.ts",
    "content": "import type { Timestamps } from \"./Timestamps\";\n\nexport interface ConversationStats extends Timestamps {\n\tdate: {\n\t\tat: Date;\n\t\tspan: \"day\" | \"week\" | \"month\";\n\t\tfield: \"updatedAt\" | \"createdAt\";\n\t};\n\ttype: \"conversation\" | \"message\";\n\t/**  _id => number of conversations/messages in the month */\n\tdistinct: \"sessionId\" | \"userId\" | \"userOrSessionId\" | \"_id\";\n\tcount: number;\n}\n"
  },
  {
    "path": "src/lib/types/Message.ts",
    "content": "import type { InferenceProvider } from \"@huggingface/inference\";\nimport type { MessageUpdate } from \"./MessageUpdate\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { v4 } from \"uuid\";\n\nexport type Message = Partial<Timestamps> & {\n\tfrom: \"user\" | \"assistant\" | \"system\";\n\tid: ReturnType<typeof v4>;\n\tcontent: string;\n\tupdates?: MessageUpdate[];\n\n\t// Optional server or client-side reasoning content (<think> blocks)\n\treasoning?: string;\n\tscore?: -1 | 0 | 1;\n\t/**\n\t * Either contains the base64 encoded image data\n\t * or the hash of the file stored on the server\n\t **/\n\tfiles?: MessageFile[];\n\tinterrupted?: boolean;\n\n\t// Router metadata when using llm-router\n\trouterMetadata?: {\n\t\troute: string;\n\t\tmodel: string;\n\t\tprovider?: InferenceProvider;\n\t};\n\n\t// needed for conversation trees\n\tancestors?: Message[\"id\"][];\n\n\t// goes one level deep\n\tchildren?: Message[\"id\"][];\n};\n\nexport type MessageFile = {\n\ttype: \"hash\" | \"base64\";\n\tname: string;\n\tvalue: string;\n\tmime: string;\n};\n"
  },
  {
    "path": "src/lib/types/MessageEvent.ts",
    "content": "import type { Session } from \"./Session\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { User } from \"./User\";\n\nexport interface MessageEvent extends Pick<Timestamps, \"createdAt\"> {\n\tuserId: User[\"_id\"] | Session[\"sessionId\"];\n\tip?: string;\n\texpiresAt: Date;\n\ttype: \"message\" | \"export\";\n}\n"
  },
  {
    "path": "src/lib/types/MessageUpdate.ts",
    "content": "import type { InferenceProvider } from \"@huggingface/inference\";\nimport type { ToolCall, ToolResult } from \"$lib/types/Tool\";\n\nexport type MessageUpdate =\n\t| MessageStatusUpdate\n\t| MessageTitleUpdate\n\t| MessageToolUpdate\n\t| MessageStreamUpdate\n\t| MessageFileUpdate\n\t| MessageFinalAnswerUpdate\n\t| MessageReasoningUpdate\n\t| MessageRouterMetadataUpdate;\n\nexport enum MessageUpdateType {\n\tStatus = \"status\",\n\tTitle = \"title\",\n\tTool = \"tool\",\n\tStream = \"stream\",\n\tFile = \"file\",\n\tFinalAnswer = \"finalAnswer\",\n\tReasoning = \"reasoning\",\n\tRouterMetadata = \"routerMetadata\",\n}\n\n// Status\nexport enum MessageUpdateStatus {\n\tStarted = \"started\",\n\tError = \"error\",\n\tFinished = \"finished\",\n\tKeepAlive = \"keepAlive\",\n}\nexport interface MessageStatusUpdate {\n\ttype: MessageUpdateType.Status;\n\tstatus: MessageUpdateStatus;\n\tmessage?: string;\n\tstatusCode?: number;\n}\n\n// Everything else\nexport interface MessageTitleUpdate {\n\ttype: MessageUpdateType.Title;\n\ttitle: string;\n}\nexport interface MessageStreamUpdate {\n\ttype: MessageUpdateType.Stream;\n\ttoken: string;\n\t/** Length of the original token. Used for compressed/persisted stream markers where token is empty. */\n\tlen?: number;\n}\n\n// Tool updates (for MCP and function calling)\nexport enum MessageToolUpdateType {\n\tCall = \"call\",\n\tResult = \"result\",\n\tError = \"error\",\n\tETA = \"eta\",\n\tProgress = \"progress\",\n}\n\ninterface MessageToolUpdateBase<TSubtype extends MessageToolUpdateType> {\n\ttype: MessageUpdateType.Tool;\n\tsubtype: TSubtype;\n\tuuid: string;\n}\n\nexport interface MessageToolCallUpdate extends MessageToolUpdateBase<MessageToolUpdateType.Call> {\n\tcall: ToolCall;\n}\n\nexport interface MessageToolResultUpdate\n\textends MessageToolUpdateBase<MessageToolUpdateType.Result> {\n\tresult: ToolResult;\n}\n\nexport interface MessageToolErrorUpdate extends MessageToolUpdateBase<MessageToolUpdateType.Error> {\n\tmessage: string;\n}\n\nexport interface MessageToolEtaUpdate extends MessageToolUpdateBase<MessageToolUpdateType.ETA> {\n\teta: number;\n}\n\nexport interface MessageToolProgressUpdate\n\textends MessageToolUpdateBase<MessageToolUpdateType.Progress> {\n\tprogress: number;\n\ttotal?: number;\n\tmessage?: string;\n}\n\nexport type MessageToolUpdate =\n\t| MessageToolCallUpdate\n\t| MessageToolResultUpdate\n\t| MessageToolErrorUpdate\n\t| MessageToolEtaUpdate\n\t| MessageToolProgressUpdate;\n\nexport enum MessageReasoningUpdateType {\n\tStream = \"stream\",\n\tStatus = \"status\",\n}\n\nexport type MessageReasoningUpdate = MessageReasoningStreamUpdate | MessageReasoningStatusUpdate;\n\nexport interface MessageReasoningStreamUpdate {\n\ttype: MessageUpdateType.Reasoning;\n\tsubtype: MessageReasoningUpdateType.Stream;\n\ttoken: string;\n}\nexport interface MessageReasoningStatusUpdate {\n\ttype: MessageUpdateType.Reasoning;\n\tsubtype: MessageReasoningUpdateType.Status;\n\tstatus: string;\n}\n\nexport interface MessageFileUpdate {\n\ttype: MessageUpdateType.File;\n\tname: string;\n\tsha: string;\n\tmime: string;\n}\nexport interface MessageFinalAnswerUpdate {\n\ttype: MessageUpdateType.FinalAnswer;\n\ttext: string;\n\tinterrupted: boolean;\n}\nexport interface MessageRouterMetadataUpdate {\n\ttype: MessageUpdateType.RouterMetadata;\n\troute: string;\n\tmodel: string;\n\tprovider?: InferenceProvider;\n}\n"
  },
  {
    "path": "src/lib/types/MigrationResult.ts",
    "content": "import type { ObjectId } from \"mongodb\";\n\nexport interface MigrationResult {\n\t_id: ObjectId;\n\tname: string;\n\tstatus: \"success\" | \"failure\" | \"ongoing\";\n}\n"
  },
  {
    "path": "src/lib/types/Model.ts",
    "content": "import type { BackendModel } from \"$lib/server/models\";\n\nexport type Model = Pick<\n\tBackendModel,\n\t| \"id\"\n\t| \"name\"\n\t| \"displayName\"\n\t| \"isRouter\"\n\t| \"websiteUrl\"\n\t| \"datasetName\"\n\t| \"promptExamples\"\n\t| \"parameters\"\n\t| \"description\"\n\t| \"logoUrl\"\n\t| \"modelUrl\"\n\t| \"datasetUrl\"\n\t| \"preprompt\"\n\t| \"multimodal\"\n\t| \"multimodalAcceptedMimetypes\"\n\t| \"unlisted\"\n\t| \"hasInferenceAPI\"\n\t| \"providers\"\n>;\n"
  },
  {
    "path": "src/lib/types/Report.ts",
    "content": "import type { ObjectId } from \"mongodb\";\nimport type { User } from \"./User\";\nimport type { Assistant } from \"./Assistant\";\nimport type { Timestamps } from \"./Timestamps\";\n\nexport interface Report extends Timestamps {\n\t_id: ObjectId;\n\tcreatedBy: User[\"_id\"] | string;\n\tobject: \"assistant\" | \"tool\";\n\tcontentId: Assistant[\"_id\"];\n\treason?: string;\n}\n"
  },
  {
    "path": "src/lib/types/Review.ts",
    "content": "export enum ReviewStatus {\n\tPRIVATE = \"PRIVATE\",\n\tPENDING = \"PENDING\",\n\tAPPROVED = \"APPROVED\",\n\tDENIED = \"DENIED\",\n}\n"
  },
  {
    "path": "src/lib/types/Semaphore.ts",
    "content": "import type { Timestamps } from \"./Timestamps\";\n\nexport interface Semaphore extends Timestamps {\n\tkey: string;\n\tdeleteAt: Date;\n}\n\nexport enum Semaphores {\n\tCONVERSATION_STATS = \"conversation.stats\",\n\tCONFIG_UPDATE = \"config.update\",\n\tMIGRATION = \"migration\",\n\tTEST_MIGRATION = \"test.migration\",\n\t/**\n\t * Note this lock name is used as `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`\n\t *\n\t * not a global lock, but a lock for each session\n\t */\n\tOAUTH_TOKEN_REFRESH = \"oauth.token.refresh\",\n}\n"
  },
  {
    "path": "src/lib/types/Session.ts",
    "content": "import type { ObjectId } from \"bson\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { User } from \"./User\";\n\nexport interface Session extends Timestamps {\n\t_id: ObjectId;\n\tsessionId: string;\n\tuserId: User[\"_id\"];\n\tuserAgent?: string;\n\tip?: string;\n\texpiresAt: Date;\n\tadmin?: boolean;\n\tcoupledCookieHash?: string;\n\n\toauth?: {\n\t\ttoken: {\n\t\t\tvalue: string;\n\t\t\texpiresAt: Date;\n\t\t};\n\t\trefreshToken?: string;\n\t};\n}\n"
  },
  {
    "path": "src/lib/types/Settings.ts",
    "content": "import { defaultModel } from \"$lib/server/models\";\nimport type { Timestamps } from \"./Timestamps\";\nimport type { User } from \"./User\";\n\nexport type StreamingMode = \"raw\" | \"smooth\";\n\nexport interface Settings extends Timestamps {\n\tuserId?: User[\"_id\"];\n\tsessionId?: string;\n\n\tshareConversationsWithModelAuthors: boolean;\n\t/** One-time welcome modal acknowledgement */\n\twelcomeModalSeenAt?: Date | null;\n\tactiveModel: string;\n\n\t// model name and system prompts\n\tcustomPrompts?: Record<string, string>;\n\n\t/**\n\t * Per‑model overrides to enable multimodal (image) support\n\t * even when not advertised by the provider/model list.\n\t * Only the `true` value is meaningful (enables images).\n\t */\n\tmultimodalOverrides?: Record<string, boolean>;\n\n\t/**\n\t * Per‑model overrides to enable tool calling (OpenAI tools/function calling)\n\t * even when not advertised by the provider list. Only `true` is meaningful.\n\t */\n\ttoolsOverrides?: Record<string, boolean>;\n\n\t/**\n\t * Per-model toggle to hide Omni prompt suggestions shown near the composer.\n\t * When set to `true`, prompt examples for that model are suppressed.\n\t */\n\thidePromptExamples?: Record<string, boolean>;\n\n\t/**\n\t * Per-model inference provider preference.\n\t * Values: \"auto\" (default), \"fastest\", \"cheapest\", or a specific provider name (e.g., \"together\", \"sambanova\").\n\t * The value is appended to the model ID when making inference requests (e.g., \"model:fastest\").\n\t */\n\tproviderOverrides?: Record<string, string>;\n\n\t/**\n\t * Preferred assistant output behavior in the chat UI.\n\t * - \"raw\": show provider-native stream chunks\n\t * - \"smooth\": show smoothed stream chunks\n\t */\n\tstreamingMode: StreamingMode;\n\tdirectPaste: boolean;\n\n\t/**\n\t * Whether haptic feedback is enabled on supported touch devices.\n\t * Uses the ios-haptics library for cross-platform vibration.\n\t */\n\thapticsEnabled: boolean;\n\n\t/**\n\t * Organization to bill inference requests to (HuggingChat only).\n\t * Stores the org's preferred_username. If empty/undefined, bills to personal account.\n\t */\n\tbillingOrganization?: string;\n}\n\nexport type SettingsEditable = Omit<Settings, \"welcomeModalSeenAt\" | \"createdAt\" | \"updatedAt\">;\n// TODO: move this to a constant file along with other constants\nexport const DEFAULT_SETTINGS = {\n\tshareConversationsWithModelAuthors: true,\n\tactiveModel: defaultModel.id,\n\tcustomPrompts: {},\n\tmultimodalOverrides: {},\n\ttoolsOverrides: {},\n\thidePromptExamples: {},\n\tproviderOverrides: {},\n\tstreamingMode: \"smooth\",\n\tdirectPaste: false,\n\thapticsEnabled: true,\n} satisfies SettingsEditable;\n"
  },
  {
    "path": "src/lib/types/SharedConversation.ts",
    "content": "import type { Conversation } from \"./Conversation\";\n\nexport type SharedConversation = Pick<\n\tConversation,\n\t\"model\" | \"title\" | \"rootMessageId\" | \"messages\" | \"preprompt\" | \"createdAt\" | \"updatedAt\"\n> & {\n\t_id: string;\n\thash: string;\n};\n"
  },
  {
    "path": "src/lib/types/Template.ts",
    "content": "import type { Message } from \"./Message\";\n\nexport type ChatTemplateInput = {\n\tmessages: Pick<Message, \"from\" | \"content\" | \"files\">[];\n\tpreprompt?: string;\n};\n"
  },
  {
    "path": "src/lib/types/Timestamps.ts",
    "content": "export interface Timestamps {\n\tcreatedAt: Date;\n\tupdatedAt: Date;\n}\n"
  },
  {
    "path": "src/lib/types/TokenCache.ts",
    "content": "import type { Timestamps } from \"./Timestamps\";\n\nexport interface TokenCache extends Timestamps {\n\ttokenHash: string; // sha256 of the bearer token\n\tuserId: string; // the matching hf user id\n}\n"
  },
  {
    "path": "src/lib/types/Tool.ts",
    "content": "export enum ToolResultStatus {\n\tSuccess = \"success\",\n\tError = \"error\",\n}\n\nexport interface ToolCall {\n\tname: string;\n\tparameters: Record<string, string | number | boolean>;\n\ttoolId?: string;\n}\n\nexport interface ToolResultSuccess {\n\tstatus: ToolResultStatus.Success;\n\tcall: ToolCall;\n\toutputs: Record<string, unknown>[];\n\tdisplay?: boolean;\n}\n\nexport interface ToolResultError {\n\tstatus: ToolResultStatus.Error;\n\tcall: ToolCall;\n\tmessage: string;\n\tdisplay?: boolean;\n}\n\nexport type ToolResult = ToolResultSuccess | ToolResultError;\n\nexport interface ToolFront {\n\t_id: string;\n\tname: string;\n\tdisplayName?: string;\n\tdescription?: string;\n\tcolor?: string;\n\ticon?: string;\n\ttype?: \"config\" | \"community\";\n\tisOnByDefault?: boolean;\n\tisLocked?: boolean;\n\tmimeTypes?: string[];\n\ttimeToUseMS?: number;\n}\n\n// MCP Server types\nexport interface KeyValuePair {\n\tkey: string;\n\tvalue: string;\n}\n\nexport type ServerStatus = \"connected\" | \"connecting\" | \"disconnected\" | \"error\";\n\nexport interface MCPTool {\n\tname: string;\n\tdescription?: string;\n\tinputSchema?: unknown;\n}\n\nexport interface MCPServer {\n\tid: string;\n\tname: string;\n\turl: string;\n\ttype: \"base\" | \"custom\";\n\theaders?: KeyValuePair[];\n\tenv?: KeyValuePair[];\n\tstatus?: ServerStatus;\n\tisLocked?: boolean;\n\ttools?: MCPTool[];\n\terrorMessage?: string;\n\t// Indicates server reports or appears to require OAuth or other auth\n\tauthRequired?: boolean;\n}\n\nexport interface MCPServerApi {\n\turl: string;\n\theaders?: KeyValuePair[];\n}\n"
  },
  {
    "path": "src/lib/types/UrlDependency.ts",
    "content": "/* eslint-disable no-shadow */\nexport enum UrlDependency {\n\tConversationList = \"conversation:list\",\n\tConversation = \"conversation:id\",\n}\n"
  },
  {
    "path": "src/lib/types/User.ts",
    "content": "import type { ObjectId } from \"mongodb\";\nimport type { Timestamps } from \"./Timestamps\";\n\nexport interface User extends Timestamps {\n\t_id: ObjectId;\n\n\tusername?: string;\n\tname: string;\n\temail?: string;\n\tavatarUrl: string | undefined;\n\thfUserId: string;\n\tisAdmin?: boolean;\n\tisEarlyAccess?: boolean;\n}\n"
  },
  {
    "path": "src/lib/utils/PublicConfig.svelte.ts",
    "content": "import type { env as publicEnv } from \"$env/dynamic/public\";\nimport { page } from \"$app/state\";\nimport { base } from \"$app/paths\";\n\nimport type { Transporter } from \"@sveltejs/kit\";\nimport { getContext } from \"svelte\";\n\ntype PublicConfigKey = keyof typeof publicEnv;\n\nclass PublicConfigManager {\n\t#configStore = $state<Record<PublicConfigKey, string>>({});\n\n\tconstructor(initialConfig?: Record<PublicConfigKey, string>) {\n\t\tthis.init = this.init.bind(this);\n\t\tthis.getPublicConfig = this.getPublicConfig.bind(this);\n\t\tif (initialConfig) {\n\t\t\tthis.init(initialConfig);\n\t\t}\n\t}\n\n\tinit(publicConfig: Record<PublicConfigKey, string>) {\n\t\tthis.#configStore = publicConfig;\n\t}\n\n\tget(key: PublicConfigKey) {\n\t\treturn this.#configStore[key];\n\t}\n\n\tgetPublicConfig() {\n\t\treturn this.#configStore;\n\t}\n\n\tget isHuggingChat() {\n\t\treturn this.#configStore.PUBLIC_APP_ASSETS === \"huggingchat\";\n\t}\n\n\tget assetPath() {\n\t\treturn (\n\t\t\t(this.#configStore.PUBLIC_ORIGIN || page.url.origin) +\n\t\t\tbase +\n\t\t\t\"/\" +\n\t\t\t(this.#configStore.PUBLIC_APP_ASSETS || \"chatui\")\n\t\t);\n\t}\n}\ntype ConfigProxy = PublicConfigManager & { [K in PublicConfigKey]: string };\n\nexport function getConfigManager(initialConfig?: Record<PublicConfigKey, string>) {\n\tconst publicConfigManager = new PublicConfigManager(initialConfig);\n\n\tconst publicConfig: ConfigProxy = new Proxy(publicConfigManager, {\n\t\tget(target, prop) {\n\t\t\tif (prop in target) {\n\t\t\t\treturn Reflect.get(target, prop);\n\t\t\t}\n\t\t\tif (typeof prop === \"string\") {\n\t\t\t\treturn target.get(prop as PublicConfigKey);\n\t\t\t}\n\t\t\treturn undefined;\n\t\t},\n\t\tset(target, prop, value, receiver) {\n\t\t\tif (prop in target) {\n\t\t\t\treturn Reflect.set(target, prop, value, receiver);\n\t\t\t}\n\t\t\treturn false;\n\t\t},\n\t}) as ConfigProxy;\n\treturn publicConfig;\n}\n\nexport const publicConfigTransporter: Transporter = {\n\tencode: (value) =>\n\t\tvalue instanceof PublicConfigManager ? JSON.stringify(value.getPublicConfig()) : false,\n\tdecode: (value) => getConfigManager(JSON.parse(value)),\n};\n\nexport const usePublicConfig = () => getContext<ConfigProxy>(\"publicConfig\");\n"
  },
  {
    "path": "src/lib/utils/auth.ts",
    "content": "import { goto } from \"$app/navigation\";\nimport { base } from \"$app/paths\";\nimport { page } from \"$app/state\";\n\n/**\n * Redirects to the login page if the user is not authenticated\n * and the login feature is enabled.\n */\nexport function requireAuthUser(): boolean {\n\tif (page.data.loginEnabled && !page.data.user) {\n\t\tconst next = page.url.pathname + page.url.search;\n\t\tconst url = `${base}/login?next=${encodeURIComponent(next)}`;\n\t\tgoto(url, { invalidateAll: true });\n\t\treturn true;\n\t}\n\treturn false;\n}\n"
  },
  {
    "path": "src/lib/utils/chunk.ts",
    "content": "/**\n * Chunk array into arrays of length at most `chunkSize`\n *\n * @param chunkSize must be greater than or equal to 1\n */\nexport function chunk<T extends unknown[] | string>(arr: T, chunkSize: number): T[] {\n\tif (isNaN(chunkSize) || chunkSize < 1) {\n\t\tthrow new RangeError(\"Invalid chunk size: \" + chunkSize);\n\t}\n\n\tif (!arr.length) {\n\t\treturn [];\n\t}\n\n\t/// Small optimization to not chunk buffers unless needed\n\tif (arr.length <= chunkSize) {\n\t\treturn [arr];\n\t}\n\n\treturn range(Math.ceil(arr.length / chunkSize)).map((i) => {\n\t\treturn arr.slice(i * chunkSize, (i + 1) * chunkSize);\n\t}) as T[];\n}\n\nfunction range(n: number, b?: number): number[] {\n\treturn b\n\t\t? Array(b - n)\n\t\t\t\t.fill(0)\n\t\t\t\t.map((_, i) => n + i)\n\t\t: Array(n)\n\t\t\t\t.fill(0)\n\t\t\t\t.map((_, i) => i);\n}\n"
  },
  {
    "path": "src/lib/utils/cookiesAreEnabled.ts",
    "content": "import { browser } from \"$app/environment\";\n\nexport function cookiesAreEnabled(): boolean {\n\tif (!browser) return false;\n\tif (navigator.cookieEnabled) return navigator.cookieEnabled;\n\n\t// Create cookie\n\tdocument.cookie = \"cookietest=1\";\n\tconst ret = document.cookie.indexOf(\"cookietest=\") != -1;\n\t// Delete cookie\n\tdocument.cookie = \"cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT\";\n\treturn ret;\n}\n"
  },
  {
    "path": "src/lib/utils/debounce.ts",
    "content": "/**\n * A debounce function that works in both browser and Nodejs.\n * For pure Nodejs work, prefer the `Debouncer` class.\n */\nexport function debounce<T extends unknown[]>(\n\tcallback: (...rest: T) => unknown,\n\tlimit: number\n): (...rest: T) => void {\n\tlet timer: ReturnType<typeof setTimeout>;\n\n\treturn function (...rest) {\n\t\tclearTimeout(timer);\n\t\ttimer = setTimeout(() => {\n\t\t\tcallback(...rest);\n\t\t}, limit);\n\t};\n}\n"
  },
  {
    "path": "src/lib/utils/deepestChild.ts",
    "content": "export function deepestChild(el: HTMLElement): HTMLElement {\n\tif (el.lastElementChild && el.lastElementChild.nodeType !== Node.TEXT_NODE) {\n\t\treturn deepestChild(el.lastElementChild as HTMLElement);\n\t}\n\treturn el;\n}\n"
  },
  {
    "path": "src/lib/utils/favicon.ts",
    "content": "/**\n * Generates a Google favicon URL for the given server URL\n * @param serverUrl - The MCP server URL (e.g., \"https://mcp.exa.ai/mcp\")\n * @param size - The size of the favicon in pixels (default: 64)\n * @returns The Google favicon service URL\n */\nexport function getMcpServerFaviconUrl(serverUrl: string, size: number = 64): string {\n\ttry {\n\t\tconst parsed = new URL(serverUrl);\n\t\t// Extract root domain (e.g., \"exa.ai\" from \"mcp.exa.ai\")\n\t\t// Google's favicon service needs the root domain, not subdomains\n\t\tconst hostnameParts = parsed.hostname.split(\".\");\n\t\tconst rootDomain =\n\t\t\thostnameParts.length >= 2 ? hostnameParts.slice(-2).join(\".\") : parsed.hostname;\n\t\tconst domain = `${parsed.protocol}//${rootDomain}`;\n\t\treturn `https://www.google.com/s2/favicons?sz=${size}&domain_url=${encodeURIComponent(domain)}`;\n\t} catch {\n\t\t// If URL parsing fails, just use the raw serverUrl - Google will handle it\n\t\treturn `https://www.google.com/s2/favicons?sz=${size}&domain_url=${encodeURIComponent(serverUrl)}`;\n\t}\n}\n"
  },
  {
    "path": "src/lib/utils/fetchJSON.ts",
    "content": "export async function fetchJSON<T>(\n\turl: string,\n\toptions?: {\n\t\tfetch?: typeof window.fetch;\n\t\tallowNull?: boolean;\n\t}\n): Promise<T> {\n\tconst response = await (options?.fetch ?? fetch)(url);\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);\n\t}\n\n\t// Handle empty responses (which parse to null)\n\tconst text = await response.text();\n\tif (!text || text.trim() === \"\") {\n\t\tif (options?.allowNull) {\n\t\t\treturn null as T;\n\t\t}\n\t\tthrow new Error(`Received empty response from ${url} but allowNull is not set to true`);\n\t}\n\n\treturn JSON.parse(text);\n}\n"
  },
  {
    "path": "src/lib/utils/file2base64.ts",
    "content": "const file2base64 = (file: File): Promise<string> => {\n\treturn new Promise<string>((resolve, reject) => {\n\t\tconst reader = new FileReader();\n\t\treader.readAsDataURL(file);\n\t\treader.onload = () => {\n\t\t\tconst dataUrl = reader.result as string;\n\t\t\tconst base64 = dataUrl.split(\",\")[1];\n\t\t\tresolve(base64);\n\t\t};\n\t\treader.onerror = (error) => reject(error);\n\t});\n};\n\nexport default file2base64;\n"
  },
  {
    "path": "src/lib/utils/formatUserCount.ts",
    "content": "export function formatUserCount(userCount: number): string {\n\tconst userCountRanges: { min: number; max: number; label: string }[] = [\n\t\t{ min: 0, max: 1, label: \"1\" },\n\t\t{ min: 2, max: 9, label: \"1-10\" },\n\t\t{ min: 10, max: 49, label: \"10+\" },\n\t\t{ min: 50, max: 99, label: \"50+\" },\n\t\t{ min: 100, max: 299, label: \"100+\" },\n\t\t{ min: 300, max: 499, label: \"300+\" },\n\t\t{ min: 500, max: 999, label: \"500+\" },\n\t\t{ min: 1_000, max: 2_999, label: \"1k+\" },\n\t\t{ min: 3_000, max: 4_999, label: \"3k+\" },\n\t\t{ min: 5_000, max: 9_999, label: \"5k+\" },\n\t\t{ min: 10_000, max: 19_999, label: \"10k+\" },\n\t\t{ min: 20_000, max: 29_999, label: \"20k+\" },\n\t\t{ min: 30_000, max: 39_999, label: \"30k+\" },\n\t\t{ min: 40_000, max: 49_999, label: \"40k+\" },\n\t\t{ min: 50_000, max: 59_999, label: \"50k+\" },\n\t\t{ min: 60_000, max: 69_999, label: \"60k+\" },\n\t\t{ min: 70_000, max: 79_999, label: \"70k+\" },\n\t\t{ min: 80_000, max: 89_999, label: \"80k+\" },\n\t\t{ min: 90_000, max: 99_999, label: \"90k+\" },\n\t\t{ min: 100_000, max: 109_999, label: \"100k+\" },\n\t\t{ min: 110_000, max: 119_999, label: \"110k+\" },\n\t\t{ min: 120_000, max: 129_999, label: \"120k+\" },\n\t\t{ min: 130_000, max: 139_999, label: \"130k+\" },\n\t\t{ min: 140_000, max: 149_999, label: \"140k+\" },\n\t\t{ min: 150_000, max: 199_999, label: \"150k+\" },\n\t\t{ min: 200_000, max: 299_999, label: \"200k+\" },\n\t\t{ min: 300_000, max: 499_999, label: \"300k+\" },\n\t\t{ min: 500_000, max: 749_999, label: \"500k+\" },\n\t\t{ min: 750_000, max: 999_999, label: \"750k+\" },\n\t\t{ min: 1_000_000, max: Infinity, label: \"1M+\" },\n\t];\n\n\tconst range = userCountRanges.find(({ min, max }) => userCount >= min && userCount <= max);\n\treturn range?.label ?? \"\";\n}\n"
  },
  {
    "path": "src/lib/utils/generationState.spec.ts",
    "content": "import { describe, expect, test } from \"vitest\";\n\nimport type { Message } from \"$lib/types/Message\";\nimport { MessageUpdateStatus, MessageUpdateType } from \"$lib/types/MessageUpdate\";\nimport { isAssistantGenerationTerminal, isConversationGenerationActive } from \"./generationState\";\n\nfunction assistantMessage(overrides: Partial<Message> = {}): Message {\n\treturn {\n\t\tfrom: \"assistant\",\n\t\tid: \"assistant-1\" as Message[\"id\"],\n\t\tcontent: \"\",\n\t\tchildren: [],\n\t\t...overrides,\n\t};\n}\n\ndescribe(\"generationState\", () => {\n\ttest(\"returns active when assistant has no terminal update\", () => {\n\t\tconst messages = [\n\t\t\tassistantMessage({\n\t\t\t\tupdates: [{ type: MessageUpdateType.Stream, token: \"Hello\" }],\n\t\t\t}),\n\t\t];\n\n\t\texpect(isConversationGenerationActive(messages)).toBe(true);\n\t});\n\n\ttest(\"treats final answer update as terminal\", () => {\n\t\tconst message = assistantMessage({\n\t\t\tupdates: [{ type: MessageUpdateType.FinalAnswer, text: \"Done\", interrupted: false }],\n\t\t});\n\n\t\texpect(isAssistantGenerationTerminal(message)).toBe(true);\n\t\texpect(isConversationGenerationActive([message])).toBe(false);\n\t});\n\n\ttest(\"treats error status update as terminal\", () => {\n\t\tconst message = assistantMessage({\n\t\t\tupdates: [\n\t\t\t\t{\n\t\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\t\tstatus: MessageUpdateStatus.Error,\n\t\t\t\t\tmessage: \"Something went wrong\",\n\t\t\t\t},\n\t\t\t],\n\t\t});\n\n\t\texpect(isAssistantGenerationTerminal(message)).toBe(true);\n\t\texpect(isConversationGenerationActive([message])).toBe(false);\n\t});\n\n\ttest(\"treats finished status update as terminal\", () => {\n\t\tconst message = assistantMessage({\n\t\t\tupdates: [\n\t\t\t\t{\n\t\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\t\tstatus: MessageUpdateStatus.Finished,\n\t\t\t\t},\n\t\t\t],\n\t\t});\n\n\t\texpect(isAssistantGenerationTerminal(message)).toBe(true);\n\t\texpect(isConversationGenerationActive([message])).toBe(false);\n\t});\n\n\ttest(\"treats interrupted assistant message as terminal\", () => {\n\t\tconst message = assistantMessage({\n\t\t\tinterrupted: true,\n\t\t\tupdates: [{ type: MessageUpdateType.Stream, token: \"partial\" }],\n\t\t});\n\n\t\texpect(isAssistantGenerationTerminal(message)).toBe(true);\n\t\texpect(isConversationGenerationActive([message])).toBe(false);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/generationState.ts",
    "content": "import type { Message } from \"$lib/types/Message\";\nimport { MessageUpdateStatus, MessageUpdateType } from \"$lib/types/MessageUpdate\";\n\nexport function isAssistantGenerationTerminal(message?: Message): boolean {\n\tif (!message || message.from !== \"assistant\") return true;\n\n\tif (message.interrupted === true) return true;\n\n\tconst updates = message.updates ?? [];\n\tconst hasFinalAnswer = updates.some((update) => update.type === MessageUpdateType.FinalAnswer);\n\tif (hasFinalAnswer) return true;\n\n\treturn updates.some(\n\t\t(update) =>\n\t\t\tupdate.type === MessageUpdateType.Status &&\n\t\t\t(update.status === MessageUpdateStatus.Error ||\n\t\t\t\tupdate.status === MessageUpdateStatus.Finished)\n\t);\n}\n\nexport function isConversationGenerationActive(messages: Message[]): boolean {\n\tconst lastAssistant = [...messages].reverse().find((message) => message.from === \"assistant\");\n\tif (!lastAssistant) return false;\n\n\treturn !isAssistantGenerationTerminal(lastAssistant);\n}\n"
  },
  {
    "path": "src/lib/utils/getHref.ts",
    "content": "export function getHref(\n\turl: URL | string,\n\tmodifications: {\n\t\tnewKeys?: Record<string, string | undefined | null>;\n\t\texistingKeys?: { behaviour: \"delete_except\" | \"delete\"; keys: string[] };\n\t}\n) {\n\tconst newUrl = new URL(url);\n\tconst { newKeys, existingKeys } = modifications;\n\n\t// exsiting keys logic\n\tif (existingKeys) {\n\t\tconst { behaviour, keys } = existingKeys;\n\t\tif (behaviour === \"delete\") {\n\t\t\tfor (const key of keys) {\n\t\t\t\tnewUrl.searchParams.delete(key);\n\t\t\t}\n\t\t} else {\n\t\t\t// delete_except\n\t\t\tconst keysToPreserve = keys;\n\t\t\tfor (const key of [...newUrl.searchParams.keys()]) {\n\t\t\t\tif (!keysToPreserve.includes(key)) {\n\t\t\t\t\tnewUrl.searchParams.delete(key);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// new keys logic\n\tif (newKeys) {\n\t\tfor (const [key, val] of Object.entries(newKeys)) {\n\t\t\tif (val) {\n\t\t\t\tnewUrl.searchParams.set(key, val);\n\t\t\t} else {\n\t\t\t\tnewUrl.searchParams.delete(key);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newUrl.toString();\n}\n"
  },
  {
    "path": "src/lib/utils/getReturnFromGenerator.ts",
    "content": "export async function getReturnFromGenerator<T, R>(generator: AsyncGenerator<T, R>): Promise<R> {\n\tlet result: IteratorResult<T, R>;\n\tdo {\n\t\tresult = await generator.next();\n\t} while (!result.done); // Keep calling `next()` until `done` is true\n\treturn result.value; // Return the final value\n}\n"
  },
  {
    "path": "src/lib/utils/haptics.ts",
    "content": "import { browser } from \"$app/environment\";\nimport type { WebHaptics } from \"web-haptics\";\n\nlet instance: WebHaptics | null = null;\nlet enabled = true;\n\n/**\n * Lazily initializes the WebHaptics instance on first use.\n * Avoids importing at module level so SSR doesn't break.\n */\nasync function getInstance(): Promise<WebHaptics | null> {\n\tif (!browser || !supportsHaptics()) return null;\n\tif (instance) return instance;\n\n\ttry {\n\t\tconst { WebHaptics: WH } = await import(\"web-haptics\");\n\t\tinstance = new WH();\n\t\treturn instance;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/** Call from the settings store to keep haptics in sync with user preference. */\nexport function setHapticsEnabled(value: boolean) {\n\tenabled = value;\n}\n\n/** Whether the device likely supports haptic feedback (touch screen present). */\nexport function supportsHaptics(): boolean {\n\treturn browser && navigator.maxTouchPoints > 0;\n}\n\n// ── Internals ────────────────────────────────────────────────────────\n\n/** Fire a haptic pattern, swallowing errors so callers can safely fire-and-forget. */\nfunction fire(pattern: string): void {\n\tif (!enabled) return;\n\tPromise.resolve(getInstance())\n\t\t.then((h) => h?.trigger(pattern))\n\t\t.catch(() => {});\n}\n\n// ── Semantic haptic actions ──────────────────────────────────────────\n\n/** Light tap — for routine actions (send message, toggle, navigate). */\nexport function tap() {\n\tfire(\"light\");\n}\n\n/** Success confirmation — double-tap pattern (copy, share, save). */\nexport function confirm() {\n\tfire(\"success\");\n}\n\n/** Error / destructive warning — three rapid taps (delete, stop generation). */\nexport function error() {\n\tfire(\"error\");\n}\n\n/** Selection change — subtle tap for pickers and selections. */\nexport function selection() {\n\tfire(\"selection\");\n}\n\n/** Stream start burst — multiple short vibrations for a \"machine starting up\" feel. */\nexport function streamStart(): void {\n\tif (!enabled || !browser) return;\n\tif (typeof navigator.vibrate !== \"function\") return;\n\t// Three quick pulses: two short taps + a slightly longer finish\n\tnavigator.vibrate([50, 30, 50, 30, 80]);\n}\n"
  },
  {
    "path": "src/lib/utils/hashConv.ts",
    "content": "import type { Conversation } from \"$lib/types/Conversation\";\nimport { sha256 } from \"./sha256\";\n\nexport async function hashConv(conv: Conversation) {\n\t// messages contains the conversation message but only the immutable part\n\tconst messages = conv.messages.map((message) => {\n\t\treturn (({ from, id, content }) => ({ from, id, content }))(message);\n\t});\n\n\tconst hash = await sha256(JSON.stringify(messages));\n\treturn hash;\n}\n"
  },
  {
    "path": "src/lib/utils/hf.ts",
    "content": "// Client-safe HF utilities used in UI components\n\nexport function isStrictHfMcpLogin(urlString: string): boolean {\n\ttry {\n\t\tconst u = new URL(urlString);\n\t\tconst host = u.hostname.toLowerCase();\n\t\tconst allowedHosts = new Set([\"hf.co\", \"huggingface.co\"]);\n\t\treturn (\n\t\t\tu.protocol === \"https:\" &&\n\t\t\tallowedHosts.has(host) &&\n\t\t\tu.pathname === \"/mcp\" &&\n\t\t\tu.search === \"?login\"\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "src/lib/utils/isDesktop.ts",
    "content": "// Approximate width from which we disable autofocus\nconst TABLET_VIEWPORT_WIDTH = 768;\n\nexport function isDesktop(window: Window) {\n\tconst { innerWidth } = window;\n\treturn innerWidth > TABLET_VIEWPORT_WIDTH;\n}\n"
  },
  {
    "path": "src/lib/utils/isUrl.ts",
    "content": "export function isURL(url: string) {\n\ttry {\n\t\tnew URL(url);\n\t\treturn true;\n\t} catch (e) {\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "src/lib/utils/isVirtualKeyboard.ts",
    "content": "import { browser } from \"$app/environment\";\n\nexport function isVirtualKeyboard(): boolean {\n\tif (!browser) return false;\n\n\t// Check for touch capability\n\tif (navigator.maxTouchPoints > 0 && screen.width <= 768) return true;\n\n\t// Check for touch events\n\tif (\"ontouchstart\" in window) return true;\n\n\t// Fallback to user agent string check\n\tconst userAgent = navigator.userAgent.toLowerCase();\n\n\treturn /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);\n}\n"
  },
  {
    "path": "src/lib/utils/loadAttachmentsFromUrls.ts",
    "content": "import { base } from \"$app/paths\";\nimport { pickSafeMime } from \"$lib/utils/mime\";\n\nexport interface AttachmentLoadResult {\n\tfiles: File[];\n\terrors: string[];\n}\n\n/**\n * Parse attachment URLs from query parameters\n * Supports both comma-separated (?attachments=url1,url2) and multiple params (?attachments=url1&attachments=url2)\n */\nfunction parseAttachmentUrls(searchParams: URLSearchParams): string[] {\n\tconst urls: string[] = [];\n\n\t// Get all 'attachments' parameters\n\tconst attachmentParams = searchParams.getAll(\"attachments\");\n\n\tfor (const param of attachmentParams) {\n\t\t// Split by comma in case multiple URLs are in one param\n\t\tconst splitUrls = param.split(\",\").map((url) => url.trim());\n\t\turls.push(...splitUrls);\n\t}\n\n\t// Filter out empty strings\n\treturn urls.filter((url) => url.length > 0);\n}\n\n/**\n * Extract filename from URL or Content-Disposition header\n */\nfunction extractFilename(url: string, contentDisposition?: string | null): string {\n\t// Try to get filename from Content-Disposition header\n\tif (contentDisposition) {\n\t\tconst filenameStar = contentDisposition.match(/filename\\*=UTF-8''([^;]+)/i)?.[1];\n\t\tif (filenameStar) {\n\t\t\tconst cleaned = filenameStar.trim().replace(/['\"]/g, \"\");\n\t\t\ttry {\n\t\t\t\treturn decodeURIComponent(cleaned);\n\t\t\t} catch {\n\t\t\t\treturn cleaned;\n\t\t\t}\n\t\t}\n\n\t\tconst match = contentDisposition.match(/filename[^;=\\n]*=((['\"]).*?\\2|[^;\\n]*)/);\n\t\tif (match && match[1]) return match[1].replace(/['\"]/g, \"\");\n\t}\n\n\t// Fallback: extract from URL\n\ttry {\n\t\tconst urlObj = new URL(url);\n\t\tconst pathname = urlObj.pathname;\n\t\tconst segments = pathname.split(\"/\");\n\t\tconst lastSegment = segments[segments.length - 1];\n\n\t\tif (lastSegment && lastSegment.length > 0) {\n\t\t\treturn decodeURIComponent(lastSegment);\n\t\t}\n\t} catch {\n\t\t// Invalid URL, fall through to default\n\t}\n\n\treturn \"attachment\";\n}\n\n/**\n * Load files from remote URLs via server-side proxy\n */\nexport async function loadAttachmentsFromUrls(\n\tsearchParams: URLSearchParams\n): Promise<AttachmentLoadResult> {\n\tconst urls = parseAttachmentUrls(searchParams);\n\n\tif (urls.length === 0) {\n\t\treturn { files: [], errors: [] };\n\t}\n\n\tconst files: File[] = [];\n\tconst errors: string[] = [];\n\n\tawait Promise.all(\n\t\turls.map(async (url) => {\n\t\t\ttry {\n\t\t\t\t// Fetch via our proxy endpoint to bypass CORS\n\t\t\t\tconst proxyUrl = `${base}/api/fetch-url?${new URLSearchParams({ url })}`;\n\t\t\t\tconst response = await fetch(proxyUrl);\n\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tconst errorText = await response.text();\n\t\t\t\t\terrors.push(`Failed to fetch ${url}: ${errorText}`);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst forwardedType = response.headers.get(\"x-forwarded-content-type\");\n\t\t\t\tconst blob = await response.blob();\n\t\t\t\tconst mimeType = pickSafeMime(forwardedType, blob.type, url);\n\t\t\t\tconst contentDisposition = response.headers.get(\"content-disposition\");\n\t\t\t\tconst filename = extractFilename(url, contentDisposition);\n\n\t\t\t\t// Create File object\n\t\t\t\tconst file = new File([blob], filename, {\n\t\t\t\t\ttype: mimeType,\n\t\t\t\t});\n\n\t\t\t\tfiles.push(file);\n\t\t\t} catch (err) {\n\t\t\t\tconst message = err instanceof Error ? err.message : \"Unknown error\";\n\t\t\t\terrors.push(`Failed to load ${url}: ${message}`);\n\t\t\t\tconsole.error(`Error loading attachment from ${url}:`, err);\n\t\t\t}\n\t\t})\n\t);\n\n\treturn { files, errors };\n}\n"
  },
  {
    "path": "src/lib/utils/marked.spec.ts",
    "content": "import { describe, expect, test } from \"vitest\";\nimport { processTokensSync } from \"./marked\";\n\nfunction renderHtml(md: string): string {\n\tconst tokens = processTokensSync(md, []);\n\tconst textToken = tokens.find((token) => token.type === \"text\");\n\tif (!textToken || textToken.type !== \"text\") return \"\";\n\treturn typeof textToken.html === \"string\" ? textToken.html : \"\";\n}\n\ndescribe(\"marked basic rendering\", () => {\n\ttest(\"renders bold text\", () => {\n\t\tconst html = renderHtml(\"**bold**\");\n\t\texpect(html).toContain(\"<strong>bold</strong>\");\n\t});\n\n\ttest(\"renders links\", () => {\n\t\tconst html = renderHtml(\"[link](https://example.com)\");\n\t\texpect(html).toContain('<a href=\"https://example.com\"');\n\t\texpect(html).toContain(\"link</a>\");\n\t});\n\n\ttest(\"renders paragraphs\", () => {\n\t\tconst html = renderHtml(\"hello world\");\n\t\texpect(html).toContain(\"<p>hello world</p>\");\n\t});\n});\n\ndescribe(\"marked image renderer\", () => {\n\ttest(\"renders video extensions as <video>\", () => {\n\t\tconst html = renderHtml(\"![](https://example.com/clip.mp4)\");\n\t\texpect(html).toContain(\"<video controls\");\n\t\texpect(html).toContain('<source src=\"https://example.com/clip.mp4\">');\n\t});\n\n\ttest(\"renders audio extensions as <audio>\", () => {\n\t\tconst html = renderHtml(\"![](https://example.com/clip.mp3)\");\n\t\texpect(html).toContain(\"<audio controls\");\n\t\texpect(html).toContain('<source src=\"https://example.com/clip.mp3\">');\n\t});\n\n\ttest(\"renders non-video images as <img>\", () => {\n\t\tconst html = renderHtml(\"![](https://example.com/pic.png)\");\n\t\texpect(html).toContain('<img src=\"https://example.com/pic.png\"');\n\t});\n\n\ttest(\"renders video with query params\", () => {\n\t\tconst html = renderHtml(\"![](https://example.com/clip.mp4?token=abc)\");\n\t\texpect(html).toContain(\"<video controls\");\n\t\texpect(html).toContain(\"clip.mp4?token=abc\");\n\t});\n});\n\ndescribe(\"marked html video tag support\", () => {\n\ttest(\"allows raw <video> tags with controls\", () => {\n\t\tconst html = renderHtml('<video controls src=\"https://example.com/video.mp4\"></video>');\n\t\texpect(html).toContain(\"<video\");\n\t\texpect(html).toContain(\"controls\");\n\t\texpect(html).toContain('src=\"https://example.com/video.mp4\"');\n\t});\n\n\ttest(\"allows <video> with nested <source> tags\", () => {\n\t\tconst html = renderHtml(\n\t\t\t'<video controls><source src=\"https://example.com/video.webm\" type=\"video/webm\"></video>'\n\t\t);\n\t\texpect(html).toContain(\"<video\");\n\t\texpect(html).toContain(\"<source\");\n\t\texpect(html).toContain('src=\"https://example.com/video.webm\"');\n\t});\n\n\ttest(\"strips disallowed attributes from video tags\", () => {\n\t\tconst html = renderHtml('<video onclick=\"alert(1)\" src=\"https://example.com/v.mp4\"></video>');\n\t\texpect(html).toContain(\"<video\");\n\t\texpect(html).not.toContain(\"onclick\");\n\t});\n\n\ttest(\"strips javascript: URLs from media sources\", () => {\n\t\tconst html = renderHtml('<video controls src=\"javascript:alert(1)\"></video>');\n\t\texpect(html).not.toContain(\"javascript:\");\n\t});\n\n\ttest(\"escapes disallowed html tags\", () => {\n\t\tconst html = renderHtml(\"<script>alert(1)</script>\");\n\t\texpect(html).not.toContain(\"<script>\");\n\t\texpect(html).toContain(\"&lt;script&gt;\");\n\t});\n\n\ttest(\"allows <audio> tags with controls\", () => {\n\t\tconst html = renderHtml(\n\t\t\t'<audio controls><source src=\"https://example.com/audio.mp3\" type=\"audio/mpeg\"></audio>'\n\t\t);\n\t\texpect(html).toContain(\"<audio\");\n\t\texpect(html).toContain(\"<source\");\n\t\texpect(html).toContain('type=\"audio/mpeg\"');\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/marked.ts",
    "content": "import katex from \"katex\";\nimport \"katex/dist/contrib/mhchem.mjs\";\nimport { Marked } from \"marked\";\nimport type { Tokens, TokenizerExtension, RendererExtension } from \"marked\";\nimport { parseDocument } from \"htmlparser2\";\n// Simple type to replace removed WebSearchSource\ntype SimpleSource = {\n\ttitle?: string;\n\tlink: string;\n};\nimport hljs from \"highlight.js/lib/core\";\nimport type { LanguageFn } from \"highlight.js\";\nimport javascript from \"highlight.js/lib/languages/javascript\";\nimport typescript from \"highlight.js/lib/languages/typescript\";\nimport json from \"highlight.js/lib/languages/json\";\nimport bash from \"highlight.js/lib/languages/bash\";\nimport shell from \"highlight.js/lib/languages/shell\";\nimport python from \"highlight.js/lib/languages/python\";\nimport go from \"highlight.js/lib/languages/go\";\nimport rust from \"highlight.js/lib/languages/rust\";\nimport java from \"highlight.js/lib/languages/java\";\nimport csharp from \"highlight.js/lib/languages/csharp\";\nimport cpp from \"highlight.js/lib/languages/cpp\";\nimport cLang from \"highlight.js/lib/languages/c\";\nimport xml from \"highlight.js/lib/languages/xml\";\nimport css from \"highlight.js/lib/languages/css\";\nimport scss from \"highlight.js/lib/languages/scss\";\nimport markdownLang from \"highlight.js/lib/languages/markdown\";\nimport yaml from \"highlight.js/lib/languages/yaml\";\nimport sql from \"highlight.js/lib/languages/sql\";\nimport plaintext from \"highlight.js/lib/languages/plaintext\";\nimport { parseIncompleteMarkdown } from \"./parseIncompleteMarkdown\";\nimport { parseMarkdownIntoBlocks } from \"./parseBlocks\";\n\nconst bundledLanguages: [string, LanguageFn][] = [\n\t[\"javascript\", javascript],\n\t[\"typescript\", typescript],\n\t[\"json\", json],\n\t[\"bash\", bash],\n\t[\"shell\", shell],\n\t[\"python\", python],\n\t[\"go\", go],\n\t[\"rust\", rust],\n\t[\"java\", java],\n\t[\"csharp\", csharp],\n\t[\"cpp\", cpp],\n\t[\"c\", cLang],\n\t[\"xml\", xml],\n\t[\"html\", xml],\n\t[\"css\", css],\n\t[\"scss\", scss],\n\t[\"markdown\", markdownLang],\n\t[\"yaml\", yaml],\n\t[\"sql\", sql],\n\t[\"plaintext\", plaintext],\n];\n\nbundledLanguages.forEach(([name, language]) => hljs.registerLanguage(name, language));\n\n// Media URL detection\nconst VIDEO_EXTENSIONS = /\\.(mp4|webm|ogg|mov|m4v)([?#]|$)/i;\nconst AUDIO_EXTENSIONS = /\\.(mp3|wav|m4a|aac|flac)([?#]|$)/i;\n\nfunction isVideoUrl(url: string): boolean {\n\treturn VIDEO_EXTENSIONS.test(url);\n}\n\nfunction isAudioUrl(url: string): boolean {\n\treturn AUDIO_EXTENSIONS.test(url);\n}\n\n// Multimedia HTML sanitization (works in Web Workers - no DOM needed)\nconst MULTIMEDIA_TAGS = new Set([\"video\", \"source\", \"audio\"]);\nconst MULTIMEDIA_ALLOWED_ATTRS = new Set([\n\t\"src\",\n\t\"type\",\n\t\"controls\",\n\t\"autoplay\",\n\t\"loop\",\n\t\"muted\",\n\t\"playsinline\",\n\t\"poster\",\n\t\"width\",\n\t\"height\",\n\t\"preload\",\n]);\nconst MULTIMEDIA_BOOLEAN_ATTRS = new Set([\"controls\", \"autoplay\", \"loop\", \"muted\", \"playsinline\"]);\nconst MULTIMEDIA_URI_ATTRS = new Set([\"src\", \"poster\"]);\nconst MULTIMEDIA_ALLOWED_URI_PATTERN = /^(?!javascript:|data:text\\/html)/i;\nconst MULTIMEDIA_HTML_REGEX = /<\\/?(video|source|audio)\\b/i;\n\ntype HtmlNode = {\n\ttype: string;\n\tname?: string;\n\tattribs?: Record<string, string>;\n\tchildren?: HtmlNode[];\n\tdata?: string;\n};\n\ninterface katexBlockToken extends Tokens.Generic {\n\ttype: \"katexBlock\";\n\traw: string;\n\ttext: string;\n\tdisplayMode: true;\n}\n\ninterface katexInlineToken extends Tokens.Generic {\n\ttype: \"katexInline\";\n\traw: string;\n\ttext: string;\n\tdisplayMode: false;\n}\n\nexport const katexBlockExtension: TokenizerExtension & RendererExtension = {\n\tname: \"katexBlock\",\n\tlevel: \"block\",\n\n\tstart(src: string): number | undefined {\n\t\tconst match = src.match(/(\\${2}|\\\\\\[)/);\n\t\treturn match ? match.index : -1;\n\t},\n\n\ttokenizer(src: string): katexBlockToken | undefined {\n\t\t// 1) $$ ... $$\n\t\tconst rule1 = /^\\${2}([\\s\\S]+?)\\${2}/;\n\t\tconst match1 = rule1.exec(src);\n\t\tif (match1) {\n\t\t\tconst token: katexBlockToken = {\n\t\t\t\ttype: \"katexBlock\",\n\t\t\t\traw: match1[0],\n\t\t\t\ttext: match1[1].trim(),\n\t\t\t\tdisplayMode: true,\n\t\t\t};\n\t\t\treturn token;\n\t\t}\n\n\t\t// 2) \\[ ... \\]\n\t\tconst rule2 = /^\\\\\\[([\\s\\S]+?)\\\\\\]/;\n\t\tconst match2 = rule2.exec(src);\n\t\tif (match2) {\n\t\t\tconst token: katexBlockToken = {\n\t\t\t\ttype: \"katexBlock\",\n\t\t\t\traw: match2[0],\n\t\t\t\ttext: match2[1].trim(),\n\t\t\t\tdisplayMode: true,\n\t\t\t};\n\t\t\treturn token;\n\t\t}\n\n\t\treturn undefined;\n\t},\n\n\trenderer(token) {\n\t\tif (token.type === \"katexBlock\") {\n\t\t\treturn katex.renderToString(token.text, {\n\t\t\t\tthrowOnError: false,\n\t\t\t\tdisplayMode: token.displayMode,\n\t\t\t});\n\t\t}\n\t\treturn undefined;\n\t},\n};\n\nconst katexInlineExtension: TokenizerExtension & RendererExtension = {\n\tname: \"katexInline\",\n\tlevel: \"inline\",\n\n\tstart(src: string): number | undefined {\n\t\tconst match = src.match(/(\\$|\\\\\\()/);\n\t\treturn match ? match.index : -1;\n\t},\n\n\ttokenizer(src: string): katexInlineToken | undefined {\n\t\t// 1) $...$\n\t\tconst rule1 = /^\\$([^$]+?)\\$/;\n\t\tconst match1 = rule1.exec(src);\n\t\tif (match1) {\n\t\t\tconst token: katexInlineToken = {\n\t\t\t\ttype: \"katexInline\",\n\t\t\t\traw: match1[0],\n\t\t\t\ttext: match1[1].trim(),\n\t\t\t\tdisplayMode: false,\n\t\t\t};\n\t\t\treturn token;\n\t\t}\n\n\t\t// 2) \\(...\\)\n\t\tconst rule2 = /^\\\\\\(([\\s\\S]+?)\\\\\\)/;\n\t\tconst match2 = rule2.exec(src);\n\t\tif (match2) {\n\t\t\tconst token: katexInlineToken = {\n\t\t\t\ttype: \"katexInline\",\n\t\t\t\traw: match2[0],\n\t\t\t\ttext: match2[1].trim(),\n\t\t\t\tdisplayMode: false,\n\t\t\t};\n\t\t\treturn token;\n\t\t}\n\n\t\treturn undefined;\n\t},\n\n\trenderer(token) {\n\t\tif (token.type === \"katexInline\") {\n\t\t\treturn katex.renderToString(token.text, {\n\t\t\t\tthrowOnError: false,\n\t\t\t\tdisplayMode: token.displayMode,\n\t\t\t});\n\t\t}\n\t\treturn undefined;\n\t},\n};\n\nfunction escapeHTML(content: string) {\n\treturn content.replace(\n\t\t/[<>&\"']/g,\n\t\t(x) =>\n\t\t\t({\n\t\t\t\t\"<\": \"&lt;\",\n\t\t\t\t\">\": \"&gt;\",\n\t\t\t\t\"&\": \"&amp;\",\n\t\t\t\t\"'\": \"&#39;\",\n\t\t\t\t'\"': \"&quot;\",\n\t\t\t})[x] || x\n\t);\n}\n\nfunction addInlineCitations(md: string, webSearchSources: SimpleSource[] = []): string {\n\tconst linkStyle =\n\t\t\"color: rgb(59, 130, 246); text-decoration: none; hover:text-decoration: underline;\";\n\treturn md.replace(/\\[(\\d+)\\]/g, (match: string) => {\n\t\tconst indices: number[] = (match.match(/\\d+/g) || []).map(Number);\n\t\tconst links: string = indices\n\t\t\t.map((index: number) => {\n\t\t\t\tif (index === 0) return false;\n\t\t\t\tconst source = webSearchSources[index - 1];\n\t\t\t\tif (source) {\n\t\t\t\t\treturn `<a href=\"${escapeHTML(source.link)}\" target=\"_blank\" rel=\"noreferrer\" style=\"${linkStyle}\">${index}</a>`;\n\t\t\t\t}\n\t\t\t\treturn \"\";\n\t\t\t})\n\t\t\t.filter(Boolean)\n\t\t\t.join(\", \");\n\t\treturn links ? ` <sup>${links}</sup>` : match;\n\t});\n}\n\nfunction sanitizeHref(href?: string | null): string | undefined {\n\tif (!href) return undefined;\n\tconst trimmed = href.trim();\n\tconst lower = trimmed.toLowerCase();\n\tif (lower.startsWith(\"javascript:\") || lower.startsWith(\"data:text/html\")) {\n\t\treturn undefined;\n\t}\n\treturn trimmed.replace(/>$/, \"\");\n}\n\nfunction highlightCode(text: string, lang?: string): string {\n\tif (lang && hljs.getLanguage(lang)) {\n\t\ttry {\n\t\t\treturn hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;\n\t\t} catch {\n\t\t\t// fall through to auto-detect\n\t\t}\n\t}\n\treturn hljs.highlightAuto(text).value;\n}\n\nfunction sanitizeMediaUrl(value: string): string | undefined {\n\tconst trimmed = value.trim().replace(/>$/, \"\");\n\tif (!MULTIMEDIA_ALLOWED_URI_PATTERN.test(trimmed)) return undefined;\n\treturn trimmed;\n}\n\nfunction serializeMediaAttributes(attribs?: Record<string, string>): string {\n\tif (!attribs) return \"\";\n\tconst parts: string[] = [];\n\tfor (const [rawName, rawValue] of Object.entries(attribs)) {\n\t\tconst name = rawName.toLowerCase();\n\t\tif (!MULTIMEDIA_ALLOWED_ATTRS.has(name)) continue;\n\t\tif (MULTIMEDIA_BOOLEAN_ATTRS.has(name)) {\n\t\t\tparts.push(name);\n\t\t\tcontinue;\n\t\t}\n\t\tlet value = rawValue ?? \"\";\n\t\tif (MULTIMEDIA_URI_ATTRS.has(name)) {\n\t\t\tconst safeUrl = sanitizeMediaUrl(value);\n\t\t\tif (!safeUrl) continue;\n\t\t\tvalue = safeUrl;\n\t\t}\n\t\tparts.push(`${name}=\"${escapeHTML(value)}\"`);\n\t}\n\treturn parts.length ? ` ${parts.join(\" \")}` : \"\";\n}\n\nfunction serializeMediaNode(node: HtmlNode, state: { hasDisallowedTag: boolean }): string {\n\tif (node.type === \"text\") {\n\t\treturn escapeHTML(node.data ?? \"\");\n\t}\n\tif (node.type === \"tag\" || node.type === \"script\" || node.type === \"style\") {\n\t\tconst tagName = node.name?.toLowerCase() ?? \"\";\n\t\tif (!MULTIMEDIA_TAGS.has(tagName)) {\n\t\t\tstate.hasDisallowedTag = true;\n\t\t\treturn \"\";\n\t\t}\n\t\tconst attrs = serializeMediaAttributes(node.attribs);\n\t\tif (tagName === \"source\") {\n\t\t\treturn `<source${attrs}>`;\n\t\t}\n\t\tconst children = (node.children ?? [])\n\t\t\t.map((child) => serializeMediaNode(child, state))\n\t\t\t.join(\"\");\n\t\treturn `<${tagName}${attrs}>${children}</${tagName}>`;\n\t}\n\tif (node.type === \"comment\") {\n\t\treturn \"\";\n\t}\n\treturn \"\";\n}\n\n/**\n * Sanitizes HTML to allow only video/audio/source tags with safe attributes.\n * Uses htmlparser2 which works in Web Workers (no DOM needed).\n * If any disallowed tags are found, escapes the entire input.\n */\nfunction sanitizeHtmlForMultimedia(html: string): string {\n\tif (!MULTIMEDIA_HTML_REGEX.test(html)) {\n\t\treturn escapeHTML(html);\n\t}\n\tconst document = parseDocument(html, {\n\t\tlowerCaseAttributeNames: true,\n\t\tlowerCaseTags: true,\n\t\trecognizeSelfClosing: true,\n\t}) as unknown as { children: HtmlNode[] };\n\tconst state = { hasDisallowedTag: false };\n\tconst sanitized = (document.children ?? [])\n\t\t.map((child) => serializeMediaNode(child, state))\n\t\t.join(\"\");\n\tif (state.hasDisallowedTag) {\n\t\treturn escapeHTML(html);\n\t}\n\treturn sanitized;\n}\n\nfunction createMarkedInstance(sources: SimpleSource[]): Marked {\n\treturn new Marked({\n\t\thooks: {\n\t\t\tpostprocess: (html) => addInlineCitations(html, sources),\n\t\t},\n\t\textensions: [katexBlockExtension, katexInlineExtension],\n\t\trenderer: {\n\t\t\tlink: (href, title, text) => {\n\t\t\t\tconst safeHref = sanitizeHref(href);\n\t\t\t\treturn safeHref\n\t\t\t\t\t? `<a href=\"${escapeHTML(safeHref)}\" target=\"_blank\" rel=\"noreferrer\">${text}</a>`\n\t\t\t\t\t: `<span>${escapeHTML(text ?? \"\")}</span>`;\n\t\t\t},\n\t\t\timage: (href, title, text) => {\n\t\t\t\tconst safeHref = sanitizeHref(href);\n\t\t\t\tif (!safeHref) return `<span>${escapeHTML(text ?? \"\")}</span>`;\n\n\t\t\t\tconst safeSrc = escapeHTML(safeHref);\n\t\t\t\tconst safeTitle = title ? ` title=\"${escapeHTML(title)}\"` : \"\";\n\t\t\t\tconst safeAlt = escapeHTML(text ?? \"\");\n\n\t\t\t\tif (isVideoUrl(safeHref)) {\n\t\t\t\t\treturn `<video controls${safeTitle}><source src=\"${safeSrc}\">${safeAlt}</video>`;\n\t\t\t\t}\n\t\t\t\tif (isAudioUrl(safeHref)) {\n\t\t\t\t\treturn `<audio controls${safeTitle}><source src=\"${safeSrc}\">${safeAlt}</audio>`;\n\t\t\t\t}\n\t\t\t\treturn `<img src=\"${safeSrc}\" alt=\"${safeAlt}\"${safeTitle} />`;\n\t\t\t},\n\t\t\thtml: (html) => sanitizeHtmlForMultimedia(html),\n\t\t},\n\t\tgfm: true,\n\t\tbreaks: true,\n\t});\n}\nfunction isFencedBlockClosed(raw?: string): boolean {\n\tif (!raw) return true;\n\t/* eslint-disable-next-line no-control-regex */\n\tconst trimmed = raw.replace(/[\\s\\u0000]+$/, \"\");\n\tconst openingFenceMatch = trimmed.match(/^([`~]{3,})/);\n\tif (!openingFenceMatch) {\n\t\treturn true;\n\t}\n\tconst fence = openingFenceMatch[1];\n\tconst closingFencePattern = new RegExp(`(?:\\n|\\r\\n)${fence}(?:[\\t ]+)?$`);\n\treturn closingFencePattern.test(trimmed);\n}\n\ntype CodeToken = {\n\ttype: \"code\";\n\tlang: string;\n\tcode: string;\n\trawCode: string;\n\tisClosed: boolean;\n};\n\ntype TextToken = {\n\ttype: \"text\";\n\thtml: string | Promise<string>;\n};\n\nconst blockCache = new Map<string, BlockToken>();\n\nfunction cacheKey(index: number, blockContent: string, sources: SimpleSource[]) {\n\tconst sourceKey = sources.map((s) => s.link).join(\"|\");\n\treturn `${index}-${hashString(blockContent)}|${sourceKey}`;\n}\n\nexport async function processTokens(content: string, sources: SimpleSource[]): Promise<Token[]> {\n\t// Apply incomplete markdown preprocessing for smooth streaming\n\tconst processedContent = parseIncompleteMarkdown(content);\n\n\tconst marked = createMarkedInstance(sources);\n\tconst tokens = marked.lexer(processedContent);\n\n\tconst processedTokens = await Promise.all(\n\t\ttokens.map(async (token) => {\n\t\t\tif (token.type === \"code\") {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"code\" as const,\n\t\t\t\t\tlang: token.lang,\n\t\t\t\t\tcode: highlightCode(token.text, token.lang),\n\t\t\t\t\trawCode: token.text,\n\t\t\t\t\tisClosed: isFencedBlockClosed(token.raw ?? \"\"),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"text\" as const,\n\t\t\t\t\thtml: marked.parse(token.raw),\n\t\t\t\t};\n\t\t\t}\n\t\t})\n\t);\n\n\treturn processedTokens;\n}\n\nexport function processTokensSync(content: string, sources: SimpleSource[]): Token[] {\n\t// Apply incomplete markdown preprocessing for smooth streaming\n\tconst processedContent = parseIncompleteMarkdown(content);\n\n\tconst marked = createMarkedInstance(sources);\n\tconst tokens = marked.lexer(processedContent);\n\treturn tokens.map((token) => {\n\t\tif (token.type === \"code\") {\n\t\t\treturn {\n\t\t\t\ttype: \"code\" as const,\n\t\t\t\tlang: token.lang,\n\t\t\t\tcode: highlightCode(token.text, token.lang),\n\t\t\t\trawCode: token.text,\n\t\t\t\tisClosed: isFencedBlockClosed(token.raw ?? \"\"),\n\t\t\t};\n\t\t}\n\t\treturn { type: \"text\" as const, html: marked.parse(token.raw) };\n\t});\n}\n\nexport type Token = CodeToken | TextToken;\n\nexport type BlockToken = {\n\tid: string;\n\tcontent: string;\n\ttokens: Token[];\n};\n\n/**\n * Simple hash function for generating stable block IDs\n */\nfunction hashString(str: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32bit integer\n\t}\n\treturn Math.abs(hash).toString(36);\n}\n\n/**\n * Process markdown content into blocks with stable IDs for efficient memoization.\n * Each block is processed independently and assigned a content-based hash ID.\n */\nexport async function processBlocks(\n\tcontent: string,\n\tsources: SimpleSource[] = []\n): Promise<BlockToken[]> {\n\tconst blocks = parseMarkdownIntoBlocks(content);\n\n\treturn await Promise.all(\n\t\tblocks.map(async (blockContent, index) => {\n\t\t\tconst key = cacheKey(index, blockContent, sources);\n\t\t\tconst cached = blockCache.get(key);\n\t\t\tif (cached) return cached;\n\n\t\t\tconst tokens = await processTokens(blockContent, sources);\n\t\t\tconst block: BlockToken = {\n\t\t\t\tid: `${index}-${hashString(blockContent)}`,\n\t\t\t\tcontent: blockContent,\n\t\t\t\ttokens,\n\t\t\t};\n\t\t\tblockCache.set(key, block);\n\t\t\treturn block;\n\t\t})\n\t);\n}\n\n/**\n * Synchronous version of processBlocks for SSR\n */\nexport function processBlocksSync(content: string, sources: SimpleSource[] = []): BlockToken[] {\n\tconst blocks = parseMarkdownIntoBlocks(content);\n\n\treturn blocks.map((blockContent, index) => {\n\t\tconst key = cacheKey(index, blockContent, sources);\n\t\tconst cached = blockCache.get(key);\n\t\tif (cached) return cached;\n\n\t\tconst tokens = processTokensSync(blockContent, sources);\n\t\tconst block: BlockToken = {\n\t\t\tid: `${index}-${hashString(blockContent)}`,\n\t\t\tcontent: blockContent,\n\t\t\ttokens,\n\t\t};\n\t\tblockCache.set(key, block);\n\t\treturn block;\n\t});\n}\n"
  },
  {
    "path": "src/lib/utils/mcpValidation.ts",
    "content": "/**\n * URL validation and sanitization utilities for MCP integration\n */\n\nimport { browser } from \"$app/environment\";\nimport { dev } from \"$app/environment\";\n\n/**\n * Sanitize and validate a URL for MCP server connections\n * @param urlString - The URL string to validate\n * @returns Sanitized URL string or null if invalid\n */\nexport function validateMcpServerUrl(urlString: string): string | null {\n\tif (!urlString || typeof urlString !== \"string\") {\n\t\treturn null;\n\t}\n\n\ttry {\n\t\tconst url = new URL(urlString.trim());\n\n\t\t// Allow http/https only\n\t\tif (![\"http:\", \"https:\"].includes(url.protocol)) {\n\t\t\treturn null;\n\t\t}\n\n\t\t// Warn about non-HTTPS in production\n\t\tif (!dev && url.protocol === \"http:\" && browser) {\n\t\t\tconsole.warn(\n\t\t\t\t\"Warning: Connecting to non-HTTPS MCP server in production. This may expose sensitive data.\"\n\t\t\t);\n\t\t}\n\n\t\t// Block certain localhost/private IPs in production\n\t\tif (!dev && isPrivateOrLocalhost(url.hostname)) {\n\t\t\tconsole.warn(\"Warning: Localhost/private IP addresses are not recommended in production.\");\n\t\t}\n\n\t\treturn url.toString();\n\t} catch (error) {\n\t\t// Invalid URL\n\t\treturn null;\n\t}\n}\n\n/**\n * Check if hostname is localhost or a private IP\n */\nfunction isPrivateOrLocalhost(hostname: string): boolean {\n\t// Localhost checks\n\tif (\n\t\thostname === \"localhost\" ||\n\t\thostname === \"127.0.0.1\" ||\n\t\thostname === \"::1\" ||\n\t\thostname.endsWith(\".localhost\")\n\t) {\n\t\treturn true;\n\t}\n\n\t// Private IP ranges (IPv4)\n\tconst ipv4Regex = /^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|0\\.0\\.0\\.0|169\\.254\\.)/;\n\tif (ipv4Regex.test(hostname)) {\n\t\treturn true;\n\t}\n\n\treturn false;\n}\n\n/**\n * Sanitize URL by removing sensitive parts\n * Used for logging and display purposes\n */\nexport function sanitizeUrlForDisplay(urlString: string): string {\n\ttry {\n\t\tconst url = new URL(urlString);\n\t\t// Remove username/password if present\n\t\turl.username = \"\";\n\t\turl.password = \"\";\n\t\treturn url.toString();\n\t} catch {\n\t\treturn urlString;\n\t}\n}\n\n/**\n * Check if URL is safe to connect to\n * Returns an error message if unsafe, null if safe\n */\nexport function checkUrlSafety(urlString: string): string | null {\n\tconst validated = validateMcpServerUrl(urlString);\n\tif (!validated) {\n\t\treturn \"Invalid URL. Please use http:// or https:// URLs only.\";\n\t}\n\n\ttry {\n\t\tconst url = new URL(validated);\n\n\t\t// Additional safety checks\n\t\tif (!dev && url.protocol === \"http:\") {\n\t\t\treturn \"Non-HTTPS URLs are not recommended in production. Please use https:// for security.\";\n\t\t}\n\n\t\treturn null; // Safe\n\t} catch {\n\t\treturn \"Invalid URL format.\";\n\t}\n}\n\n/**\n * Check if a header key is likely to contain sensitive data\n */\nexport function isSensitiveHeader(key: string): boolean {\n\tconst sensitiveKeys = [\n\t\t\"authorization\",\n\t\t\"api-key\",\n\t\t\"api_key\",\n\t\t\"apikey\",\n\t\t\"token\",\n\t\t\"secret\",\n\t\t\"password\",\n\t\t\"bearer\",\n\t\t\"x-api-key\",\n\t\t\"x-auth-token\",\n\t];\n\n\tconst lowerKey = key.toLowerCase();\n\treturn sensitiveKeys.some((sensitive) => lowerKey.includes(sensitive));\n}\n\n/**\n * Validate header key-value pair\n * Returns error message if invalid, null if valid\n */\nexport function validateHeader(key: string, value: string): string | null {\n\tif (!key || !key.trim()) {\n\t\treturn \"Header name is required\";\n\t}\n\n\tif (!/^[a-zA-Z0-9_-]+$/.test(key)) {\n\t\treturn \"Header name can only contain letters, numbers, hyphens, and underscores\";\n\t}\n\n\tif (!value) {\n\t\treturn \"Header value is required\";\n\t}\n\n\treturn null;\n}\n"
  },
  {
    "path": "src/lib/utils/mergeAsyncGenerators.ts",
    "content": "type Gen<T, TReturn> = AsyncGenerator<T, TReturn, undefined>;\n\ntype GenPromiseMap<T, TReturn> = Map<\n\tGen<T, TReturn>,\n\tPromise<{ gen: Gen<T, TReturn> } & IteratorResult<T, TReturn>>\n>;\n\n/** Merges multiple async generators into a single async generator that yields values from all of them in parallel. */\nexport async function* mergeAsyncGenerators<T, TReturn>(\n\tgenerators: Gen<T, TReturn>[]\n): Gen<T, TReturn[]> {\n\tconst promises: GenPromiseMap<T, TReturn> = new Map();\n\tconst results: Map<Gen<T, TReturn>, TReturn> = new Map();\n\n\tfor (const gen of generators) {\n\t\tpromises.set(\n\t\t\tgen,\n\t\t\tgen.next().then((result) => ({ gen, ...result }))\n\t\t);\n\t}\n\n\twhile (promises.size) {\n\t\tconst { gen, value, done } = await Promise.race(promises.values());\n\t\tif (done) {\n\t\t\tresults.set(gen, value as TReturn);\n\t\t\tpromises.delete(gen);\n\t\t} else {\n\t\t\tpromises.set(\n\t\t\t\tgen,\n\t\t\t\tgen.next().then((result) => ({ gen, ...result }))\n\t\t\t);\n\t\t\tyield value as T;\n\t\t}\n\t}\n\n\tconst orderedResults = generators.map((gen) => results.get(gen) as TReturn);\n\treturn orderedResults;\n}\n"
  },
  {
    "path": "src/lib/utils/messageUpdates.spec.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n\tMessageUpdateStatus,\n\tMessageUpdateType,\n\ttype MessageUpdate,\n} from \"$lib/types/MessageUpdate\";\nimport { applyStreamingMode, resolveStreamingMode, smoothStreamUpdates } from \"./messageUpdates\";\n\nasync function* fromArray<T>(values: T[]): AsyncGenerator<T> {\n\tfor (const value of values) {\n\t\tyield value;\n\t}\n}\n\nasync function collect(iter: AsyncGenerator<MessageUpdate>) {\n\tconst updates: MessageUpdate[] = [];\n\tfor await (const update of iter) {\n\t\tupdates.push(update);\n\t}\n\treturn updates;\n}\n\nconst streamText = (updates: MessageUpdate[]) =>\n\tupdates\n\t\t.filter((u) => u.type === MessageUpdateType.Stream)\n\t\t.map((u) => u.token)\n\t\t.join(\"\");\n\ndescribe(\"smoothStreamUpdates\", () => {\n\tit(\"merges partial words and preserves final text\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{ type: MessageUpdateType.Stream, token: \"Hel\" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"lo \" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"wor\" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"ld!\" },\n\t\t\t{ type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished },\n\t\t];\n\n\t\tconst updates = await collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), {\n\t\t\t\tminDelayMs: 0,\n\t\t\t\tmaxDelayMs: 0,\n\t\t\t\t_internal: { detectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null },\n\t\t\t})\n\t\t);\n\n\t\tconst streamedChunks = updates.filter((u) => u.type === MessageUpdateType.Stream);\n\t\texpect(streamedChunks.map((u) => u.token)).toEqual([\"Hello \", \"world!\"]);\n\t\texpect(streamText(updates)).toBe(\"Hello world!\");\n\t});\n\n\tit(\"flushes buffered stream text before non-stream updates\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{ type: MessageUpdateType.Stream, token: \"hello\" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \" world\" },\n\t\t\t{ type: MessageUpdateType.Title, title: \"done\" },\n\t\t];\n\n\t\tconst updates = await collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 })\n\t\t);\n\t\texpect(updates[0]).toMatchObject({ type: MessageUpdateType.Stream });\n\t\texpect(updates[1]).toMatchObject({ type: MessageUpdateType.Stream });\n\t\texpect(updates[2]).toEqual({ type: MessageUpdateType.Title, title: \"done\" });\n\t\texpect(streamText(updates)).toBe(\"hello world\");\n\t});\n\n\tit(\"spreads burst tokens over time\", async () => {\n\t\tconst bigToken = \"word \".repeat(40); // 200 chars, 40 words\n\t\tconst source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: bigToken }];\n\t\tlet nowMs = 0;\n\t\tconst emitTimes: number[] = [];\n\n\t\tconst iter = smoothStreamUpdates(fromArray(source), {\n\t\t\tminDelayMs: 5,\n\t\t\tmaxDelayMs: 80,\n\t\t\tminRateCharsPerMs: 0.3,\n\t\t\t_internal: {\n\t\t\t\tnow: () => nowMs,\n\t\t\t\tsleep: async (ms: number) => {\n\t\t\t\t\tnowMs += ms;\n\t\t\t\t},\n\t\t\t\tdetectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null,\n\t\t\t},\n\t\t});\n\n\t\tfor await (const update of iter) {\n\t\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\t\temitTimes.push(nowMs);\n\t\t\t}\n\t\t}\n\n\t\t// Should have multiple emissions\n\t\texpect(emitTimes.length).toBeGreaterThan(5);\n\t\t// Gap between first and last emission should be significant (not instant dump)\n\t\tconst totalSpread = (emitTimes.at(-1) ?? 0) - (emitTimes[0] ?? 0);\n\t\texpect(totalSpread).toBeGreaterThan(100);\n\t});\n\n\tit(\"keeps delays within configured bounds\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{\n\t\t\t\ttype: MessageUpdateType.Stream,\n\t\t\t\ttoken: \"one two three four five six seven eight nine ten \",\n\t\t\t},\n\t\t];\n\t\tconst delays: number[] = [];\n\t\tlet nowMs = 0;\n\n\t\tawait collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), {\n\t\t\t\tminDelayMs: 5,\n\t\t\t\tmaxDelayMs: 80,\n\t\t\t\tminRateCharsPerMs: 0.3,\n\t\t\t\t_internal: {\n\t\t\t\t\tnow: () => nowMs,\n\t\t\t\t\tsleep: async (ms: number) => {\n\t\t\t\t\t\tdelays.push(ms);\n\t\t\t\t\t\tnowMs += ms;\n\t\t\t\t\t},\n\t\t\t\t\tdetectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null,\n\t\t\t\t},\n\t\t\t})\n\t\t);\n\n\t\texpect(delays.length).toBeGreaterThan(2);\n\t\texpect(delays.every((d) => d >= 5 && d <= 80)).toBe(true);\n\t\t// First delay should be >= later delays (rate floor dominates initially)\n\t\texpect(delays[0]).toBeGreaterThanOrEqual(delays.at(-1) ?? 0);\n\t});\n\n\tit(\"handles CJK text correctly\", async () => {\n\t\tconst source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: \"你好，世界！\" }];\n\n\t\tconst updates = await collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 })\n\t\t);\n\n\t\texpect(streamText(updates)).toBe(\"你好，世界！\");\n\t});\n\n\tit(\"propagates source errors to consumer\", async () => {\n\t\tasync function* failingSource(): AsyncGenerator<MessageUpdate> {\n\t\t\tyield { type: MessageUpdateType.Stream, token: \"hello \" };\n\t\t\tthrow new Error(\"source failed\");\n\t\t}\n\n\t\tawait expect(\n\t\t\tcollect(smoothStreamUpdates(failingSource(), { minDelayMs: 0, maxDelayMs: 0 }))\n\t\t).rejects.toThrow(\"source failed\");\n\t});\n\n\tit(\"propagates source errors even when no full chunk was emitted yet\", async () => {\n\t\tasync function* failingSource(): AsyncGenerator<MessageUpdate> {\n\t\t\tyield { type: MessageUpdateType.Stream, token: \"hel\" };\n\t\t\tthrow new Error(\"source failed\");\n\t\t}\n\n\t\tawait expect(\n\t\t\tcollect(\n\t\t\t\tsmoothStreamUpdates(failingSource(), {\n\t\t\t\t\tminDelayMs: 0,\n\t\t\t\t\tmaxDelayMs: 0,\n\t\t\t\t\t_internal: { detectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null },\n\t\t\t\t})\n\t\t\t)\n\t\t).rejects.toThrow(\"source failed\");\n\t});\n\n\tit(\"drains queued stream chunks before throwing source errors\", async () => {\n\t\tasync function* failingSource(): AsyncGenerator<MessageUpdate> {\n\t\t\tyield { type: MessageUpdateType.Stream, token: \"a \" };\n\t\t\tyield { type: MessageUpdateType.Stream, token: \"b \" };\n\t\t\tyield { type: MessageUpdateType.Stream, token: \"c \" };\n\t\t\tthrow new Error(\"source failed\");\n\t\t}\n\n\t\tconst seen: MessageUpdate[] = [];\n\t\tlet seenError: Error | null = null;\n\t\ttry {\n\t\t\tfor await (const update of smoothStreamUpdates(failingSource(), {\n\t\t\t\tminDelayMs: 0,\n\t\t\t\tmaxDelayMs: 0,\n\t\t\t\t_internal: { detectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null },\n\t\t\t})) {\n\t\t\t\tseen.push(update);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tseenError = error as Error;\n\t\t}\n\n\t\texpect(streamText(seen)).toBe(\"a b c \");\n\t\texpect(seenError?.message).toBe(\"source failed\");\n\t});\n\n\tit(\"caps burst tail latency with backlog acceleration\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{ type: MessageUpdateType.Stream, token: \"word \".repeat(500) },\n\t\t];\n\t\tlet nowMs = 0;\n\t\tawait collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), {\n\t\t\t\tminDelayMs: 5,\n\t\t\t\tmaxDelayMs: 80,\n\t\t\t\tminRateCharsPerMs: 0.3,\n\t\t\t\tmaxBufferedMs: 400,\n\t\t\t\t_internal: {\n\t\t\t\t\tnow: () => nowMs,\n\t\t\t\t\tsleep: async (ms: number) => {\n\t\t\t\t\t\tnowMs += ms;\n\t\t\t\t\t},\n\t\t\t\t\tdetectChunk: (buffer) => /\\S+\\s+/.exec(buffer)?.[0] ?? null,\n\t\t\t\t},\n\t\t\t})\n\t\t);\n\n\t\texpect(nowMs).toBeLessThan(1500);\n\t});\n\n\tit(\"skips empty tokens gracefully\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{ type: MessageUpdateType.Stream, token: \"\" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"hello \" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"\" },\n\t\t\t{ type: MessageUpdateType.Stream, token: \"world!\" },\n\t\t\t{ type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished },\n\t\t];\n\n\t\tconst updates = await collect(\n\t\t\tsmoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 })\n\t\t);\n\t\texpect(streamText(updates)).toBe(\"hello world!\");\n\t});\n});\n\ndescribe(\"applyStreamingMode\", () => {\n\tit(\"keeps stream unchanged for raw mode\", async () => {\n\t\tconst source: MessageUpdate[] = [\n\t\t\t{ type: MessageUpdateType.Stream, token: \"Hello\" },\n\t\t\t{ type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished },\n\t\t];\n\n\t\tconst raw = await collect(applyStreamingMode(fromArray(source), \"raw\"));\n\n\t\texpect(raw).toEqual(source);\n\t});\n});\n\ndescribe(\"resolveStreamingMode\", () => {\n\tit(\"returns explicit streamingMode when set\", () => {\n\t\texpect(resolveStreamingMode({ streamingMode: \"raw\" })).toBe(\"raw\");\n\t\texpect(resolveStreamingMode({ streamingMode: \"smooth\" })).toBe(\"smooth\");\n\t});\n\n\tit(\"defaults to smooth when unset\", () => {\n\t\texpect(resolveStreamingMode({})).toBe(\"smooth\");\n\t});\n\n\tit(\"maps unsupported legacy values to smooth\", () => {\n\t\texpect(resolveStreamingMode({ streamingMode: \"final\" })).toBe(\"smooth\");\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/messageUpdates.ts",
    "content": "import type { MessageFile } from \"$lib/types/Message\";\nimport {\n\ttype MessageUpdate,\n\ttype MessageToolUpdate,\n\ttype MessageToolCallUpdate,\n\ttype MessageToolResultUpdate,\n\ttype MessageToolErrorUpdate,\n\ttype MessageToolProgressUpdate,\n\tMessageUpdateType,\n\tMessageToolUpdateType,\n} from \"$lib/types/MessageUpdate\";\nimport type { StreamingMode } from \"$lib/types/Settings\";\nimport type { KeyValuePair } from \"$lib/types/Tool\";\n\ntype MessageUpdateRequestOptions = {\n\tbase: string;\n\tinputs?: string;\n\tmessageId?: string;\n\tisRetry: boolean;\n\tisContinue?: boolean;\n\tfiles?: MessageFile[];\n\t// Optional: pass selected MCP server names (client-side selection)\n\tselectedMcpServerNames?: string[];\n\t// Optional: pass selected MCP server configs (for custom client-defined servers)\n\tselectedMcpServers?: Array<{ name: string; url: string; headers?: KeyValuePair[] }>;\n\tstreamingMode?: StreamingMode;\n};\n\ntype ChunkDetector = (buffer: string) => string | null;\n\ntype SmoothStreamConfig = {\n\tminDelayMs?: number;\n\tmaxDelayMs?: number;\n\tminRateCharsPerMs?: number;\n\tmaxBufferedMs?: number;\n\t_internal?: {\n\t\tnow?: () => number;\n\t\tsleep?: (ms: number) => Promise<void>;\n\t\tdetectChunk?: ChunkDetector;\n\t};\n};\n\nexport async function fetchMessageUpdates(\n\tconversationId: string,\n\topts: MessageUpdateRequestOptions,\n\tabortSignal: AbortSignal\n): Promise<AsyncGenerator<MessageUpdate>> {\n\tconst abortController = new AbortController();\n\tabortSignal.addEventListener(\"abort\", () => abortController.abort());\n\n\tconst form = new FormData();\n\n\tconst optsJSON = JSON.stringify({\n\t\tinputs: opts.inputs,\n\t\tid: opts.messageId,\n\t\tis_retry: opts.isRetry,\n\t\tis_continue: Boolean(opts.isContinue),\n\t\t// Will be ignored server-side if unsupported\n\t\tselectedMcpServerNames: opts.selectedMcpServerNames,\n\t\tselectedMcpServers: opts.selectedMcpServers,\n\t});\n\n\topts.files?.forEach((file) => {\n\t\tconst name = file.type + \";\" + file.name;\n\n\t\tform.append(\"files\", new File([file.value], name, { type: file.mime }));\n\t});\n\n\tform.append(\"data\", optsJSON);\n\n\tconst response = await fetch(`${opts.base}/conversation/${conversationId}`, {\n\t\tmethod: \"POST\",\n\t\tbody: form,\n\t\tsignal: abortController.signal,\n\t});\n\n\tif (!response.ok) {\n\t\tconst errorMessage = await response\n\t\t\t.json()\n\t\t\t.then((obj) => obj.message)\n\t\t\t.catch(() => `Request failed with status code ${response.status}: ${response.statusText}`);\n\t\tthrow Error(errorMessage);\n\t}\n\tif (!response.body) {\n\t\tthrow Error(\"Body not defined\");\n\t}\n\n\treturn applyStreamingMode(\n\t\tendpointStreamToIterator(response, abortController),\n\t\topts.streamingMode ?? \"smooth\"\n\t);\n}\n\nexport function applyStreamingMode(\n\titerator: AsyncGenerator<MessageUpdate>,\n\tstreamingMode: StreamingMode\n): AsyncGenerator<MessageUpdate> {\n\tif (streamingMode === \"smooth\") {\n\t\treturn smoothStreamUpdates(iterator);\n\t}\n\n\t// \"raw\" keeps source stream intact.\n\treturn iterator;\n}\n\nexport function resolveStreamingMode(s: { streamingMode?: unknown }): StreamingMode {\n\treturn s.streamingMode === \"raw\" || s.streamingMode === \"smooth\" ? s.streamingMode : \"smooth\";\n}\n\nasync function* endpointStreamToIterator(\n\tresponse: Response,\n\tabortController: AbortController\n): AsyncGenerator<MessageUpdate> {\n\tconst reader = response.body?.pipeThrough(new TextDecoderStream()).getReader();\n\tif (!reader) throw Error(\"Response for endpoint had no body\");\n\n\t// Handle any cases where we must abort\n\treader.closed.then(() => abortController.abort());\n\n\t// Handle logic for aborting\n\tabortController.signal.addEventListener(\"abort\", () => reader.cancel());\n\n\t// ex) If the last response is => {\"type\": \"stream\", \"token\":\n\t// It should be => {\"type\": \"stream\", \"token\": \"Hello\"} = prev_input_chunk + \"Hello\"}\n\tlet prevChunk = \"\";\n\twhile (!abortController.signal.aborted) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) {\n\t\t\tabortController.abort();\n\t\t\tbreak;\n\t\t}\n\t\tif (!value) continue;\n\n\t\tconst { messageUpdates, remainingText } = parseMessageUpdates(prevChunk + value);\n\t\tprevChunk = remainingText;\n\t\tfor (const messageUpdate of messageUpdates) yield messageUpdate;\n\t}\n}\n\nfunction parseMessageUpdates(value: string): {\n\tmessageUpdates: MessageUpdate[];\n\tremainingText: string;\n} {\n\tconst inputs = value.split(\"\\n\");\n\tconst messageUpdates: MessageUpdate[] = [];\n\tfor (const input of inputs) {\n\t\ttry {\n\t\t\tmessageUpdates.push(JSON.parse(input) as MessageUpdate);\n\t\t} catch (error) {\n\t\t\t// in case of parsing error, we return what we were able to parse\n\t\t\tif (error instanceof SyntaxError) {\n\t\t\t\treturn {\n\t\t\t\t\tmessageUpdates,\n\t\t\t\t\tremainingText: inputs.at(-1) ?? \"\",\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t}\n\treturn { messageUpdates, remainingText: \"\" };\n}\n\nexport async function* smoothStreamUpdates(\n\titerator: AsyncGenerator<MessageUpdate>,\n\t{\n\t\tminDelayMs = 5,\n\t\tmaxDelayMs = 80,\n\t\tminRateCharsPerMs = 0.3,\n\t\tmaxBufferedMs = 400,\n\t\t_internal: { now = () => performance.now(), sleep = defaultSleep, detectChunk } = {},\n\t}: SmoothStreamConfig = {}\n): AsyncGenerator<MessageUpdate> {\n\tconst chunkDetector = detectChunk ?? createWordChunkDetector();\n\tconst eventTarget = new EventTarget();\n\tconst outputQueue: Array<{ update: MessageUpdate }> = [];\n\tlet producerDone = false;\n\tlet producerError: unknown = null;\n\tlet pendingBuffer = \"\";\n\tlet queuedStreamChars = 0;\n\n\tconst enqueue = (update: MessageUpdate) => {\n\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\tqueuedStreamChars += update.token.length;\n\t\t}\n\t\toutputQueue.push({ update });\n\t\teventTarget.dispatchEvent(new Event(\"next\"));\n\t};\n\n\tconst flushPendingBuffer = () => {\n\t\tif (pendingBuffer.length === 0) return;\n\t\tenqueue({ type: MessageUpdateType.Stream, token: pendingBuffer });\n\t\tpendingBuffer = \"\";\n\t};\n\n\tconst producer = (async () => {\n\t\tfor await (const messageUpdate of iterator) {\n\t\t\tif (messageUpdate.type !== MessageUpdateType.Stream) {\n\t\t\t\tflushPendingBuffer();\n\t\t\t\tenqueue(messageUpdate);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!messageUpdate.token) continue;\n\n\t\t\tpendingBuffer += messageUpdate.token;\n\t\t\tlet chunk: string | null;\n\t\t\twhile ((chunk = chunkDetector(pendingBuffer)) !== null) {\n\t\t\t\tif (chunk.length === 0) break;\n\t\t\t\tenqueue({ type: MessageUpdateType.Stream, token: chunk });\n\t\t\t\tpendingBuffer = pendingBuffer.slice(chunk.length);\n\t\t\t}\n\t\t}\n\t\tflushPendingBuffer();\n\t})()\n\t\t.catch((error) => {\n\t\t\tproducerError = error;\n\t\t})\n\t\t.finally(() => {\n\t\t\tproducerDone = true;\n\t\t\teventTarget.dispatchEvent(new Event(\"next\"));\n\t\t});\n\n\t// Character-rate targeting consumer\n\tlet totalCharsEmitted = 0;\n\tlet firstEmitAt: number | null = null;\n\n\twhile (!producerDone || outputQueue.length > 0) {\n\t\tif (outputQueue.length === 0) {\n\t\t\tawait waitForEvent(eventTarget, \"next\");\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst next = outputQueue.shift();\n\t\tif (!next) continue;\n\n\t\tif (next.update.type === MessageUpdateType.Stream) {\n\t\t\tconst tokenLen = next.update.token.length;\n\t\t\tqueuedStreamChars = Math.max(0, queuedStreamChars - tokenLen);\n\t\t\ttotalCharsEmitted += tokenLen;\n\n\t\t\tif (firstEmitAt === null) firstEmitAt = now();\n\n\t\t\tconst elapsedMs = now() - firstEmitAt;\n\t\t\tconst currentRate = elapsedMs > 0 ? totalCharsEmitted / elapsedMs : 0;\n\t\t\tconst backlogChars = tokenLen + queuedStreamChars;\n\t\t\tconst backlogRate = maxBufferedMs > 0 ? backlogChars / maxBufferedMs : 0;\n\t\t\tconst targetRate = Math.max(currentRate, minRateCharsPerMs, backlogRate);\n\t\t\tconst rawDelay = tokenLen / targetRate;\n\t\t\tconst underBacklogPressure = backlogRate > minRateCharsPerMs;\n\t\t\tconst effectiveMinDelayMs = underBacklogPressure ? 0 : minDelayMs;\n\t\t\tconst delayMs = Math.round(Math.max(effectiveMinDelayMs, Math.min(maxDelayMs, rawDelay)));\n\n\t\t\tif (delayMs > 0) {\n\t\t\t\tawait sleep(delayMs);\n\t\t\t}\n\t\t}\n\n\t\tyield next.update;\n\t}\n\n\tawait producer;\n\tif (producerError) throw producerError;\n}\n\nfunction createWordChunkDetector(): ChunkDetector {\n\tif (typeof Intl !== \"undefined\" && typeof Intl.Segmenter === \"function\") {\n\t\tconst segmenter = new Intl.Segmenter(undefined, { granularity: \"word\" });\n\t\treturn (buffer: string): string | null => {\n\t\t\tif (buffer.length === 0) return null;\n\t\t\tlet cursor = 0;\n\t\t\tlet boundary = 0;\n\t\t\tlet sawWordLike = false;\n\n\t\t\tfor (const part of segmenter.segment(buffer)) {\n\t\t\t\tcursor += part.segment.length;\n\t\t\t\tif (part.isWordLike) {\n\t\t\t\t\tsawWordLike = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (sawWordLike) {\n\t\t\t\t\tboundary = cursor;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn boundary > 0 ? buffer.slice(0, boundary) : null;\n\t\t};\n\t}\n\n\tconst wordWithTrailingBoundary = /\\S+\\s+/m;\n\treturn (buffer: string): string | null => {\n\t\tconst match = wordWithTrailingBoundary.exec(buffer);\n\t\tif (!match) return null;\n\t\treturn buffer.slice(0, match.index) + match[0];\n\t};\n}\n\n// Tool update type guards for UI rendering\nexport const isMessageToolUpdate = (update: MessageUpdate): update is MessageToolUpdate =>\n\tupdate.type === MessageUpdateType.Tool;\n\nexport const isMessageToolCallUpdate = (update: MessageUpdate): update is MessageToolCallUpdate =>\n\tisMessageToolUpdate(update) && update.subtype === MessageToolUpdateType.Call;\n\nexport const isMessageToolResultUpdate = (\n\tupdate: MessageUpdate\n): update is MessageToolResultUpdate =>\n\tisMessageToolUpdate(update) && update.subtype === MessageToolUpdateType.Result;\n\nexport const isMessageToolErrorUpdate = (update: MessageUpdate): update is MessageToolErrorUpdate =>\n\tisMessageToolUpdate(update) && update.subtype === MessageToolUpdateType.Error;\n\nexport const isMessageToolProgressUpdate = (\n\tupdate: MessageUpdate\n): update is MessageToolProgressUpdate =>\n\tisMessageToolUpdate(update) && update.subtype === MessageToolUpdateType.Progress;\n\nconst defaultSleep = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => setTimeout(resolve, ms));\nconst waitForEvent = (eventTarget: EventTarget, eventName: string) =>\n\tnew Promise<boolean>((resolve) =>\n\t\teventTarget.addEventListener(eventName, () => resolve(true), { once: true })\n\t);\n"
  },
  {
    "path": "src/lib/utils/mime.ts",
    "content": "// Lightweight MIME helpers to avoid new dependencies.\n\nconst EXTENSION_TO_MIME: Record<string, string> = {\n\tpng: \"image/png\",\n\tjpg: \"image/jpeg\",\n\tjpe: \"image/jpeg\",\n\tjpeg: \"image/jpeg\",\n\tgif: \"image/gif\",\n\twebp: \"image/webp\",\n\tsvg: \"image/svg+xml\",\n\tpdf: \"application/pdf\",\n\ttxt: \"text/plain\",\n\tcsv: \"text/csv\",\n\tjson: \"application/json\",\n\tmp3: \"audio/mpeg\",\n\twav: \"audio/wav\",\n\togg: \"audio/ogg\",\n\tmp4: \"video/mp4\",\n\tmov: \"video/quicktime\",\n\twebm: \"video/webm\",\n\tzip: \"application/zip\",\n\tgz: \"application/gzip\",\n\ttgz: \"application/gzip\",\n\ttar: \"application/x-tar\",\n\thtml: \"text/html\",\n\thtm: \"text/html\",\n\tmd: \"text/markdown\",\n};\n\nexport function guessMimeFromUrl(url: string): string | undefined {\n\ttry {\n\t\tconst pathname = new URL(url).pathname;\n\t\tconst ext = pathname.split(\".\").pop()?.toLowerCase();\n\t\tif (ext && EXTENSION_TO_MIME[ext]) return EXTENSION_TO_MIME[ext];\n\t} catch {\n\t\t/* ignore */\n\t}\n\treturn undefined;\n}\n\nexport function pickSafeMime(\n\tforwardedType: string | null,\n\tblobType: string | undefined,\n\turl: string\n): string {\n\tconst inferred = guessMimeFromUrl(url);\n\tif (forwardedType) return forwardedType;\n\tif (\n\t\tinferred &&\n\t\t(!blobType || blobType === \"application/octet-stream\" || blobType.startsWith(\"text/plain\"))\n\t) {\n\t\treturn inferred;\n\t}\n\tif (blobType) return blobType;\n\treturn inferred || \"application/octet-stream\";\n}\n"
  },
  {
    "path": "src/lib/utils/models.ts",
    "content": "import type { Model } from \"$lib/types/Model\";\n\nexport const findCurrentModel = (\n\tmodels: Model[],\n\t_oldModels: { id: string; transferTo?: string }[] = [],\n\tid?: string\n): Model => {\n\tif (id) {\n\t\tconst direct = models.find((m) => m.id === id);\n\t\tif (direct) return direct;\n\t}\n\n\treturn models[0];\n};\n"
  },
  {
    "path": "src/lib/utils/parseBlocks.ts",
    "content": "/*\n * Copyright 2023 Vercel, Inc.\n * Adapted from: https://github.com/vercel/streamdown/blob/main/packages/streamdown/lib/parse-blocks.tsx\n */\n\nimport { Lexer } from \"marked\";\n\n/**\n * Parses markdown into independent blocks for efficient memoization during streaming.\n * Blocks are split at natural boundaries while keeping related content together.\n */\nexport function parseMarkdownIntoBlocks(markdown: string): string[] {\n\t// Check if the markdown contains footnotes (references or definitions)\n\t// Footnote references: [^1], [^label], etc.\n\t// Footnote definitions: [^1]: text, [^label]: text, etc.\n\t// Use atomic groups or possessive quantifiers to prevent backtracking\n\tconst hasFootnoteReference = /\\[\\^[^\\]\\s]{1,200}\\](?!:)/.test(markdown);\n\tconst hasFootnoteDefinition = /\\[\\^[^\\]\\s]{1,200}\\]:/.test(markdown);\n\n\t// If footnotes are present, return the entire document as a single block\n\t// This ensures footnote references and definitions remain in the same mdast tree\n\tif (hasFootnoteReference || hasFootnoteDefinition) {\n\t\treturn [markdown];\n\t}\n\n\tconst tokens = Lexer.lex(markdown, { gfm: true });\n\n\t// Post-process to merge consecutive blocks that belong together\n\tconst mergedBlocks: string[] = [];\n\tconst htmlStack: string[] = []; // Track opening HTML tags\n\n\tfor (let i = 0; i < tokens.length; i++) {\n\t\tconst token = tokens[i];\n\t\tconst currentBlock = token.raw;\n\n\t\t// Check if we're inside an HTML block\n\t\tif (htmlStack.length > 0) {\n\t\t\t// We're inside an HTML block, merge with the previous block\n\t\t\tmergedBlocks[mergedBlocks.length - 1] += currentBlock;\n\n\t\t\t// Check if this token closes an HTML tag\n\t\t\tif (token.type === \"html\") {\n\t\t\t\tconst closingTagMatch = currentBlock.match(/<\\/(\\w+)>/);\n\t\t\t\tif (closingTagMatch) {\n\t\t\t\t\tconst closingTag = closingTagMatch[1];\n\t\t\t\t\t// Check if this closes the most recent opening tag\n\t\t\t\t\tif (htmlStack[htmlStack.length - 1] === closingTag) {\n\t\t\t\t\t\thtmlStack.pop();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if this is an opening HTML block tag\n\t\tif (token.type === \"html\" && token.block) {\n\t\t\tconst openingTagMatch = currentBlock.match(/<(\\w+)[\\s>]/);\n\t\t\tif (openingTagMatch) {\n\t\t\t\tconst tagName = openingTagMatch[1];\n\t\t\t\t// Check if this is a self-closing tag or if there's a closing tag in the same block\n\t\t\t\tconst hasClosingTag = currentBlock.includes(`</${tagName}>`);\n\t\t\t\tif (!hasClosingTag) {\n\t\t\t\t\t// This is an opening tag without a closing tag in the same block\n\t\t\t\t\thtmlStack.push(tagName);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Math block merging logic (existing)\n\t\t// Check if this is a standalone $$ that might be a closing delimiter\n\t\tif (currentBlock.trim() === \"$$\" && mergedBlocks.length > 0) {\n\t\t\tconst previousBlock = mergedBlocks.at(-1);\n\n\t\t\tif (!previousBlock) {\n\t\t\t\tmergedBlocks.push(currentBlock);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check if the previous block starts with $$ but doesn't end with $$\n\t\t\tconst prevStartsWith$$ = previousBlock.trimStart().startsWith(\"$$\");\n\t\t\tconst prevDollarCount = (previousBlock.match(/\\$\\$/g) || []).length;\n\n\t\t\t// If previous block has odd number of $$ and starts with $$, merge them\n\t\t\tif (prevStartsWith$$ && prevDollarCount % 2 === 1) {\n\t\t\t\tmergedBlocks[mergedBlocks.length - 1] = previousBlock + currentBlock;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\t// Check if current block ends with $$ and previous block started with $$ but didn't close\n\t\tif (mergedBlocks.length > 0 && currentBlock.trimEnd().endsWith(\"$$\")) {\n\t\t\tconst previousBlock = mergedBlocks.at(-1);\n\n\t\t\tif (!previousBlock) {\n\t\t\t\tmergedBlocks.push(currentBlock);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst prevStartsWith$$ = previousBlock.trimStart().startsWith(\"$$\");\n\t\t\tconst prevDollarCount = (previousBlock.match(/\\$\\$/g) || []).length;\n\t\t\tconst currDollarCount = (currentBlock.match(/\\$\\$/g) || []).length;\n\n\t\t\t// If previous block has unclosed math (odd $$) and current block ends with $$\n\t\t\t// AND current block doesn't start with $$, it's likely a continuation\n\t\t\tif (\n\t\t\t\tprevStartsWith$$ &&\n\t\t\t\tprevDollarCount % 2 === 1 &&\n\t\t\t\t!currentBlock.trimStart().startsWith(\"$$\") &&\n\t\t\t\tcurrDollarCount === 1\n\t\t\t) {\n\t\t\t\tmergedBlocks[mergedBlocks.length - 1] = previousBlock + currentBlock;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\tmergedBlocks.push(currentBlock);\n\t}\n\n\treturn mergedBlocks;\n}\n"
  },
  {
    "path": "src/lib/utils/parseIncompleteMarkdown.ts",
    "content": "/*\n * Copyright 2023 Vercel, Inc.\n * Source: https://github.com/vercel/streamdown/blob/main/packages/streamdown/lib/parse-incomplete-markdown.ts\n */\n\nconst linkImagePattern = /(!?\\[)([^\\]]*?)$/;\nconst boldPattern = /(\\*\\*)([^*]*?)$/;\nconst italicPattern = /(__)([^_]*?)$/;\nconst boldItalicPattern = /(\\*\\*\\*)([^*]*?)$/;\nconst singleAsteriskPattern = /(\\*)([^*]*?)$/;\nconst singleUnderscorePattern = /(_)([^_]*?)$/;\nconst inlineCodePattern = /(`)([^`]*?)$/;\nconst strikethroughPattern = /(~~)([^~]*?)$/;\n\n// Helper function to check if we have a complete code block\nconst hasCompleteCodeBlock = (text: string): boolean => {\n\tconst tripleBackticks = (text.match(/```/g) || []).length;\n\treturn tripleBackticks > 0 && tripleBackticks % 2 === 0 && text.includes(\"\\n\");\n};\n\n// Returns the start index of the currently open fenced code block, or -1 if none\nconst getOpenCodeFenceIndex = (text: string): number => {\n\tlet openFenceIndex = -1;\n\tlet inFence = false;\n\n\tfor (const match of text.matchAll(/```/g)) {\n\t\tconst index = match.index ?? -1;\n\t\tif (index === -1) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (inFence) {\n\t\t\t// This fence closes the current block\n\t\t\tinFence = false;\n\t\t\topenFenceIndex = -1;\n\t\t} else {\n\t\t\t// This fence opens a new block\n\t\t\tinFence = true;\n\t\t\topenFenceIndex = index;\n\t\t}\n\t}\n\n\treturn openFenceIndex;\n};\n\n// Handles incomplete links and images by preserving them with a special marker\nconst handleIncompleteLinksAndImages = (text: string): string => {\n\t// First check for incomplete URLs: [text](partial-url or ![text](partial-url without closing )\n\t// Pattern: !?[text](url-without-closing-paren at end of string\n\tconst incompleteLinkUrlPattern = /(!?)\\[([^\\]]+)\\]\\(([^)]+)$/;\n\tconst incompleteLinkUrlMatch = text.match(incompleteLinkUrlPattern);\n\n\tif (incompleteLinkUrlMatch) {\n\t\tconst isImage = incompleteLinkUrlMatch[1] === \"!\";\n\t\tconst linkText = incompleteLinkUrlMatch[2];\n\t\tconst partialUrl = incompleteLinkUrlMatch[3];\n\n\t\t// Find the start position of this link/image pattern\n\t\tconst matchStart = text.lastIndexOf(`${isImage ? \"!\" : \"\"}[${linkText}](${partialUrl}`);\n\t\tconst beforeLink = text.substring(0, matchStart);\n\n\t\tif (isImage) {\n\t\t\t// For images with incomplete URLs, remove them entirely\n\t\t\treturn beforeLink;\n\t\t}\n\n\t\t// For links with incomplete URLs, replace the URL with placeholder and close it\n\t\treturn `${beforeLink}[${linkText}](streamdown:incomplete-link)`;\n\t}\n\n\t// Then check for incomplete link text: [partial-text without closing ]\n\tconst linkMatch = text.match(linkImagePattern);\n\n\tif (linkMatch) {\n\t\tconst isImage = linkMatch[1].startsWith(\"!\");\n\n\t\t// For images, we still remove them as they can't show skeleton\n\t\tif (isImage) {\n\t\t\tconst startIndex = text.lastIndexOf(linkMatch[1]);\n\t\t\treturn text.substring(0, startIndex);\n\t\t}\n\n\t\t// For links, preserve the text and close the link with a\n\t\t// special placeholder URL that indicates it's incomplete\n\t\treturn `${text}](streamdown:incomplete-link)`;\n\t}\n\n\treturn text;\n};\n\n// Completes incomplete bold formatting (**)\nconst handleIncompleteBold = (text: string): string => {\n\t// Don't process if inside a complete code block\n\tif (hasCompleteCodeBlock(text)) {\n\t\treturn text;\n\t}\n\n\tconst boldMatch = text.match(boldPattern);\n\n\tif (boldMatch) {\n\t\t// Don't close if there's no meaningful content after the opening markers\n\t\t// boldMatch[2] contains the content after **\n\t\t// Check if content is only whitespace or other emphasis markers\n\t\tconst contentAfterMarker = boldMatch[2];\n\t\tif (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Check if the bold marker is in a list item context\n\t\t// Find the position of the matched bold marker\n\t\tconst markerIndex = text.lastIndexOf(boldMatch[1]);\n\n\t\t// Don't process if the marker is inside an incomplete code block\n\t\tconst openFenceIndex = getOpenCodeFenceIndex(text);\n\t\tif (openFenceIndex !== -1 && markerIndex > openFenceIndex) {\n\t\t\treturn text;\n\t\t}\n\t\tconst beforeMarker = text.substring(0, markerIndex);\n\t\tconst lastNewlineBeforeMarker = beforeMarker.lastIndexOf(\"\\n\");\n\t\tconst lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;\n\t\tconst lineBeforeMarker = text.substring(lineStart, markerIndex);\n\n\t\t// Check if this line is a list item with just the bold marker\n\t\tif (/^[\\s]*[-*+][\\s]+$/.test(lineBeforeMarker)) {\n\t\t\t// This is a list item with just emphasis markers\n\t\t\t// Check if content after marker spans multiple lines\n\t\t\tconst hasNewlineInContent = contentAfterMarker.includes(\"\\n\");\n\t\t\tif (hasNewlineInContent) {\n\t\t\t\t// Don't complete if the content spans to another line\n\t\t\t\treturn text;\n\t\t\t}\n\t\t}\n\n\t\tconst asteriskPairs = (text.match(/\\*\\*/g) || []).length;\n\t\tif (asteriskPairs % 2 === 1) {\n\t\t\treturn `${text}**`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Completes incomplete italic formatting with double underscores (__)\nconst handleIncompleteDoubleUnderscoreItalic = (text: string): string => {\n\t// Don't process if inside a complete code block\n\tif (hasCompleteCodeBlock(text)) {\n\t\treturn text;\n\t}\n\n\tconst italicMatch = text.match(italicPattern);\n\n\tif (italicMatch) {\n\t\t// Don't close if there's no meaningful content after the opening markers\n\t\t// italicMatch[2] contains the content after __\n\t\t// Check if content is only whitespace or other emphasis markers\n\t\tconst contentAfterMarker = italicMatch[2];\n\t\tif (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Check if the underscore marker is in a list item context\n\t\t// Find the position of the matched underscore marker\n\t\tconst markerIndex = text.lastIndexOf(italicMatch[1]);\n\n\t\t// Don't process if the marker is inside an incomplete code block\n\t\tconst openFenceIndex = getOpenCodeFenceIndex(text);\n\t\tif (openFenceIndex !== -1 && markerIndex > openFenceIndex) {\n\t\t\treturn text;\n\t\t}\n\t\tconst beforeMarker = text.substring(0, markerIndex);\n\t\tconst lastNewlineBeforeMarker = beforeMarker.lastIndexOf(\"\\n\");\n\t\tconst lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;\n\t\tconst lineBeforeMarker = text.substring(lineStart, markerIndex);\n\n\t\t// Check if this line is a list item with just the underscore marker\n\t\tif (/^[\\s]*[-*+][\\s]+$/.test(lineBeforeMarker)) {\n\t\t\t// This is a list item with just emphasis markers\n\t\t\t// Check if content after marker spans multiple lines\n\t\t\tconst hasNewlineInContent = contentAfterMarker.includes(\"\\n\");\n\t\t\tif (hasNewlineInContent) {\n\t\t\t\t// Don't complete if the content spans to another line\n\t\t\t\treturn text;\n\t\t\t}\n\t\t}\n\n\t\tconst underscorePairs = (text.match(/__/g) || []).length;\n\t\tif (underscorePairs % 2 === 1) {\n\t\t\treturn `${text}__`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Counts single asterisks that are not part of double asterisks, not escaped, and not list markers\nconst countSingleAsterisks = (text: string): number => {\n\treturn text.split(\"\").reduce((acc, char, index) => {\n\t\tif (char === \"*\") {\n\t\t\tconst prevChar = text[index - 1];\n\t\t\tconst nextChar = text[index + 1];\n\t\t\t// Skip if escaped with backslash\n\t\t\tif (prevChar === \"\\\\\") {\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\t// Check if this is a list marker (asterisk at start of line followed by space)\n\t\t\t// Look backwards to find the start of the current line\n\t\t\tlet lineStartIndex = index;\n\t\t\tfor (let i = index - 1; i >= 0; i--) {\n\t\t\t\tif (text[i] === \"\\n\") {\n\t\t\t\t\tlineStartIndex = i + 1;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (i === 0) {\n\t\t\t\t\tlineStartIndex = 0;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Check if this asterisk is at the beginning of a line (with optional whitespace)\n\t\t\tconst beforeAsterisk = text.substring(lineStartIndex, index);\n\t\t\tif (beforeAsterisk.trim() === \"\" && (nextChar === \" \" || nextChar === \"\\t\")) {\n\t\t\t\t// This is likely a list marker, don't count it\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\tif (prevChar !== \"*\" && nextChar !== \"*\") {\n\t\t\t\treturn acc + 1;\n\t\t\t}\n\t\t}\n\t\treturn acc;\n\t}, 0);\n};\n\n// Completes incomplete italic formatting with single asterisks (*)\nconst handleIncompleteSingleAsteriskItalic = (text: string): string => {\n\t// Don't process if inside a complete code block\n\tif (hasCompleteCodeBlock(text)) {\n\t\treturn text;\n\t}\n\n\tconst singleAsteriskMatch = text.match(singleAsteriskPattern);\n\n\tif (singleAsteriskMatch) {\n\t\t// Find the first single asterisk position (not part of **)\n\t\tlet firstSingleAsteriskIndex = -1;\n\t\tfor (let i = 0; i < text.length; i++) {\n\t\t\tif (text[i] === \"*\" && text[i - 1] !== \"*\" && text[i + 1] !== \"*\") {\n\t\t\t\tfirstSingleAsteriskIndex = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (firstSingleAsteriskIndex === -1) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Don't process if the marker is inside an incomplete code block\n\t\tconst openFenceIndex = getOpenCodeFenceIndex(text);\n\t\tif (openFenceIndex !== -1 && firstSingleAsteriskIndex > openFenceIndex) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Get content after the first single asterisk\n\t\tconst contentAfterFirstAsterisk = text.substring(firstSingleAsteriskIndex + 1);\n\n\t\t// Check if there's meaningful content after the asterisk\n\t\t// Don't close if content is only whitespace or emphasis markers\n\t\tif (!contentAfterFirstAsterisk || /^[\\s_~*`]*$/.test(contentAfterFirstAsterisk)) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst singleAsterisks = countSingleAsterisks(text);\n\t\tif (singleAsterisks % 2 === 1) {\n\t\t\treturn `${text}*`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Check if a position is within a math block (between $ or $$)\nconst isWithinMathBlock = (text: string, position: number): boolean => {\n\t// Count dollar signs before this position\n\tlet inInlineMath = false;\n\tlet inBlockMath = false;\n\n\tfor (let i = 0; i < text.length && i < position; i++) {\n\t\t// Skip escaped dollar signs\n\t\tif (text[i] === \"\\\\\" && text[i + 1] === \"$\") {\n\t\t\ti++; // Skip the next character\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (text[i] === \"$\") {\n\t\t\t// Check for block math ($$)\n\t\t\tif (text[i + 1] === \"$\") {\n\t\t\t\tinBlockMath = !inBlockMath;\n\t\t\t\ti++; // Skip the second $\n\t\t\t\tinInlineMath = false; // Block math takes precedence\n\t\t\t} else if (!inBlockMath) {\n\t\t\t\t// Only toggle inline math if not in block math\n\t\t\t\tinInlineMath = !inInlineMath;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn inInlineMath || inBlockMath;\n};\n\n// Counts single underscores that are not part of double underscores, not escaped, and not in math blocks\nconst countSingleUnderscores = (text: string): number => {\n\treturn text.split(\"\").reduce((acc, char, index) => {\n\t\tif (char === \"_\") {\n\t\t\tconst prevChar = text[index - 1];\n\t\t\tconst nextChar = text[index + 1];\n\t\t\t// Skip if escaped with backslash\n\t\t\tif (prevChar === \"\\\\\") {\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\t// Skip if within math block\n\t\t\tif (isWithinMathBlock(text, index)) {\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\t// Skip if underscore is word-internal (between word characters)\n\t\t\tif (\n\t\t\t\tprevChar &&\n\t\t\t\tnextChar &&\n\t\t\t\t/[\\p{L}\\p{N}_]/u.test(prevChar) &&\n\t\t\t\t/[\\p{L}\\p{N}_]/u.test(nextChar)\n\t\t\t) {\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\tif (prevChar !== \"_\" && nextChar !== \"_\") {\n\t\t\t\treturn acc + 1;\n\t\t\t}\n\t\t}\n\t\treturn acc;\n\t}, 0);\n};\n\n// Completes incomplete italic formatting with single underscores (_)\nconst handleIncompleteSingleUnderscoreItalic = (text: string): string => {\n\t// Don't process if inside a complete code block\n\tif (hasCompleteCodeBlock(text)) {\n\t\treturn text;\n\t}\n\n\tconst singleUnderscoreMatch = text.match(singleUnderscorePattern);\n\n\tif (singleUnderscoreMatch) {\n\t\t// Find the first single underscore position (not part of __ and not word-internal)\n\t\tlet firstSingleUnderscoreIndex = -1;\n\t\tfor (let i = 0; i < text.length; i++) {\n\t\t\tif (\n\t\t\t\ttext[i] === \"_\" &&\n\t\t\t\ttext[i - 1] !== \"_\" &&\n\t\t\t\ttext[i + 1] !== \"_\" &&\n\t\t\t\ttext[i - 1] !== \"\\\\\" &&\n\t\t\t\t!isWithinMathBlock(text, i)\n\t\t\t) {\n\t\t\t\t// Check if underscore is word-internal (between word characters)\n\t\t\t\tconst prevChar = i > 0 ? text[i - 1] : \"\";\n\t\t\t\tconst nextChar = i < text.length - 1 ? text[i + 1] : \"\";\n\t\t\t\tif (\n\t\t\t\t\tprevChar &&\n\t\t\t\t\tnextChar &&\n\t\t\t\t\t/[\\p{L}\\p{N}_]/u.test(prevChar) &&\n\t\t\t\t\t/[\\p{L}\\p{N}_]/u.test(nextChar)\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tfirstSingleUnderscoreIndex = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (firstSingleUnderscoreIndex === -1) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Don't process if the marker is inside an incomplete code block\n\t\tconst openFenceIndex = getOpenCodeFenceIndex(text);\n\t\tif (openFenceIndex !== -1 && firstSingleUnderscoreIndex > openFenceIndex) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Get content after the first single underscore\n\t\tconst contentAfterFirstUnderscore = text.substring(firstSingleUnderscoreIndex + 1);\n\n\t\t// Check if there's meaningful content after the underscore\n\t\t// Don't close if content is only whitespace or emphasis markers\n\t\tif (!contentAfterFirstUnderscore || /^[\\s_~*`]*$/.test(contentAfterFirstUnderscore)) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst singleUnderscores = countSingleUnderscores(text);\n\t\tif (singleUnderscores % 2 === 1) {\n\t\t\t// If text ends with newline(s), insert underscore before them\n\t\t\tconst trailingNewlineMatch = text.match(/\\n+$/);\n\t\t\tif (trailingNewlineMatch) {\n\t\t\t\tconst textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length);\n\t\t\t\treturn `${textBeforeNewlines}_${trailingNewlineMatch[0]}`;\n\t\t\t}\n\t\t\treturn `${text}_`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Checks if a backtick at position i is part of a triple backtick sequence\nconst isPartOfTripleBacktick = (text: string, i: number): boolean => {\n\tconst isTripleStart = text.substring(i, i + 3) === \"```\";\n\tconst isTripleMiddle = i > 0 && text.substring(i - 1, i + 2) === \"```\";\n\tconst isTripleEnd = i > 1 && text.substring(i - 2, i + 1) === \"```\";\n\n\treturn isTripleStart || isTripleMiddle || isTripleEnd;\n};\n\n// Counts single backticks that are not part of triple backticks\nconst countSingleBackticks = (text: string): number => {\n\tlet count = 0;\n\tfor (let i = 0; i < text.length; i++) {\n\t\tif (text[i] === \"`\" && !isPartOfTripleBacktick(text, i)) {\n\t\t\tcount++;\n\t\t}\n\t}\n\treturn count;\n};\n\n// Completes incomplete inline code formatting (`)\n// Avoids completing if inside an incomplete code block\nconst handleIncompleteInlineCode = (text: string): string => {\n\t// Check if we have inline triple backticks (starts with ``` and should end with ```)\n\t// This pattern should ONLY match truly inline code (no newlines)\n\t// Examples: ```code``` or ```python code```\n\tconst inlineTripleBacktickMatch = text.match(/^```[^`\\n]*```?$/);\n\tif (inlineTripleBacktickMatch && !text.includes(\"\\n\")) {\n\t\t// Check if it ends with exactly 2 backticks (incomplete)\n\t\tif (text.endsWith(\"``\") && !text.endsWith(\"```\")) {\n\t\t\treturn `${text}\\``;\n\t\t}\n\t\t// Already complete inline triple backticks\n\t\treturn text;\n\t}\n\n\t// Check if we're inside a multi-line code block (complete or incomplete)\n\tconst allTripleBackticks = (text.match(/```/g) || []).length;\n\tconst insideIncompleteCodeBlock = allTripleBackticks % 2 === 1;\n\n\t// Don't modify text if we have complete multi-line code blocks (even pairs of ```)\n\tif (allTripleBackticks > 0 && allTripleBackticks % 2 === 0 && text.includes(\"\\n\")) {\n\t\t// We have complete multi-line code blocks, don't add any backticks\n\t\treturn text;\n\t}\n\n\t// Special case: if text ends with ```\\n (triple backticks followed by newline)\n\t// This is actually a complete code block, not incomplete\n\tif (text.endsWith(\"```\\n\") || text.endsWith(\"```\")) {\n\t\t// Count all triple backticks - if even, it's complete\n\t\tif (allTripleBackticks % 2 === 0) {\n\t\t\treturn text;\n\t\t}\n\t}\n\n\tconst inlineCodeMatch = text.match(inlineCodePattern);\n\n\tif (inlineCodeMatch && !insideIncompleteCodeBlock) {\n\t\t// Don't close if there's no meaningful content after the opening marker\n\t\t// inlineCodeMatch[2] contains the content after `\n\t\t// Check if content is only whitespace or other emphasis markers\n\t\tconst contentAfterMarker = inlineCodeMatch[2];\n\t\tif (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst singleBacktickCount = countSingleBackticks(text);\n\t\tif (singleBacktickCount % 2 === 1) {\n\t\t\treturn `${text}\\``;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Completes incomplete strikethrough formatting (~~)\nconst handleIncompleteStrikethrough = (text: string): string => {\n\tconst strikethroughMatch = text.match(strikethroughPattern);\n\n\tif (strikethroughMatch) {\n\t\t// Don't close if there's no meaningful content after the opening markers\n\t\t// strikethroughMatch[2] contains the content after ~~\n\t\t// Check if content is only whitespace or other emphasis markers\n\t\tconst contentAfterMarker = strikethroughMatch[2];\n\t\tif (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst tildePairs = (text.match(/~~/g) || []).length;\n\t\tif (tildePairs % 2 === 1) {\n\t\t\treturn `${text}~~`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Counts single dollar signs that are not part of double dollar signs and not escaped\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst _countSingleDollarSigns = (text: string): number => {\n\treturn text.split(\"\").reduce((acc, char, index) => {\n\t\tif (char === \"$\") {\n\t\t\tconst prevChar = text[index - 1];\n\t\t\tconst nextChar = text[index + 1];\n\t\t\t// Skip if escaped with backslash\n\t\t\tif (prevChar === \"\\\\\") {\n\t\t\t\treturn acc;\n\t\t\t}\n\t\t\tif (prevChar !== \"$\" && nextChar !== \"$\") {\n\t\t\t\treturn acc + 1;\n\t\t\t}\n\t\t}\n\t\treturn acc;\n\t}, 0);\n};\n\n// Completes incomplete block KaTeX formatting ($$)\nconst handleIncompleteBlockKatex = (text: string): string => {\n\t// Count all $$ pairs in the text\n\tconst dollarPairs = (text.match(/\\$\\$/g) || []).length;\n\n\t// If we have an even number of $$, the block is complete\n\tif (dollarPairs % 2 === 0) {\n\t\treturn text;\n\t}\n\n\t// If we have an odd number, add closing $$\n\t// Check if this looks like a multi-line math block (contains newlines after opening $$)\n\tconst firstDollarIndex = text.indexOf(\"$$\");\n\tconst hasNewlineAfterStart =\n\t\tfirstDollarIndex !== -1 && text.indexOf(\"\\n\", firstDollarIndex) !== -1;\n\n\t// For multi-line blocks, add newline before closing $$ if not present\n\tif (hasNewlineAfterStart && !text.endsWith(\"\\n\")) {\n\t\treturn `${text}\\n$$`;\n\t}\n\n\t// For inline blocks or when already ending with newline, just add $$\n\treturn `${text}$$`;\n};\n\n// Counts triple asterisks that are not part of quadruple or more asterisks\nconst countTripleAsterisks = (text: string): number => {\n\tlet count = 0;\n\tconst matches = text.match(/\\*+/g) || [];\n\n\tfor (const match of matches) {\n\t\t// Count how many complete triple asterisks are in this sequence\n\t\tconst asteriskCount = match.length;\n\t\tif (asteriskCount >= 3) {\n\t\t\t// Each group of exactly 3 asterisks counts as one triple asterisk marker\n\t\t\tcount += Math.floor(asteriskCount / 3);\n\t\t}\n\t}\n\n\treturn count;\n};\n\n// Completes incomplete bold-italic formatting (***)\nconst handleIncompleteBoldItalic = (text: string): string => {\n\t// Don't process if inside a complete code block\n\tif (hasCompleteCodeBlock(text)) {\n\t\treturn text;\n\t}\n\n\t// Don't process if text is only asterisks and has 4 or more consecutive asterisks\n\t// This prevents cases like **** from being treated as incomplete ***\n\tif (/^\\*{4,}$/.test(text)) {\n\t\treturn text;\n\t}\n\n\tconst boldItalicMatch = text.match(boldItalicPattern);\n\n\tif (boldItalicMatch) {\n\t\t// Don't close if there's no meaningful content after the opening markers\n\t\t// boldItalicMatch[2] contains the content after ***\n\t\t// Check if content is only whitespace or other emphasis markers\n\t\tconst contentAfterMarker = boldItalicMatch[2];\n\t\tif (!contentAfterMarker || /^[\\s_~*`]*$/.test(contentAfterMarker)) {\n\t\t\treturn text;\n\t\t}\n\n\t\t// Find the position of the matched bold-italic marker\n\t\tconst markerIndex = text.lastIndexOf(boldItalicMatch[1]);\n\n\t\t// Don't process if the marker is inside an incomplete code block\n\t\tconst openFenceIndex = getOpenCodeFenceIndex(text);\n\t\tif (openFenceIndex !== -1 && markerIndex > openFenceIndex) {\n\t\t\treturn text;\n\t\t}\n\n\t\tconst tripleAsteriskCount = countTripleAsterisks(text);\n\t\tif (tripleAsteriskCount % 2 === 1) {\n\t\t\treturn `${text}***`;\n\t\t}\n\t}\n\n\treturn text;\n};\n\n// Parses markdown text and removes incomplete tokens to prevent partial rendering\nexport const parseIncompleteMarkdown = (text: string): string => {\n\tif (!text || typeof text !== \"string\") {\n\t\treturn text;\n\t}\n\n\tlet result = text;\n\n\t// Handle incomplete links and images first\n\tconst processedResult = handleIncompleteLinksAndImages(result);\n\n\t// If we added an incomplete link marker, don't process other formatting\n\t// as the content inside the link should be preserved as-is\n\tif (processedResult.endsWith(\"](streamdown:incomplete-link)\")) {\n\t\treturn processedResult;\n\t}\n\n\tresult = processedResult;\n\n\t// Handle various formatting completions\n\t// Handle triple asterisks first (most specific)\n\tresult = handleIncompleteBoldItalic(result);\n\tresult = handleIncompleteBold(result);\n\tresult = handleIncompleteDoubleUnderscoreItalic(result);\n\tresult = handleIncompleteSingleAsteriskItalic(result);\n\tresult = handleIncompleteSingleUnderscoreItalic(result);\n\tresult = handleIncompleteInlineCode(result);\n\tresult = handleIncompleteStrikethrough(result);\n\n\t// Handle KaTeX formatting (only block math with $$)\n\tresult = handleIncompleteBlockKatex(result);\n\t// Note: We don't handle inline KaTeX with single $ as they're likely currency symbols\n\n\treturn result;\n};\n"
  },
  {
    "path": "src/lib/utils/parseStringToList.ts",
    "content": "export function parseStringToList(links: unknown): string[] {\n\tif (typeof links !== \"string\") {\n\t\tthrow new Error(\"Expected a string\");\n\t}\n\n\treturn links\n\t\t.split(\",\")\n\t\t.map((link) => link.trim())\n\t\t.filter((link) => link.length > 0);\n}\n"
  },
  {
    "path": "src/lib/utils/randomUuid.ts",
    "content": "type UUID = ReturnType<typeof crypto.randomUUID>;\n\nexport function randomUUID(): UUID {\n\t// Only on old safari / ios\n\tif (!(\"randomUUID\" in crypto)) {\n\t\treturn \"10000000-1000-4000-8000-100000000000\".replace(/[018]/g, (c) =>\n\t\t\t(\n\t\t\t\tNumber(c) ^\n\t\t\t\t(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (Number(c) / 4)))\n\t\t\t).toString(16)\n\t\t) as UUID;\n\t}\n\treturn crypto.randomUUID();\n}\n"
  },
  {
    "path": "src/lib/utils/searchTokens.ts",
    "content": "const PUNCTUATION_REGEX = /\\p{P}/gu;\n\nfunction removeDiacritics(s: string, form: \"NFD\" | \"NFKD\" = \"NFD\"): string {\n\treturn s.normalize(form).replace(/[\\u0300-\\u036f]/g, \"\");\n}\n\nexport function generateSearchTokens(value: string): string[] {\n\tconst fullTitleToken = removeDiacritics(value)\n\t\t.replace(PUNCTUATION_REGEX, \"\")\n\t\t.replaceAll(/\\s+/g, \"\")\n\t\t.toLowerCase();\n\treturn [\n\t\t...new Set([\n\t\t\t...removeDiacritics(value)\n\t\t\t\t.split(/\\s+/)\n\t\t\t\t.map((word) => word.replace(PUNCTUATION_REGEX, \"\").toLowerCase())\n\t\t\t\t.filter((word) => word.length),\n\t\t\t...(fullTitleToken.length ? [fullTitleToken] : []),\n\t\t]),\n\t];\n}\n\nfunction escapeForRegExp(s: string): string {\n\treturn s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"); // $& means the whole matched string\n}\n\nexport function generateQueryTokens(query: string): RegExp[] {\n\treturn removeDiacritics(query)\n\t\t.split(/\\s+/)\n\t\t.map((word) => word.replace(PUNCTUATION_REGEX, \"\").toLowerCase())\n\t\t.filter((word) => word.length)\n\t\t.map((token) => new RegExp(`^${escapeForRegExp(token)}`));\n}\n"
  },
  {
    "path": "src/lib/utils/sha256.ts",
    "content": "export async function sha256(input: string): Promise<string> {\n\tconst utf8 = new TextEncoder().encode(input);\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", utf8);\n\tconst hashArray = Array.from(new Uint8Array(hashBuffer));\n\tconst hashHex = hashArray.map((bytes) => bytes.toString(16).padStart(2, \"0\")).join(\"\");\n\treturn hashHex;\n}\n"
  },
  {
    "path": "src/lib/utils/stringifyError.ts",
    "content": "/** Takes an unknown error and attempts to convert it to a string */\nexport function stringifyError(error: unknown): string {\n\tif (error instanceof Error) return error.message;\n\tif (typeof error === \"string\") return error;\n\tif (typeof error === \"object\" && error !== null) {\n\t\t// try a few common properties\n\t\tif (\"message\" in error && typeof error.message === \"string\") return error.message;\n\t\tif (\"body\" in error && typeof error.body === \"string\") return error.body;\n\t\tif (\"name\" in error && typeof error.name === \"string\") return error.name;\n\t}\n\treturn \"Unknown error\";\n}\n"
  },
  {
    "path": "src/lib/utils/sum.ts",
    "content": "export function sum(nums: number[]): number {\n\treturn nums.reduce((a, b) => a + b, 0);\n}\n"
  },
  {
    "path": "src/lib/utils/template.spec.ts",
    "content": "import { describe, test, expect } from \"vitest\";\nimport { compileTemplate } from \"./template\";\n\n// Test data for simple templates\nconst modelData = {\n\tpreprompt: \"Hello\",\n};\n\nconst simpleTemplate = \"Test: {{preprompt}} and {{foo}}\";\n\n// Additional realistic test data for Llama 70B templates\nconst messages = [\n\t{ from: \"user\", content: \"Hello there\" },\n\t{ from: \"assistant\", content: \"Hi, how can I help?\" },\n];\n\n// Handlebars Llama 70B Template\nconst llama70bTemplateHB = `<s>{{#if preprompt}}Source: system\\n\\n{{preprompt}}<step>{{/if}}{{#each messages}}{{#ifUser}}Source: user\\n\\n{{content}}<step>{{/ifUser}}{{#ifAssistant}}Source: assistant\\n\\n{{content}}<step>{{/ifAssistant}}{{/each}}Source: assistant\\nDestination: user\\n\\n`;\n\n// Expected output for Handlebars Llama 70B Template\nconst expectedHB =\n\t\"<s>Source: system\\n\\nSystem Message<step>Source: user\\n\\nHello there<step>Source: assistant\\n\\nHi, how can I help?<step>Source: assistant\\nDestination: user\\n\\n\";\n\n// Jinja Llama 70B Template\nconst llama70bTemplateJinja = `<s>{% if preprompt %}Source: system\\n\\n{{ preprompt }}<step>{% endif %}{% for message in messages %}{% if message.from == 'user' %}Source: user\\n\\n{{ message.content }}<step>{% elif message.from == 'assistant' %}Source: assistant\\n\\n{{ message.content }}<step>{% endif %}{% endfor %}Source: assistant\\nDestination: user\\n\\n`;\n\n// Expected output for Jinja Llama 70B Template\nconst expectedJinja =\n\t\"<s>Source: system\\n\\nSystem Message<step>Source: user\\n\\nHello there<step>Source: assistant\\n\\nHi, how can I help?<step>Source: assistant\\nDestination: user\\n\\n\";\n\ndescribe(\"Template Engine Rendering\", () => {\n\ttest(\"should render using Handlebars fallback when no templateEngine is specified\", () => {\n\t\tconst render = compileTemplate(simpleTemplate, modelData);\n\t\tconst result = render({ foo: \"World\" });\n\t\texpect(result).toBe(\"Test: Hello and World\");\n\t});\n\n\ttest('should render using Jinja when templateEngine is set to \"jinja\"', () => {\n\t\tconst render = compileTemplate(simpleTemplate, modelData);\n\t\tconst result = render({ foo: \"World\" });\n\t\texpect(result).toBe(\"Test: Hello and World\");\n\t});\n\n\t// Realistic Llama 70B template tests\n\ttest(\"should render realistic Llama 70B template using Handlebars\", () => {\n\t\tconst render = compileTemplate(llama70bTemplateHB, { preprompt: \"System Message\" });\n\t\tconst result = render({ messages });\n\t\texpect(result).toBe(expectedHB);\n\t});\n\n\ttest(\"should render realistic Llama 70B template using Jinja\", () => {\n\t\tconst render = compileTemplate(llama70bTemplateJinja, {\n\t\t\tpreprompt: \"System Message\",\n\t\t});\n\t\tconst result = render({ messages });\n\t\t// Trim both outputs to account for whitespace differences in Jinja engine\n\t\texpect(result.trim()).toBe(expectedJinja.trim());\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/template.ts",
    "content": "import type { Message } from \"$lib/types/Message\";\nimport Handlebars from \"handlebars\";\nimport { Template } from \"@huggingface/jinja\";\nimport { logger } from \"$lib/server/logger\";\n\n// Register Handlebars helpers\nHandlebars.registerHelper(\"ifUser\", function (this: Pick<Message, \"from\" | \"content\">, options) {\n\tif (this.from == \"user\") return options.fn(this);\n});\n\nHandlebars.registerHelper(\n\t\"ifAssistant\",\n\tfunction (this: Pick<Message, \"from\" | \"content\">, options) {\n\t\tif (this.from == \"assistant\") return options.fn(this);\n\t}\n);\n\n// Updated compileTemplate to try Jinja and fallback to Handlebars if Jinja fails\nexport function compileTemplate<T>(\n\tinput: string,\n\tmodel: { preprompt: string; templateEngine?: string }\n) {\n\tlet jinjaTemplate: Template | undefined;\n\ttry {\n\t\t// Try to compile with Jinja\n\t\tjinjaTemplate = new Template(input);\n\t} catch (e) {\n\t\t// logger.error(e, \"Could not compile with Jinja\");\n\t\t// Could not compile with Jinja\n\t\tjinjaTemplate = undefined;\n\t}\n\n\tconst hbTemplate = Handlebars.compile<T>(input, {\n\t\tknownHelpers: { ifUser: true, ifAssistant: true },\n\t\tknownHelpersOnly: true,\n\t\tnoEscape: true,\n\t\tstrict: true,\n\t\tpreventIndent: true,\n\t});\n\n\treturn function render(inputs: T) {\n\t\tif (jinjaTemplate) {\n\t\t\ttry {\n\t\t\t\treturn jinjaTemplate.render({ ...model, ...inputs });\n\t\t\t} catch (e) {\n\t\t\t\tlogger.error(e, \"Could not render with Jinja\");\n\t\t\t\t// Fallback to Handlebars if Jinja rendering fails\n\t\t\t\treturn hbTemplate({ ...model, ...inputs });\n\t\t\t}\n\t\t}\n\t\treturn hbTemplate({ ...model, ...inputs });\n\t};\n}\n"
  },
  {
    "path": "src/lib/utils/timeout.ts",
    "content": "export const timeout = <T>(prom: Promise<T>, time: number): Promise<T> => {\n\tlet timer: NodeJS.Timeout;\n\treturn Promise.race([\n\t\tprom,\n\t\tnew Promise<T>((_, reject) => {\n\t\t\ttimer = setTimeout(() => reject(new Error(`Timeout after ${time / 1000} seconds`)), time);\n\t\t}),\n\t]).finally(() => clearTimeout(timer));\n};\n"
  },
  {
    "path": "src/lib/utils/toolProgress.spec.ts",
    "content": "import { describe, expect, test } from \"vitest\";\n\nimport { MessageToolUpdateType, MessageUpdateType } from \"$lib/types/MessageUpdate\";\nimport { formatToolProgressLabel } from \"./toolProgress\";\n\ndescribe(\"formatToolProgressLabel\", () => {\n\ttest(\"returns empty string when progress is missing\", () => {\n\t\texpect(formatToolProgressLabel(undefined)).toBe(\"\");\n\t});\n\n\ttest(\"formats progress with message\", () => {\n\t\texpect(\n\t\t\tformatToolProgressLabel({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Progress,\n\t\t\t\tuuid: \"tool-1\",\n\t\t\t\tprogress: 3,\n\t\t\t\ttotal: 10,\n\t\t\t\tmessage: \"Indexing\",\n\t\t\t})\n\t\t).toBe(\"Indexing (3/10)\");\n\t});\n\n\ttest(\"formats progress without message\", () => {\n\t\texpect(\n\t\t\tformatToolProgressLabel({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Progress,\n\t\t\t\tuuid: \"tool-2\",\n\t\t\t\tprogress: 7,\n\t\t\t})\n\t\t).toBe(\"Progress: 7\");\n\t});\n\n\ttest(\"formats progress with message and no total\", () => {\n\t\texpect(\n\t\t\tformatToolProgressLabel({\n\t\t\t\ttype: MessageUpdateType.Tool,\n\t\t\t\tsubtype: MessageToolUpdateType.Progress,\n\t\t\t\tuuid: \"tool-3\",\n\t\t\t\tprogress: 12,\n\t\t\t\tmessage: \"ZeroGPU Initializing xxx\",\n\t\t\t})\n\t\t).toBe(\"ZeroGPU Initializing xxx (12)\");\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/toolProgress.ts",
    "content": "import type { MessageToolProgressUpdate } from \"$lib/types/MessageUpdate\";\n\nexport function formatToolProgressLabel(progress?: MessageToolProgressUpdate): string {\n\tif (!progress) return \"\";\n\tconst total = typeof progress.total === \"number\" ? `/${progress.total}` : \"\";\n\tconst value = `${progress.progress}${total}`;\n\tif (progress.message && progress.message.trim().length > 0) {\n\t\treturn `${progress.message} (${value})`;\n\t}\n\treturn `Progress: ${value}`;\n}\n"
  },
  {
    "path": "src/lib/utils/tree/addChildren.spec.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { insertLegacyConversation, insertSideBranchesConversation } from \"./treeHelpers.spec\";\nimport { addChildren } from \"./addChildren\";\nimport type { Message } from \"$lib/types/Message\";\n\nconst newMessage: Omit<Message, \"id\"> = {\n\tcontent: \"new message\",\n\tfrom: \"user\",\n};\n\nObject.freeze(newMessage);\n\ndescribe(\"addChildren\", async () => {\n\tit(\"should let you append on legacy conversations\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst convLength = conv.messages.length;\n\n\t\taddChildren(conv, newMessage, conv.messages[conv.messages.length - 1].id);\n\t\texpect(conv.messages.length).toEqual(convLength + 1);\n\t});\n\tit(\"should not let you create branches on legacy conversations\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\texpect(() => addChildren(conv, newMessage, conv.messages[0].id)).toThrow();\n\t});\n\tit(\"should not let you create a message that already exists\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst messageThatAlreadyExists: Message = {\n\t\t\tid: conv.messages[0].id,\n\t\t\tcontent: \"new message\",\n\t\t\tfrom: \"user\",\n\t\t};\n\n\t\texpect(() => addChildren(conv, messageThatAlreadyExists, conv.messages[0].id)).toThrow();\n\t});\n\tit(\"should let you create branches on conversations with subtrees\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst nChildren = conv.messages[0].children?.length;\n\t\tif (!nChildren) throw new Error(\"No children found\");\n\t\taddChildren(conv, newMessage, conv.messages[0].id);\n\t\texpect(conv.messages[0].children?.length).toEqual(nChildren + 1);\n\t});\n\n\tit(\"should let you create a new leaf\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst parentId = conv.messages[conv.messages.length - 1].id;\n\t\tconst nChildren = conv.messages[conv.messages.length - 1].children?.length;\n\n\t\tif (nChildren === undefined) throw new Error(\"No children found\");\n\t\texpect(nChildren).toEqual(0);\n\n\t\taddChildren(conv, newMessage, parentId);\n\t\texpect(conv.messages[conv.messages.length - 2].children?.length).toEqual(nChildren + 1);\n\t});\n\n\tit(\"should let you append to an empty conversation without specifying a parentId\", async () => {\n\t\tconst conv = {\n\t\t\t_id: new ObjectId(),\n\t\t\trootMessageId: undefined,\n\t\t\tmessages: [] as Message[],\n\t\t};\n\n\t\taddChildren(conv, newMessage);\n\t\texpect(conv.messages.length).toEqual(1);\n\t\texpect(conv.rootMessageId).toEqual(conv.messages[0].id);\n\t});\n\n\tit(\"should throw if you don't specify a parentId in a conversation with messages\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\texpect(() => addChildren(conv, newMessage)).toThrow();\n\t});\n\n\tit(\"should return the id of the new message\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\texpect(addChildren(conv, newMessage, conv.messages[conv.messages.length - 1].id)).toEqual(\n\t\t\tconv.messages[conv.messages.length - 1].id\n\t\t);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/tree/addChildren.ts",
    "content": "import { v4 } from \"uuid\";\nimport type { Tree, TreeId, NewNode, TreeNode } from \"./tree\";\n\nexport function addChildren<T>(conv: Tree<T>, message: NewNode<T>, parentId?: TreeId): TreeId {\n\t// if this is the first message we just push it\n\tif (conv.messages.length === 0) {\n\t\tconst messageId = v4();\n\t\tconv.rootMessageId = messageId;\n\t\tconv.messages.push({\n\t\t\t...message,\n\t\t\tancestors: [],\n\t\t\tid: messageId,\n\t\t} as TreeNode<T>);\n\t\treturn messageId;\n\t}\n\n\tif (!parentId) {\n\t\tthrow new Error(\"You need to specify a parentId if this is not the first message\");\n\t}\n\n\tconst messageId = v4();\n\tif (!conv.rootMessageId) {\n\t\t// if there is no parentId we just push the message\n\t\tif (!!parentId && parentId !== conv.messages[conv.messages.length - 1].id) {\n\t\t\tthrow new Error(\"This is a legacy conversation, you can only append to the last message\");\n\t\t}\n\t\tconv.messages.push({ ...message, id: messageId } as TreeNode<T>);\n\t\treturn messageId;\n\t}\n\n\tconst ancestors = [...(conv.messages.find((m) => m.id === parentId)?.ancestors ?? []), parentId];\n\tconv.messages.push({\n\t\t...message,\n\t\tancestors,\n\t\tid: messageId,\n\t\tchildren: [],\n\t} as TreeNode<T>);\n\n\tconst parent = conv.messages.find((m) => m.id === parentId);\n\n\tif (parent) {\n\t\tif (parent.children) {\n\t\t\tparent.children.push(messageId);\n\t\t} else parent.children = [messageId];\n\t}\n\n\treturn messageId;\n}\n"
  },
  {
    "path": "src/lib/utils/tree/addSibling.spec.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { insertLegacyConversation, insertSideBranchesConversation } from \"./treeHelpers.spec\";\nimport type { Message } from \"$lib/types/Message\";\nimport { addSibling } from \"./addSibling\";\nimport type { Conversation } from \"$lib/types/Conversation\";\n\nconst newMessage = {\n\tcontent: \"new message\",\n\tfrom: \"user\" as const,\n};\n\nObject.freeze(newMessage);\n\ndescribe(\"addSibling\", async () => {\n\tit(\"should fail on empty conversations\", () => {\n\t\tconst conv = {\n\t\t\t_id: new ObjectId(),\n\t\t\trootMessageId: undefined,\n\t\t\tmessages: [] as Message[],\n\t\t} satisfies Pick<Conversation, \"_id\" | \"rootMessageId\" | \"messages\">;\n\n\t\texpect(() => addSibling(conv, newMessage, \"not-a-real-id-test\")).toThrow(\n\t\t\t\"Cannot add a sibling to an empty conversation\"\n\t\t);\n\t});\n\n\tit(\"should fail on legacy conversations\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\texpect(() => addSibling(conv, newMessage, conv.messages[0].id)).toThrow(\n\t\t\t\"Cannot add a sibling to a legacy conversation\"\n\t\t);\n\t});\n\n\tit(\"should fail if the sibling message doesn't exist\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\texpect(() => addSibling(conv, newMessage, \"not-a-real-id-test\")).toThrow(\n\t\t\t\"The sibling message doesn't exist\"\n\t\t);\n\t});\n\n\t// TODO: This behaviour should be fixed, we do not need to fail on the root message.\n\tit(\"should fail if the sibling message is the root message\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\t\tif (!conv.rootMessageId) throw new Error(\"Root message not found\");\n\n\t\texpect(() => addSibling(conv, newMessage, conv.rootMessageId as Message[\"id\"])).toThrow(\n\t\t\t\"The sibling message is the root message, therefore we can't add a sibling\"\n\t\t);\n\t});\n\n\tit(\"should add a sibling to a message\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\t// add sibling and check children count for parnets\n\n\t\tconst nChildren = conv.messages[1].children?.length;\n\t\tconst siblingId = addSibling(conv, newMessage, conv.messages[2].id);\n\t\tconst nChildrenNew = conv.messages[1].children?.length;\n\n\t\tif (!nChildren) throw new Error(\"No children found\");\n\n\t\texpect(nChildrenNew).toBe(nChildren + 1);\n\n\t\t// make sure siblings have the same ancestors\n\t\tconst sibling = conv.messages.find((m) => m.id === siblingId);\n\t\texpect(sibling?.ancestors).toEqual(conv.messages[2].ancestors);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/tree/addSibling.ts",
    "content": "import { v4 } from \"uuid\";\nimport type { Tree, TreeId, NewNode, TreeNode } from \"./tree\";\n\nexport function addSibling<T>(conv: Tree<T>, message: NewNode<T>, siblingId: TreeId): TreeId {\n\tif (conv.messages.length === 0) {\n\t\tthrow new Error(\"Cannot add a sibling to an empty conversation\");\n\t}\n\tif (!conv.rootMessageId) {\n\t\tthrow new Error(\"Cannot add a sibling to a legacy conversation\");\n\t}\n\n\tconst sibling = conv.messages.find((m) => m.id === siblingId);\n\n\tif (!sibling) {\n\t\tthrow new Error(\"The sibling message doesn't exist\");\n\t}\n\n\tif (!sibling.ancestors || sibling.ancestors?.length === 0) {\n\t\tthrow new Error(\"The sibling message is the root message, therefore we can't add a sibling\");\n\t}\n\n\tconst messageId = v4();\n\n\tconv.messages.push({\n\t\t...message,\n\t\tid: messageId,\n\t\tancestors: sibling.ancestors,\n\t\tchildren: [],\n\t} as TreeNode<T>);\n\n\tconst nearestAncestorId = sibling.ancestors[sibling.ancestors.length - 1];\n\tconst nearestAncestor = conv.messages.find((m) => m.id === nearestAncestorId);\n\n\tif (nearestAncestor) {\n\t\tif (nearestAncestor.children) {\n\t\t\tnearestAncestor.children.push(messageId);\n\t\t} else nearestAncestor.children = [messageId];\n\t}\n\n\treturn messageId;\n}\n"
  },
  {
    "path": "src/lib/utils/tree/buildSubtree.spec.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { describe, expect, it } from \"vitest\";\n\nimport {\n\tinsertLegacyConversation,\n\tinsertLinearBranchConversation,\n\tinsertSideBranchesConversation,\n} from \"./treeHelpers.spec\";\nimport { buildSubtree } from \"./buildSubtree\";\n\ndescribe(\"buildSubtree\", () => {\n\tit(\"a subtree in a legacy conversation should be just a slice\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\t// check middle\n\t\tconst id = conv.messages[2].id;\n\t\tconst subtree = buildSubtree(conv, id);\n\t\texpect(subtree).toEqual(conv.messages.slice(0, 3));\n\n\t\t// check zero\n\t\tconst id2 = conv.messages[0].id;\n\t\tconst subtree2 = buildSubtree(conv, id2);\n\t\texpect(subtree2).toEqual(conv.messages.slice(0, 1));\n\n\t\t//check full length\n\t\tconst id3 = conv.messages[conv.messages.length - 1].id;\n\t\tconst subtree3 = buildSubtree(conv, id3);\n\t\texpect(subtree3).toEqual(conv.messages);\n\t});\n\n\tit(\"a subtree in a linear branch conversation should be the ancestors and the message\", async () => {\n\t\tconst convId = await insertLinearBranchConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\t// check middle\n\t\tconst id = conv.messages[1].id;\n\t\tconst subtree = buildSubtree(conv, id);\n\t\texpect(subtree).toEqual([conv.messages[0], conv.messages[1]]);\n\n\t\t// check zero\n\t\tconst id2 = conv.messages[0].id;\n\t\tconst subtree2 = buildSubtree(conv, id2);\n\t\texpect(subtree2).toEqual([conv.messages[0]]);\n\n\t\t//check full length\n\t\tconst id3 = conv.messages[conv.messages.length - 1].id;\n\t\tconst subtree3 = buildSubtree(conv, id3);\n\t\texpect(subtree3).toEqual(conv.messages);\n\t});\n\n\tit(\"should throw an error if the message is not found\", async () => {\n\t\tconst convId = await insertLinearBranchConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst id = \"not-a-real-id-test\";\n\n\t\texpect(() => buildSubtree(conv, id)).toThrow(\"Message not found\");\n\t});\n\n\tit(\"should throw an error if the ancestor is not found\", async () => {\n\t\tconst convId = await insertLinearBranchConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst id = \"1-1-1-1-2\";\n\n\t\tconv.messages[1].ancestors = [\"not-a-real-id-test\"];\n\n\t\texpect(() => buildSubtree(conv, id)).toThrow(\"Ancestor not found\");\n\t});\n\n\tit(\"should work on empty conversations\", () => {\n\t\tconst conv = {\n\t\t\t_id: new ObjectId(),\n\t\t\trootMessageId: undefined,\n\t\t\tmessages: [],\n\t\t};\n\n\t\tconst subtree = buildSubtree(conv, \"not-a-real-id-test\");\n\t\texpect(subtree).toEqual([]);\n\t});\n\n\tit(\"should work for conversation with subtrees\", async () => {\n\t\tconst convId = await insertSideBranchesConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst subtree = buildSubtree(conv, \"1-1-1-1-2\");\n\t\texpect(subtree).toEqual([conv.messages[0], conv.messages[1]]);\n\n\t\tconst subtree2 = buildSubtree(conv, \"1-1-1-1-4\");\n\t\texpect(subtree2).toEqual([\n\t\t\tconv.messages[0],\n\t\t\tconv.messages[1],\n\t\t\tconv.messages[2],\n\t\t\tconv.messages[3],\n\t\t]);\n\n\t\tconst subtree3 = buildSubtree(conv, \"1-1-1-1-6\");\n\t\texpect(subtree3).toEqual([conv.messages[0], conv.messages[4], conv.messages[5]]);\n\n\t\tconst subtree4 = buildSubtree(conv, \"1-1-1-1-7\");\n\t\texpect(subtree4).toEqual([conv.messages[0], conv.messages[4], conv.messages[6]]);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/tree/buildSubtree.ts",
    "content": "import type { Tree, TreeId, TreeNode } from \"./tree\";\n\nexport function buildSubtree<T>(conv: Tree<T>, id: TreeId): TreeNode<T>[] {\n\tif (!conv.rootMessageId) {\n\t\tif (conv.messages.length === 0) return [];\n\t\t// legacy conversation slice up to id\n\t\tconst index = conv.messages.findIndex((m) => m.id === id);\n\t\tif (index === -1) throw new Error(\"Message not found\");\n\t\treturn conv.messages.slice(0, index + 1);\n\t} else {\n\t\t// find the message with the right id then create the ancestor tree\n\t\tconst message = conv.messages.find((m) => m.id === id);\n\t\tif (!message) throw new Error(\"Message not found\");\n\n\t\treturn [\n\t\t\t...(message.ancestors?.map((ancestorId) => {\n\t\t\t\tconst ancestor = conv.messages.find((m) => m.id === ancestorId);\n\t\t\t\tif (!ancestor) throw new Error(\"Ancestor not found\");\n\t\t\t\treturn ancestor;\n\t\t\t}) ?? []),\n\t\t\tmessage,\n\t\t];\n\t}\n}\n"
  },
  {
    "path": "src/lib/utils/tree/convertLegacyConversation.spec.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { convertLegacyConversation } from \"./convertLegacyConversation\";\nimport { insertLegacyConversation } from \"./treeHelpers.spec\";\n\ndescribe(\"convertLegacyConversation\", () => {\n\tit(\"should convert a legacy conversation\", async () => {\n\t\tconst convId = await insertLegacyConversation();\n\t\tconst conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });\n\t\tif (!conv) throw new Error(\"Conversation not found\");\n\n\t\tconst newConv = convertLegacyConversation(conv);\n\n\t\texpect(newConv.rootMessageId).toBe(newConv.messages[0].id);\n\t\texpect(newConv.messages[0].ancestors).toEqual([]);\n\t\texpect(newConv.messages[1].ancestors).toEqual([newConv.messages[0].id]);\n\t\texpect(newConv.messages[0].children).toEqual([newConv.messages[1].id]);\n\t});\n\tit(\"should work on empty conversations\", async () => {\n\t\tconst conv = {\n\t\t\t_id: new ObjectId(),\n\t\t\trootMessageId: undefined,\n\t\t\tmessages: [],\n\t\t};\n\t\tconst newConv = convertLegacyConversation(conv);\n\t\texpect(newConv.rootMessageId).toBe(undefined);\n\t\texpect(newConv.messages).toEqual([]);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/tree/convertLegacyConversation.ts",
    "content": "import type { Conversation } from \"$lib/types/Conversation\";\nimport type { Message } from \"$lib/types/Message\";\nimport { v4 } from \"uuid\";\n\nexport function convertLegacyConversation(\n\tconv: Pick<Conversation, \"messages\" | \"rootMessageId\" | \"preprompt\">\n): Pick<Conversation, \"messages\" | \"rootMessageId\" | \"preprompt\"> {\n\tif (conv.rootMessageId) return conv; // not a legacy conversation\n\tif (conv.messages.length === 0) return conv; // empty conversation\n\tconst messages = [\n\t\t{\n\t\t\tfrom: \"system\",\n\t\t\tcontent: conv.preprompt ?? \"\",\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tid: v4(),\n\t\t} satisfies Message,\n\t\t...conv.messages,\n\t];\n\n\tconst rootMessageId = messages[0].id;\n\n\tconst newMessages = messages.map((message, index) => {\n\t\treturn {\n\t\t\t...message,\n\t\t\tancestors: messages.slice(0, index).map((m) => m.id),\n\t\t\tchildren: index < messages.length - 1 ? [messages[index + 1].id] : [],\n\t\t};\n\t});\n\n\treturn {\n\t\t...conv,\n\t\trootMessageId,\n\t\tmessages: newMessages,\n\t};\n}\n"
  },
  {
    "path": "src/lib/utils/tree/isMessageId.spec.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { isMessageId } from \"./isMessageId\";\nimport { v4 } from \"uuid\";\n\ndescribe(\"isMessageId\", () => {\n\tit(\"should return true for a valid message id\", () => {\n\t\texpect(isMessageId(v4())).toBe(true);\n\t});\n\tit(\"should return false for an invalid message id\", () => {\n\t\texpect(isMessageId(\"1-2-3-4\")).toBe(false);\n\t});\n\tit(\"should return false for an empty string\", () => {\n\t\texpect(isMessageId(\"\")).toBe(false);\n\t});\n});\n"
  },
  {
    "path": "src/lib/utils/tree/isMessageId.ts",
    "content": "import type { Message } from \"$lib/types/Message\";\n\nexport function isMessageId(id: string): id is Message[\"id\"] {\n\treturn id.split(\"-\").length === 5;\n}\n"
  },
  {
    "path": "src/lib/utils/tree/tree.d.ts",
    "content": "export type TreeId = string;\n\nexport type Tree<T> = {\n\trootMessageId?: TreeId;\n\tmessages: TreeNode<T>[];\n};\n\nexport type TreeNode<T> = T & {\n\tid: TreeId;\n\tancestors?: TreeId[];\n\tchildren?: TreeId[];\n};\n\nexport type NewNode<T> = Omit<TreeNode<T>, \"id\">;\n"
  },
  {
    "path": "src/lib/utils/tree/treeHelpers.spec.ts",
    "content": "import { getCollectionsEarly } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { describe, expect, it } from \"vitest\";\n\n// function used to insert conversations used for testing\nconst getConversations = async () => (await getCollectionsEarly()).conversations;\n\nexport const insertLegacyConversation = async () => {\n\tconst conversations = await getConversations();\n\tconst res = await conversations.insertOne({\n\t\t_id: new ObjectId(),\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\ttitle: \"legacy conversation\",\n\t\tmodel: \"\",\n\n\t\tmessages: [\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-1\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, world! I am a user\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-2\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, world! I am an assistant.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-3\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, world! I am a user.\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-4\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, world! I am an assistant.\",\n\t\t\t},\n\t\t],\n\t});\n\treturn res.insertedId;\n};\n\nexport const insertLinearBranchConversation = async () => {\n\tconst conversations = await getConversations();\n\tconst res = await conversations.insertOne({\n\t\t_id: new ObjectId(),\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\ttitle: \"linear branch conversation\",\n\t\tmodel: \"\",\n\n\t\trootMessageId: \"1-1-1-1-1\",\n\t\tmessages: [\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-1\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, world! I am a user\",\n\t\t\t\tancestors: [],\n\t\t\t\tchildren: [\"1-1-1-1-2\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-2\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, world! I am an assistant.\",\n\t\t\t\tancestors: [\"1-1-1-1-1\"],\n\t\t\t\tchildren: [\"1-1-1-1-3\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-3\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, world! I am a user.\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-2\"],\n\t\t\t\tchildren: [\"1-1-1-1-4\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-4\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, world! I am an assistant.\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-2\", \"1-1-1-1-3\"],\n\t\t\t\tchildren: [],\n\t\t\t},\n\t\t],\n\t});\n\treturn res.insertedId;\n};\n\nexport const insertSideBranchesConversation = async () => {\n\tconst conversations = await getConversations();\n\tconst res = await conversations.insertOne({\n\t\t_id: new ObjectId(),\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\ttitle: \"side branches conversation\",\n\t\tmodel: \"\",\n\n\t\trootMessageId: \"1-1-1-1-1\",\n\t\tmessages: [\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-1\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, world, root message!\",\n\t\t\t\tancestors: [],\n\t\t\t\tchildren: [\"1-1-1-1-2\", \"1-1-1-1-5\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-2\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, response to root message!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\"],\n\t\t\t\tchildren: [\"1-1-1-1-3\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-3\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, follow up question!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-2\"],\n\t\t\t\tchildren: [\"1-1-1-1-4\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-4\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, response from follow up question!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-2\", \"1-1-1-1-3\"],\n\t\t\t\tchildren: [],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-5\",\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"Hello, alternative assistant answer!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\"],\n\t\t\t\tchildren: [\"1-1-1-1-6\", \"1-1-1-1-7\"],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-6\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, follow up question to alternative answer!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-5\"],\n\t\t\t\tchildren: [],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"1-1-1-1-7\",\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: \"Hello, alternative follow up question to alternative answer!\",\n\t\t\t\tancestors: [\"1-1-1-1-1\", \"1-1-1-1-5\"],\n\t\t\t\tchildren: [],\n\t\t\t},\n\t\t],\n\t});\n\treturn res.insertedId;\n};\n\ndescribe(\"inserting conversations\", () => {\n\tit(\"should insert a legacy conversation\", async () => {\n\t\tconst id = await insertLegacyConversation();\n\t\texpect(id).toBeDefined();\n\t}, 30000);\n\n\tit(\"should insert a linear branch conversation\", async () => {\n\t\tconst id = await insertLinearBranchConversation();\n\t\texpect(id).toBeDefined();\n\t}, 30000);\n\n\tit(\"should insert a side branches conversation\", async () => {\n\t\tconst id = await insertSideBranchesConversation();\n\t\texpect(id).toBeDefined();\n\t}, 30000);\n});\n"
  },
  {
    "path": "src/lib/utils/updates.ts",
    "content": "// This is a debouncer for the updates from the server to the client\n// It is used to prevent the client from being overloaded with too many updates\n// It works by keeping track of the time it takes to render the updates\n// and adding a safety margin to it, to find the debounce time.\n\nclass UpdateDebouncer {\n\tprivate renderStartedAt: Date | null = null;\n\tprivate lastRenderTimes: number[] = [];\n\n\tget maxUpdateTime() {\n\t\tif (this.lastRenderTimes.length === 0) {\n\t\t\treturn 50;\n\t\t}\n\n\t\tconst averageTime =\n\t\t\tthis.lastRenderTimes.reduce((acc, time) => acc + time, 0) / this.lastRenderTimes.length;\n\n\t\treturn Math.min(averageTime * 3, 500);\n\t}\n\n\tpublic startRender() {\n\t\tthis.renderStartedAt = new Date();\n\t}\n\n\tpublic endRender() {\n\t\tif (!this.renderStartedAt) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst timeSinceRenderStarted = new Date().getTime() - this.renderStartedAt.getTime();\n\t\tthis.lastRenderTimes.push(timeSinceRenderStarted);\n\t\tif (this.lastRenderTimes.length > 10) {\n\t\t\tthis.lastRenderTimes.shift();\n\t\t}\n\t\tthis.renderStartedAt = null;\n\t}\n}\n\nexport const updateDebouncer = new UpdateDebouncer();\n"
  },
  {
    "path": "src/lib/utils/urlParams.ts",
    "content": "const MAX_PARAM_LENGTH = 10_000;\n\nexport function sanitizeUrlParam(value: string | null): string | null {\n\tif (value == null) return null;\n\n\tconst trimmed = value.trim();\n\tif (!trimmed.length) return null;\n\tif (trimmed.length > MAX_PARAM_LENGTH) return null;\n\n\treturn trimmed;\n}\n\nexport { MAX_PARAM_LENGTH };\n"
  },
  {
    "path": "src/lib/workers/markdownWorker.ts",
    "content": "// Simple type to replace removed WebSearchSource\ntype SimpleSource = {\n\ttitle?: string;\n\tlink: string;\n};\nimport { processBlocks, type BlockToken } from \"$lib/utils/marked\";\n\nexport type IncomingMessage = {\n\ttype: \"process\";\n\tcontent: string;\n\tsources: SimpleSource[];\n\trequestId: number;\n};\n\nexport type OutgoingMessage = {\n\ttype: \"processed\";\n\tblocks: BlockToken[];\n\trequestId: number;\n};\n\n// Flag to track if the worker is currently processing a message\nlet isProcessing = false;\n\n// Buffer to store the latest incoming message\nlet latestMessage: IncomingMessage | null = null;\n\n// Helper function to safely handle the latest message\nasync function processMessage() {\n\tif (latestMessage) {\n\t\tconst nextMessage = latestMessage;\n\n\t\tlatestMessage = null;\n\t\tisProcessing = true;\n\n\t\ttry {\n\t\t\tconst { content, sources, requestId } = nextMessage;\n\t\t\tconst processedBlocks = await processBlocks(content, sources);\n\t\t\tpostMessage(\n\t\t\t\tJSON.parse(JSON.stringify({ type: \"processed\", blocks: processedBlocks, requestId }))\n\t\t\t);\n\t\t} finally {\n\t\t\tisProcessing = false;\n\n\t\t\t// After processing, check if a new message was buffered\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 100));\n\t\t\tprocessMessage();\n\t\t}\n\t}\n}\n\nonmessage = (event) => {\n\tif (event.data.type !== \"process\") {\n\t\treturn;\n\t}\n\n\tlatestMessage = event.data as IncomingMessage;\n\n\tif (!isProcessing && latestMessage) {\n\t\tprocessMessage();\n\t}\n};\n"
  },
  {
    "path": "src/routes/+error.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from \"$app/state\";\n</script>\n\n<div\n\tclass=\"flex items-center justify-center bg-gradient-to-t from-gray-200 text-gray-800 dark:from-gray-700 dark:text-gray-300\"\n>\n\t<div\n\t\tclass=\"align-center -mt-24 flex flex-col justify-center rounded-xl border bg-white px-8 pb-2 pt-4 text-center dark:border-gray-700 dark:bg-gray-800\"\n\t>\n\t\t<h1 class=\"mb-2 text-5xl font-semibold\">{page.status}</h1>\n\t\t<div class=\"-mx-8 my-2 h-px bg-gray-200 dark:bg-gray-700\"></div>\n\t\t<h2 class=\"max-w-sm text-lg\">{page.error?.message}</h2>\n\t\t{#if page.error?.errorId}\n\t\t\t<div class=\"-mx-8 my-2 h-px bg-gray-200 dark:bg-gray-700\"></div>\n\t\t\t<pre class=\"max-w-sm whitespace-pre-wrap text-left font-mono text-xs\">{page.error\n\t\t\t\t\t.errorId}</pre>\n\t\t{/if}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport \"../styles/main.css\";\n\n\timport { onDestroy, onMount, untrack } from \"svelte\";\n\timport { goto } from \"$app/navigation\";\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\n\timport { error } from \"$lib/stores/errors\";\n\timport { createSettingsStore } from \"$lib/stores/settings\";\n\timport { loading } from \"$lib/stores/loading\";\n\timport { setHapticsEnabled } from \"$lib/utils/haptics\";\n\n\timport Toast from \"$lib/components/Toast.svelte\";\n\timport NavMenu from \"$lib/components/NavMenu.svelte\";\n\timport MobileNav from \"$lib/components/MobileNav.svelte\";\n\timport titleUpdate from \"$lib/stores/titleUpdate\";\n\timport WelcomeModal from \"$lib/components/WelcomeModal.svelte\";\n\timport ExpandNavigation from \"$lib/components/ExpandNavigation.svelte\";\n\timport { setContext } from \"svelte\";\n\timport { handleResponse, useAPIClient } from \"$lib/APIClient\";\n\timport { isAborted } from \"$lib/stores/isAborted\";\n\timport { isPro } from \"$lib/stores/isPro\";\n\timport IconShare from \"$lib/components/icons/IconShare.svelte\";\n\timport { shareModal } from \"$lib/stores/shareModal\";\n\timport BackgroundGenerationPoller from \"$lib/components/BackgroundGenerationPoller.svelte\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\n\tlet { data = $bindable(), children } = $props();\n\n\tsetContext(\"publicConfig\", data.publicConfig);\n\n\tconst publicConfig = data.publicConfig;\n\tconst client = useAPIClient();\n\n\tlet conversations = $state(data.conversations);\n\t$effect(() => {\n\t\tdata.conversations && untrack(() => (conversations = data.conversations));\n\t});\n\n\tlet isNavCollapsed = $state(false);\n\n\tlet errorToastTimeout: ReturnType<typeof setTimeout>;\n\tlet currentError: string | undefined = $state();\n\n\tasync function onError() {\n\t\t// If a new different error comes, wait for the current error to hide first\n\t\tif ($error && currentError && $error !== currentError) {\n\t\t\tclearTimeout(errorToastTimeout);\n\t\t\tcurrentError = undefined;\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 300));\n\t\t}\n\n\t\tcurrentError = $error;\n\n\t\terrorToastTimeout = setTimeout(() => {\n\t\t\t$error = undefined;\n\t\t\tcurrentError = undefined;\n\t\t}, 5000);\n\t}\n\n\tlet canShare = $derived(\n\t\tpublicConfig.isHuggingChat &&\n\t\t\tBoolean(page.params?.id) &&\n\t\t\tpage.route.id?.startsWith(\"/conversation/\")\n\t);\n\n\tasync function deleteConversation(id: string) {\n\t\tclient\n\t\t\t.conversations({ id })\n\t\t\t.delete()\n\t\t\t.then(handleResponse)\n\t\t\t.then(async () => {\n\t\t\t\tconversations = conversations.filter((conv) => conv.id !== id);\n\n\t\t\t\tif (page.params.id === id) {\n\t\t\t\t\tawait goto(`${base}/`, { invalidateAll: true });\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconsole.error(err);\n\t\t\t\t$error = String(err);\n\t\t\t});\n\t}\n\n\tasync function editConversationTitle(id: string, title: string) {\n\t\tclient\n\t\t\t.conversations({ id })\n\t\t\t.patch({ title })\n\t\t\t.then(handleResponse)\n\t\t\t.then(async () => {\n\t\t\t\tconversations = conversations.map((conv) => (conv.id === id ? { ...conv, title } : conv));\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconsole.error(err);\n\t\t\t\t$error = String(err);\n\t\t\t});\n\t}\n\n\tfunction closeWelcomeModal() {\n\t\tif (requireAuthUser()) return;\n\t\tsettings.set({ welcomeModalSeen: true });\n\t}\n\n\tonDestroy(() => {\n\t\tclearTimeout(errorToastTimeout);\n\t});\n\n\t$effect(() => {\n\t\tif ($error) onError();\n\t});\n\n\t$effect(() => {\n\t\tif ($titleUpdate) {\n\t\t\tconst convIdx = conversations.findIndex(({ id }) => id === $titleUpdate?.convId);\n\n\t\t\tif (convIdx != -1) {\n\t\t\t\tconversations[convIdx].title = $titleUpdate?.title ?? conversations[convIdx].title;\n\t\t\t}\n\n\t\t\t$titleUpdate = null;\n\t\t}\n\t});\n\n\tconst settings = createSettingsStore(data.settings);\n\n\t$effect(() => {\n\t\tsetHapticsEnabled($settings.hapticsEnabled);\n\t});\n\n\tonMount(async () => {\n\t\tif (publicConfig.isHuggingChat && data.user?.username) {\n\t\t\tfetch(`https://huggingface.co/api/users/${data.user.username}/overview`)\n\t\t\t\t.then((res) => res.json())\n\t\t\t\t.then((userData) => {\n\t\t\t\t\tisPro.set(userData.isPro ?? false);\n\t\t\t\t})\n\t\t\t\t.catch(() => {\n\t\t\t\t\t// Keep isPro as null on error - don't show any badge if status is unknown\n\t\t\t\t});\n\t\t}\n\n\t\tif (page.url.searchParams.has(\"model\")) {\n\t\t\tawait settings\n\t\t\t\t.instantSet({\n\t\t\t\t\tactiveModel: page.url.searchParams.get(\"model\") ?? $settings.activeModel,\n\t\t\t\t})\n\t\t\t\t.then(async () => {\n\t\t\t\t\tconst query = new URLSearchParams(page.url.searchParams.toString());\n\t\t\t\t\tquery.delete(\"model\");\n\t\t\t\t\tawait goto(`${base}/?${query.toString()}`, {\n\t\t\t\t\t\tinvalidateAll: true,\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t}\n\n\t\tif (page.url.searchParams.has(\"token\")) {\n\t\t\tconst token = page.url.searchParams.get(\"token\");\n\n\t\t\tawait fetch(`${base}/api/user/validate-token`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: JSON.stringify({ token }),\n\t\t\t}).then(() => {\n\t\t\t\tgoto(`${base}/`, { invalidateAll: true });\n\t\t\t});\n\t\t}\n\n\t\t// Global keyboard shortcut: New Chat (Ctrl/Cmd + Shift + O)\n\t\tconst onKeydown = (e: KeyboardEvent) => {\n\t\t\t// Ignore when a modal has focus (app is inert)\n\t\t\tconst appEl = document.getElementById(\"app\");\n\t\t\tif (appEl?.hasAttribute(\"inert\")) return;\n\n\t\t\tconst oPressed = e.key?.toLowerCase() === \"o\";\n\t\t\tconst metaOrCtrl = e.metaKey || e.ctrlKey;\n\t\t\tif (oPressed && e.shiftKey && metaOrCtrl) {\n\t\t\t\te.preventDefault();\n\t\t\t\tisAborted.set(true);\n\t\t\t\tif (requireAuthUser()) return;\n\t\t\t\tgoto(`${base}/`, { invalidateAll: true });\n\t\t\t}\n\t\t};\n\n\t\twindow.addEventListener(\"keydown\", onKeydown, { capture: true });\n\t\tonDestroy(() => window.removeEventListener(\"keydown\", onKeydown, { capture: true }));\n\t});\n\n\tlet mobileNavTitle = $derived(\n\t\t[\"/models\", \"/privacy\"].includes(page.route.id ?? \"\")\n\t\t\t? \"\"\n\t\t\t: conversations.find((conv) => conv.id === page.params.id)?.title\n\t);\n\n\t// Show the welcome modal once on first app load\n\tlet showWelcome = $derived(\n\t\t!$settings.welcomeModalSeen &&\n\t\t\t!(page.data.shared === true && page.route.id?.startsWith(\"/conversation/\"))\n\t);\n</script>\n\n<svelte:head>\n\t<title>{publicConfig.PUBLIC_APP_NAME} - Chat with AI models</title>\n\t<meta name=\"description\" content={publicConfig.PUBLIC_APP_DESCRIPTION} />\n\t<meta name=\"twitter:site\" content=\"@huggingface\" />\n\n\t<!-- use those meta tags everywhere except on special listing pages -->\n\t<!-- feel free to refacto if there's a better way -->\n\t{#if !page.url.pathname.includes(\"/models/\")}\n\t\t<meta name=\"twitter:card\" content=\"summary_large_image\" />\n\t\t<meta name=\"twitter:title\" content=\"{publicConfig.PUBLIC_APP_NAME} - Chat with AI models\" />\n\t\t<meta name=\"twitter:description\" content={publicConfig.PUBLIC_APP_DESCRIPTION} />\n\t\t<meta\n\t\t\tname=\"twitter:image\"\n\t\t\tcontent=\"{publicConfig.PUBLIC_ORIGIN ||\n\t\t\t\tpage.url.origin}{publicConfig.assetPath}/thumbnail.png\"\n\t\t/>\n\t\t<meta name=\"twitter:image:alt\" content=\"{publicConfig.PUBLIC_APP_NAME} preview\" />\n\t\t<meta property=\"og:title\" content=\"{publicConfig.PUBLIC_APP_NAME} - Chat with AI models\" />\n\t\t<meta property=\"og:type\" content=\"website\" />\n\t\t<meta property=\"og:url\" content=\"{publicConfig.PUBLIC_ORIGIN || page.url.origin}{base}\" />\n\t\t<meta property=\"og:image\" content=\"{publicConfig.assetPath}/thumbnail.png\" />\n\t\t<meta property=\"og:description\" content={publicConfig.PUBLIC_APP_DESCRIPTION} />\n\t\t<meta property=\"og:site_name\" content={publicConfig.PUBLIC_APP_NAME} />\n\t\t<meta property=\"og:locale\" content=\"en_US\" />\n\t{/if}\n\t<link rel=\"icon\" href=\"{publicConfig.assetPath}/icon.svg\" type=\"image/svg+xml\" />\n\t{#if publicConfig.PUBLIC_ORIGIN}\n\t\t<link\n\t\t\trel=\"icon\"\n\t\t\thref=\"{publicConfig.assetPath}/favicon.svg\"\n\t\t\ttype=\"image/svg+xml\"\n\t\t\tmedia=\"(prefers-color-scheme: light)\"\n\t\t/>\n\t\t<link\n\t\t\trel=\"icon\"\n\t\t\thref=\"{publicConfig.assetPath}/favicon-dark.svg\"\n\t\t\ttype=\"image/svg+xml\"\n\t\t\tmedia=\"(prefers-color-scheme: dark)\"\n\t\t/>\n\t{:else}\n\t\t<link rel=\"icon\" href=\"{publicConfig.assetPath}/favicon-dev.svg\" type=\"image/svg+xml\" />\n\t{/if}\n\t<link rel=\"apple-touch-icon\" href=\"{publicConfig.assetPath}/apple-touch-icon.png\" />\n\t<link rel=\"manifest\" href=\"{publicConfig.assetPath}/manifest.json\" />\n\n\t{#if publicConfig.PUBLIC_PLAUSIBLE_SCRIPT_URL}\n\t\t<script async src={publicConfig.PUBLIC_PLAUSIBLE_SCRIPT_URL}></script>\n\t{/if}\n\n\t{#if publicConfig.PUBLIC_APPLE_APP_ID}\n\t\t<meta name=\"apple-itunes-app\" content={`app-id=${publicConfig.PUBLIC_APPLE_APP_ID}`} />\n\t{/if}\n</svelte:head>\n\n{#if showWelcome}\n\t<WelcomeModal close={closeWelcomeModal} />\n{/if}\n\n<BackgroundGenerationPoller />\n\n<div\n\tclass=\"fixed grid h-dvh w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd {!isNavCollapsed\n\t\t? 'md:grid-cols-[290px,1fr]'\n\t\t: 'md:grid-cols-[0px,1fr]'} transition-[300ms] [transition-property:grid-template-columns] dark:text-gray-300 md:grid-rows-[1fr]\"\n>\n\t<ExpandNavigation\n\t\tisCollapsed={isNavCollapsed}\n\t\tonClick={() => (isNavCollapsed = !isNavCollapsed)}\n\t\tclassNames=\"absolute inset-y-0 z-10 my-auto {!isNavCollapsed\n\t\t\t? 'left-[290px]'\n\t\t\t: 'left-0'} *:transition-transform\"\n\t/>\n\n\t{#if canShare}\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclass=\"hidden size-8 items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white/90 text-sm font-medium text-gray-700 shadow-sm hover:bg-white/60 hover:text-gray-500 dark:border-gray-700 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700 md:absolute md:right-6 md:top-5 md:flex\n\t\t\t\t{$loading ? 'cursor-not-allowed opacity-40' : ''}\"\n\t\t\tonclick={() => shareModal.open()}\n\t\t\taria-label=\"Share conversation\"\n\t\t\tdisabled={$loading}\n\t\t>\n\t\t\t<IconShare />\n\t\t</button>\n\t{/if}\n\n\t<MobileNav title={mobileNavTitle}>\n\t\t<NavMenu\n\t\t\t{conversations}\n\t\t\tuser={data.user}\n\t\t\tondeleteConversation={(id) => deleteConversation(id)}\n\t\t\toneditConversationTitle={(payload) => editConversationTitle(payload.id, payload.title)}\n\t\t/>\n\t</MobileNav>\n\t<nav\n\t\tclass=\"grid max-h-dvh grid-cols-1 grid-rows-[auto,1fr,auto] overflow-hidden *:w-[290px] max-md:hidden\"\n\t>\n\t\t<NavMenu\n\t\t\t{conversations}\n\t\t\tuser={data.user}\n\t\t\tondeleteConversation={(id) => deleteConversation(id)}\n\t\t\toneditConversationTitle={(payload) => editConversationTitle(payload.id, payload.title)}\n\t\t/>\n\t</nav>\n\t{#if currentError}\n\t\t<Toast message={currentError} />\n\t{/if}\n\t{@render children?.()}\n\n\t{#if publicConfig.PUBLIC_PLAUSIBLE_SCRIPT_URL}\n\t\t<script>\n\t\t\t(window.plausible =\n\t\t\t\twindow.plausible ||\n\t\t\t\tfunction () {\n\t\t\t\t\t(plausible.q = plausible.q || []).push(arguments);\n\t\t\t\t}),\n\t\t\t\t(plausible.init =\n\t\t\t\t\tplausible.init ||\n\t\t\t\t\tfunction (i) {\n\t\t\t\t\t\tplausible.o = i || {};\n\t\t\t\t\t});\n\t\t\tplausible.init();\n\t\t</script>\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/routes/+layout.ts",
    "content": "import { UrlDependency } from \"$lib/types/UrlDependency\";\nimport type { ConvSidebar } from \"$lib/types/ConvSidebar\";\nimport { useAPIClient, handleResponse } from \"$lib/APIClient\";\nimport { getConfigManager } from \"$lib/utils/PublicConfig.svelte\";\nimport type { GETModelsResponse, FeatureFlags } from \"$lib/server/api/types\";\n\ninterface ConversationListItem {\n\t_id: { toString(): string };\n\ttitle: string;\n\tupdatedAt: Date | string;\n\tmodel?: string;\n}\n\ninterface UserInfo {\n\tid: string;\n\tusername?: string;\n\tavatarUrl?: string;\n\temail?: string;\n\tisAdmin: boolean;\n\tisEarlyAccess: boolean;\n}\n\ninterface SettingsResponse {\n\twelcomeModalSeen: boolean;\n\twelcomeModalSeenAt: Date | null;\n\tshareConversationsWithModelAuthors: boolean;\n\tactiveModel: string;\n\tstreamingMode: \"raw\" | \"smooth\";\n\tdirectPaste: boolean;\n\thapticsEnabled: boolean;\n\tcustomPrompts: Record<string, string>;\n\tmultimodalOverrides: Record<string, boolean>;\n\ttoolsOverrides: Record<string, boolean>;\n\thidePromptExamples: Record<string, boolean>;\n\tproviderOverrides: Record<string, string>;\n\tbillingOrganization?: string;\n}\n\nexport const load = async ({ depends, fetch, url }) => {\n\tdepends(UrlDependency.ConversationList);\n\n\tconst client = useAPIClient({ fetch, origin: url.origin });\n\n\tconst [settings, models, user, publicConfig, featureFlags, conversationsData] =\n\t\t(await Promise.all([\n\t\t\tclient.user.settings.get().then(handleResponse),\n\t\t\tclient.models.get().then(handleResponse),\n\t\t\tclient.user.get().then(handleResponse),\n\t\t\tclient[\"public-config\"].get().then(handleResponse),\n\t\t\tclient[\"feature-flags\"].get().then(handleResponse),\n\t\t\tclient.conversations.get({ query: { p: 0 } }).then(handleResponse),\n\t\t])) as [\n\t\t\tSettingsResponse,\n\t\t\tGETModelsResponse,\n\t\t\tUserInfo | null,\n\t\t\tRecord<string, unknown>,\n\t\t\tFeatureFlags,\n\t\t\t{ conversations: ConversationListItem[]; hasMore: boolean },\n\t\t];\n\n\tconst defaultModel = models[0];\n\n\tconst { conversations: rawConversations } = conversationsData;\n\tconst conversations = rawConversations.map((conv: ConversationListItem) => {\n\t\tconst trimmedTitle = conv.title.trim();\n\n\t\tconv.title = trimmedTitle;\n\n\t\treturn {\n\t\t\tid: conv._id.toString(),\n\t\t\ttitle: conv.title,\n\t\t\tmodel: conv.model ?? defaultModel?.id,\n\t\t\tupdatedAt: new Date(conv.updatedAt),\n\t\t} satisfies ConvSidebar;\n\t});\n\n\treturn {\n\t\tconversations,\n\t\tmodels,\n\t\toldModels: [],\n\t\tuser,\n\t\tsettings: {\n\t\t\t...settings,\n\t\t\twelcomeModalSeenAt: settings.welcomeModalSeenAt\n\t\t\t\t? new Date(settings.welcomeModalSeenAt)\n\t\t\t\t: null,\n\t\t},\n\t\tpublicConfig: getConfigManager(publicConfig as Record<`PUBLIC_${string}`, string>),\n\t\t...featureFlags,\n\t};\n};\n"
  },
  {
    "path": "src/routes/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { goto, replaceState } from \"$app/navigation\";\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\timport ChatWindow from \"$lib/components/chat/ChatWindow.svelte\";\n\timport { ERROR_MESSAGES, error } from \"$lib/stores/errors\";\n\timport { pendingMessage } from \"$lib/stores/pendingMessage\";\n\timport { useSettingsStore } from \"$lib/stores/settings.js\";\n\timport { findCurrentModel } from \"$lib/utils/models\";\n\timport { sanitizeUrlParam } from \"$lib/utils/urlParams\";\n\timport { onMount, tick } from \"svelte\";\n\timport { loading } from \"$lib/stores/loading.js\";\n\timport { loadAttachmentsFromUrls } from \"$lib/utils/loadAttachmentsFromUrls\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\n\tlet { data } = $props();\n\n\tlet hasModels = $derived(Boolean(data.models?.length));\n\tlet files: File[] = $state([]);\n\tlet draft = $state(\"\");\n\n\tconst settings = useSettingsStore();\n\n\tasync function createConversation(message: string) {\n\t\ttry {\n\t\t\t$loading = true;\n\n\t\t\t// check if $settings.activeModel is a valid model\n\t\t\t// else check if it's an assistant, and use that model\n\t\t\t// else use the first model\n\n\t\t\tconst validModels = data.models.map((model) => model.id);\n\n\t\t\tlet model;\n\t\t\tif (validModels.includes($settings.activeModel)) {\n\t\t\t\tmodel = $settings.activeModel;\n\t\t\t} else {\n\t\t\t\tmodel = data.models[0].id;\n\t\t\t}\n\t\t\tconst res = await fetch(`${base}/conversation`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\tmodel,\n\t\t\t\t\tpreprompt: $settings.customPrompts[$settings.activeModel],\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tif (!res.ok) {\n\t\t\t\tlet errorMessage = ERROR_MESSAGES.default;\n\t\t\t\ttry {\n\t\t\t\t\tconst json = await res.json();\n\t\t\t\t\terrorMessage = json.message || errorMessage;\n\t\t\t\t} catch {\n\t\t\t\t\t// Response wasn't JSON (e.g., HTML error page)\n\t\t\t\t\tif (res.status === 401) {\n\t\t\t\t\t\terrorMessage = \"Authentication required\";\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\terror.set(errorMessage);\n\t\t\t\tconsole.error(\"Error while creating conversation: \", errorMessage);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { conversationId } = await res.json();\n\n\t\t\t// Ugly hack to use a store as temp storage, feel free to improve ^^\n\t\t\tpendingMessage.set({\n\t\t\t\tcontent: message,\n\t\t\t\tfiles,\n\t\t\t});\n\n\t\t\t// invalidateAll to update list of conversations\n\t\t\tawait goto(`${base}/conversation/${conversationId}`, { invalidateAll: true });\n\t\t} catch (err) {\n\t\t\terror.set((err as Error).message || ERROR_MESSAGES.default);\n\t\t\tconsole.error(err);\n\t\t} finally {\n\t\t\t$loading = false;\n\t\t}\n\t}\n\n\tonMount(async () => {\n\t\ttry {\n\t\t\t// Check if auth is required before processing any query params\n\t\t\tconst hasQ = page.url.searchParams.has(\"q\");\n\t\t\tconst hasPrompt = page.url.searchParams.has(\"prompt\");\n\t\t\tconst hasAttachments = page.url.searchParams.has(\"attachments\");\n\n\t\t\tif ((hasQ || hasPrompt || hasAttachments) && requireAuthUser()) {\n\t\t\t\treturn; // Redirecting to login, will return to this URL after\n\t\t\t}\n\n\t\t\t// Handle attachments parameter first\n\t\t\tif (hasAttachments) {\n\t\t\t\tconst result = await loadAttachmentsFromUrls(page.url.searchParams);\n\t\t\t\tfiles = result.files;\n\n\t\t\t\t// Show errors if any\n\t\t\t\tif (result.errors.length > 0) {\n\t\t\t\t\tconsole.error(\"Failed to load some attachments:\", result.errors);\n\t\t\t\t\terror.set(\n\t\t\t\t\t\t`Failed to load ${result.errors.length} attachment(s). Check console for details.`\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Clean up URL\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"attachments\");\n\t\t\t\thistory.replaceState({}, \"\", url);\n\t\t\t}\n\n\t\t\tconst query = sanitizeUrlParam(page.url.searchParams.get(\"q\"));\n\t\t\tif (query) {\n\t\t\t\tvoid createConversation(query);\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"q\");\n\t\t\t\ttick().then(() => {\n\t\t\t\t\treplaceState(url, page.state);\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst promptQuery = sanitizeUrlParam(page.url.searchParams.get(\"prompt\"));\n\t\t\tif (promptQuery && !draft) {\n\t\t\t\tdraft = promptQuery;\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"prompt\");\n\t\t\t\ttick().then(() => {\n\t\t\t\t\treplaceState(url, page.state);\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to process URL parameters:\", err);\n\t\t}\n\t});\n\n\tlet currentModel = $derived(findCurrentModel(data.models, data.oldModels, $settings.activeModel));\n</script>\n\n<svelte:head>\n\t<title>{publicConfig.PUBLIC_APP_NAME}</title>\n</svelte:head>\n\n{#if hasModels}\n\t<ChatWindow\n\t\tonmessage={(message) => createConversation(message)}\n\t\tloading={$loading}\n\t\t{currentModel}\n\t\tmodels={data.models}\n\t\tbind:files\n\t\tbind:draft\n\t/>\n{:else}\n\t<div class=\"mx-auto my-20 max-w-xl rounded-xl border p-6 text-center dark:border-gray-700\">\n\t\t<h2 class=\"mb-2 text-xl font-semibold\">No models available</h2>\n\t\t<p class=\"text-gray-600 dark:text-gray-300\">\n\t\t\tNo chat models are configured. Set `OPENAI_BASE_URL` and ensure the server can reach the\n\t\t\tendpoint, then reload. If unset, the app defaults to the Hugging Face router.\n\t\t</p>\n\t</div>\n{/if}\n"
  },
  {
    "path": "src/routes/.well-known/oauth-cimd/+server.ts",
    "content": "import { base } from \"$app/paths\";\nimport { OIDConfig } from \"$lib/server/auth\";\nimport { config } from \"$lib/server/config\";\n\n/**\n * See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/\n */\nexport const GET = ({ url }) => {\n\tif (!OIDConfig.CLIENT_ID) {\n\t\treturn new Response(\"Client ID not found\", { status: 404 });\n\t}\n\tif (OIDConfig.CLIENT_ID !== \"__CIMD__\") {\n\t\treturn new Response(\n\t\t\t`Client ID is manually set to something other than '__CIMD__': ${OIDConfig.CLIENT_ID}`,\n\t\t\t{\n\t\t\t\tstatus: 404,\n\t\t\t}\n\t\t);\n\t}\n\treturn new Response(\n\t\tJSON.stringify({\n\t\t\tclient_id: new URL(url, config.PUBLIC_ORIGIN || url.origin).toString(),\n\t\t\tclient_name: config.PUBLIC_APP_NAME,\n\t\t\tclient_uri: `${config.PUBLIC_ORIGIN || url.origin}${base}`,\n\t\t\tredirect_uris: [\n\t\t\t\tnew URL(`${base}/login/callback`, config.PUBLIC_ORIGIN || url.origin).toString(),\n\t\t\t],\n\t\t\ttoken_endpoint_auth_method: \"none\",\n\t\t\tscopes: OIDConfig.SCOPES,\n\t\t}),\n\t\t{\n\t\t\theaders: {\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t}\n\t);\n};\n"
  },
  {
    "path": "src/routes/__debug/openai/+server.ts",
    "content": "import { json } from \"@sveltejs/kit\";\nimport { config } from \"$lib/server/config\";\nconst DEFAULT_OPENAI_BASE = \"https://router.huggingface.co/v1\";\n\nexport async function GET() {\n\tconst base = (config.OPENAI_BASE_URL || DEFAULT_OPENAI_BASE).replace(/\\/$/, \"\");\n\ttry {\n\t\tconst res = await fetch(`${base}/models`);\n\t\tconst text = await res.text();\n\t\tlet length: number | null = null;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(text);\n\t\t\tlength = Array.isArray(parsed?.data) ? parsed.data.length : null;\n\t\t} catch (_err) {\n\t\t\tlength = null; // ignore parse errors\n\t\t}\n\t\treturn json({ base, status: res.status, ok: res.ok, length, sample: text.slice(0, 1000) });\n\t} catch (e) {\n\t\treturn json({ base, error: String(e) });\n\t}\n}\n"
  },
  {
    "path": "src/routes/admin/export/+server.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { collections } from \"$lib/server/database\";\nimport type { Message } from \"$lib/types/Message\";\nimport { error } from \"@sveltejs/kit\";\nimport { pathToFileURL } from \"node:url\";\nimport { unlink } from \"node:fs/promises\";\nimport { uploadFile } from \"@huggingface/hub\";\nimport parquet from \"parquetjs\";\nimport { z } from \"zod\";\nimport { logger } from \"$lib/server/logger.js\";\n\n// Triger like this:\n// curl -X POST \"http://localhost:5173/chat/admin/export\" -H \"Authorization: Bearer <ADMIN_API_SECRET>\" -H \"Content-Type: application/json\" -d '{\"model\": \"OpenAssistant/oasst-sft-6-llama-30b-xor\"}'\n\nexport async function POST({ request }) {\n\tif (!config.PARQUET_EXPORT_DATASET || !config.PARQUET_EXPORT_HF_TOKEN) {\n\t\terror(500, \"Parquet export is not configured.\");\n\t}\n\n\tconst { model } = z\n\t\t.object({\n\t\t\tmodel: z.string(),\n\t\t})\n\t\t.parse(await request.json());\n\n\tconst schema = new parquet.ParquetSchema({\n\t\ttitle: { type: \"UTF8\" },\n\t\tcreated_at: { type: \"TIMESTAMP_MILLIS\" },\n\t\tupdated_at: { type: \"TIMESTAMP_MILLIS\" },\n\t\tmessages: {\n\t\t\trepeated: true,\n\t\t\tfields: {\n\t\t\t\tfrom: { type: \"UTF8\" },\n\t\t\t\tcontent: { type: \"UTF8\" },\n\t\t\t\tscore: { type: \"INT_8\", optional: true },\n\t\t\t},\n\t\t},\n\t});\n\n\tconst fileName = `/tmp/conversations-${new Date().toJSON().slice(0, 10)}-${Date.now()}.parquet`;\n\n\tconst writer = await parquet.ParquetWriter.openFile(schema, fileName);\n\n\tlet count = 0;\n\tlogger.info(\"Exporting conversations for model\", model);\n\n\tfor await (const conversation of collections.settings.aggregate<{\n\t\ttitle: string;\n\t\tcreated_at: Date;\n\t\tupdated_at: Date;\n\t\tmessages: Message[];\n\t}>([\n\t\t{\n\t\t\t$match: {\n\t\t\t\tshareConversationsWithModelAuthors: true,\n\t\t\t\tsessionId: { $exists: true },\n\t\t\t\tuserId: { $exists: false },\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t$lookup: {\n\t\t\t\tfrom: \"conversations\",\n\t\t\t\tlocalField: \"sessionId\",\n\t\t\t\tforeignField: \"sessionId\",\n\t\t\t\tas: \"conversations\",\n\t\t\t\tpipeline: [{ $match: { model, userId: { $exists: false } } }],\n\t\t\t},\n\t\t},\n\t\t{ $unwind: \"$conversations\" },\n\t\t{\n\t\t\t$project: {\n\t\t\t\ttitle: \"$conversations.title\",\n\t\t\t\tcreated_at: \"$conversations.createdAt\",\n\t\t\t\tupdated_at: \"$conversations.updatedAt\",\n\t\t\t\tmessages: \"$conversations.messages\",\n\t\t\t},\n\t\t},\n\t])) {\n\t\tawait writer.appendRow({\n\t\t\ttitle: conversation.title,\n\t\t\tcreated_at: conversation.created_at,\n\t\t\tupdated_at: conversation.updated_at,\n\t\t\tmessages: conversation.messages.map((message: Message) => ({\n\t\t\t\tfrom: message.from,\n\t\t\t\tcontent: message.content,\n\t\t\t\t...(message.score ? { score: message.score } : undefined),\n\t\t\t})),\n\t\t});\n\t\t++count;\n\n\t\tif (count % 1_000 === 0) {\n\t\t\tlogger.info(\"Exported\", count, \"conversations\");\n\t\t}\n\t}\n\n\tlogger.info(\"exporting convos with userId\");\n\n\tfor await (const conversation of collections.settings.aggregate<{\n\t\ttitle: string;\n\t\tcreated_at: Date;\n\t\tupdated_at: Date;\n\t\tmessages: Message[];\n\t}>([\n\t\t{ $match: { shareConversationsWithModelAuthors: true, userId: { $exists: true } } },\n\t\t{\n\t\t\t$lookup: {\n\t\t\t\tfrom: \"conversations\",\n\t\t\t\tlocalField: \"userId\",\n\t\t\t\tforeignField: \"userId\",\n\t\t\t\tas: \"conversations\",\n\t\t\t\tpipeline: [{ $match: { model } }],\n\t\t\t},\n\t\t},\n\t\t{ $unwind: \"$conversations\" },\n\t\t{\n\t\t\t$project: {\n\t\t\t\ttitle: \"$conversations.title\",\n\t\t\t\tcreated_at: \"$conversations.createdAt\",\n\t\t\t\tupdated_at: \"$conversations.updatedAt\",\n\t\t\t\tmessages: \"$conversations.messages\",\n\t\t\t},\n\t\t},\n\t])) {\n\t\tawait writer.appendRow({\n\t\t\ttitle: conversation.title,\n\t\t\tcreated_at: conversation.created_at,\n\t\t\tupdated_at: conversation.updated_at,\n\t\t\tmessages: conversation.messages.map((message: Message) => ({\n\t\t\t\tfrom: message.from,\n\t\t\t\tcontent: message.content,\n\t\t\t\t...(message.score ? { score: message.score } : undefined),\n\t\t\t})),\n\t\t});\n\t\t++count;\n\n\t\tif (count % 1_000 === 0) {\n\t\t\tlogger.info(\"Exported\", count, \"conversations\");\n\t\t}\n\t}\n\n\tawait writer.close();\n\n\tlogger.info(\"Uploading\", fileName, \"to Hugging Face Hub\");\n\n\tawait uploadFile({\n\t\tfile: pathToFileURL(fileName) as URL,\n\t\tcredentials: { accessToken: config.PARQUET_EXPORT_HF_TOKEN },\n\t\trepo: {\n\t\t\ttype: \"dataset\",\n\t\t\tname: config.PARQUET_EXPORT_DATASET,\n\t\t},\n\t});\n\n\tlogger.info(\"Upload done\");\n\n\tawait unlink(fileName);\n\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/admin/stats/compute/+server.ts",
    "content": "import { json } from \"@sveltejs/kit\";\nimport { logger } from \"$lib/server/logger\";\nimport { computeAllStats } from \"$lib/jobs/refresh-conversation-stats\";\n\n// Triger like this:\n// curl -X POST \"http://localhost:5173/chat/admin/stats/compute\" -H \"Authorization: Bearer <ADMIN_API_SECRET>\"\n\nexport async function POST() {\n\tcomputeAllStats().catch((e) => logger.error(e, \"Error computing all stats\"));\n\treturn json(\n\t\t{\n\t\t\tmessage: \"Stats job started\",\n\t\t},\n\t\t{ status: 202 }\n\t);\n}\n"
  },
  {
    "path": "src/routes/api/conversation/[id]/+server.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { z } from \"zod\";\nimport { ObjectId } from \"mongodb\";\n\nexport async function GET({ locals, params }) {\n\tconst id = z.string().parse(params.id);\n\tconst convId = new ObjectId(id);\n\n\tif (locals.user?._id || locals.sessionId) {\n\t\tconst conv = await collections.conversations.findOne({\n\t\t\t_id: convId,\n\t\t\t...authCondition(locals),\n\t\t});\n\n\t\tif (conv) {\n\t\t\tconst res = {\n\t\t\t\tid: conv._id,\n\t\t\t\ttitle: conv.title,\n\t\t\t\tupdatedAt: conv.updatedAt,\n\t\t\t\tmodelId: conv.model,\n\t\t\t\tmessages: conv.messages.map((message) => ({\n\t\t\t\t\tcontent: message.content,\n\t\t\t\t\tfrom: message.from,\n\t\t\t\t\tid: message.id,\n\t\t\t\t\tcreatedAt: message.createdAt,\n\t\t\t\t\tupdatedAt: message.updatedAt,\n\t\t\t\t\t// websearch removed\n\t\t\t\t\tfiles: message.files,\n\t\t\t\t\tupdates: message.updates,\n\t\t\t\t})),\n\t\t\t};\n\t\t\treturn Response.json(res);\n\t\t} else {\n\t\t\treturn Response.json({ message: \"Conversation not found\" }, { status: 404 });\n\t\t}\n\t} else {\n\t\treturn Response.json({ message: \"Must have session cookie\" }, { status: 401 });\n\t}\n}\n"
  },
  {
    "path": "src/routes/api/conversation/[id]/message/[messageId]/+server.ts",
    "content": "import { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\n\nexport async function DELETE({ locals, params }) {\n\tconst messageId = params.messageId;\n\n\tif (!messageId || typeof messageId !== \"string\") {\n\t\terror(400, \"Invalid message id\");\n\t}\n\n\tconst conversation = await collections.conversations.findOne({\n\t\t...authCondition(locals),\n\t\t_id: new ObjectId(params.id),\n\t});\n\n\tif (!conversation) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\tconst filteredMessages = conversation.messages\n\t\t.filter(\n\t\t\t(message) =>\n\t\t\t\t// not the message AND the message is not in ancestors\n\t\t\t\t!(message.id === messageId) && message.ancestors && !message.ancestors.includes(messageId)\n\t\t)\n\t\t.map((message) => {\n\t\t\t// remove the message from children if it's there\n\t\t\tif (message.children && message.children.includes(messageId)) {\n\t\t\t\tmessage.children = message.children.filter((child) => child !== messageId);\n\t\t\t}\n\t\t\treturn message;\n\t\t});\n\n\tawait collections.conversations.updateOne(\n\t\t{ _id: conversation._id, ...authCondition(locals) },\n\t\t{ $set: { messages: filteredMessages } }\n\t);\n\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/api/conversations/+server.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport { CONV_NUM_PER_PAGE } from \"$lib/constants/pagination\";\n\nexport async function GET({ locals, url }) {\n\tconst p = parseInt(url.searchParams.get(\"p\") ?? \"0\");\n\tif (locals.user?._id || locals.sessionId) {\n\t\tconst convs = await collections.conversations\n\t\t\t.find({\n\t\t\t\t...authCondition(locals),\n\t\t\t})\n\t\t\t.project<Pick<Conversation, \"_id\" | \"title\" | \"updatedAt\" | \"model\" | never>>({\n\t\t\t\ttitle: 1,\n\t\t\t\tupdatedAt: 1,\n\t\t\t\tmodel: 1,\n\t\t\t})\n\t\t\t.sort({ updatedAt: -1 })\n\t\t\t.skip(p * CONV_NUM_PER_PAGE)\n\t\t\t.limit(CONV_NUM_PER_PAGE)\n\t\t\t.toArray();\n\n\t\tif (convs.length === 0) {\n\t\t\treturn Response.json([]);\n\t\t}\n\t\tconst res = convs.map((conv) => ({\n\t\t\t_id: conv._id,\n\t\t\tid: conv._id, // legacy param iOS\n\t\t\ttitle: conv.title,\n\t\t\tupdatedAt: conv.updatedAt,\n\t\t\tmodel: conv.model,\n\t\t\tmodelId: conv.model, // legacy param iOS\n\t\t}));\n\t\treturn Response.json(res);\n\t} else {\n\t\treturn Response.json({ message: \"Must have session cookie\" }, { status: 401 });\n\t}\n}\n\nexport async function DELETE({ locals }) {\n\tif (locals.user?._id || locals.sessionId) {\n\t\tawait collections.conversations.deleteMany({\n\t\t\t...authCondition(locals),\n\t\t});\n\t}\n\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/api/fetch-url/+server.ts",
    "content": "import { error } from \"@sveltejs/kit\";\nimport { logger } from \"$lib/server/logger.js\";\nimport { Agent, fetch } from \"undici\";\nimport { isValidUrl, assertSafeIp } from \"$lib/server/urlSafety\";\nimport dns from \"node:dns\";\n\nconst MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\nconst FETCH_TIMEOUT = 30000; // 30 seconds\nconst MAX_REDIRECTS = 5;\nconst SECURITY_HEADERS: HeadersInit = {\n\t// Prevent any active content from executing if someone navigates directly to this endpoint.\n\t\"Content-Security-Policy\":\n\t\t\"default-src 'none'; frame-ancestors 'none'; sandbox; script-src 'none'; img-src 'none'; style-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'\",\n\t\"X-Content-Type-Options\": \"nosniff\",\n\t\"X-Frame-Options\": \"DENY\",\n\t\"Referrer-Policy\": \"no-referrer\",\n};\n\n/**\n * Undici dispatcher that validates resolved IPs at connection time,\n * preventing TOCTOU DNS rebinding attacks.\n */\nconst ssrfSafeAgent = new Agent({\n\tconnect: {\n\t\tlookup: (hostname, options, callback) => {\n\t\t\tdns.lookup(hostname, options, (err, address, family) => {\n\t\t\t\tif (err) return callback(err, \"\", 4);\n\t\t\t\tif (typeof address === \"string\") {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tassertSafeIp(address, hostname);\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\treturn callback(e as Error, \"\", 4);\n\t\t\t\t\t}\n\t\t\t\t} else if (Array.isArray(address)) {\n\t\t\t\t\tfor (const entry of address) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tassertSafeIp(entry.address, hostname);\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\treturn callback(e as Error, \"\", 4);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn callback(null, address, family);\n\t\t\t});\n\t\t},\n\t},\n});\n\nexport async function GET({ url }) {\n\tconst targetUrl = url.searchParams.get(\"url\");\n\n\tif (!targetUrl) {\n\t\tlogger.warn(\"Missing 'url' parameter\");\n\t\tthrow error(400, \"Missing 'url' parameter\");\n\t}\n\n\tif (!isValidUrl(targetUrl)) {\n\t\tlogger.warn({ targetUrl }, \"Invalid or unsafe URL (only HTTPS is supported)\");\n\t\tthrow error(400, \"Invalid or unsafe URL (only HTTPS is supported)\");\n\t}\n\n\t// Fetch with timeout, following redirects manually to validate each hop\n\tconst controller = new AbortController();\n\tconst timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);\n\n\tlet currentUrl = targetUrl;\n\tlet response: Awaited<ReturnType<typeof fetch>>;\n\tlet redirectCount = 0;\n\n\ttry {\n\t\t// eslint-disable-next-line no-constant-condition\n\t\twhile (true) {\n\t\t\tresponse = await fetch(currentUrl, {\n\t\t\t\tsignal: controller.signal,\n\t\t\t\tredirect: \"manual\",\n\t\t\t\tdispatcher: ssrfSafeAgent,\n\t\t\t\theaders: {\n\t\t\t\t\t\"User-Agent\": \"HuggingChat-Attachment-Fetcher/1.0\",\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tif (response.status >= 300 && response.status < 400) {\n\t\t\t\tredirectCount++;\n\t\t\t\tif (redirectCount > MAX_REDIRECTS) {\n\t\t\t\t\tthrow error(502, \"Too many redirects\");\n\t\t\t\t}\n\n\t\t\t\tconst location = response.headers.get(\"location\");\n\t\t\t\tif (!location) {\n\t\t\t\t\tthrow error(502, \"Redirect without Location header\");\n\t\t\t\t}\n\n\t\t\t\t// Resolve relative redirects against the current URL\n\t\t\t\tconst redirectUrl = new URL(location, currentUrl).toString();\n\n\t\t\t\tif (!isValidUrl(redirectUrl)) {\n\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t{ redirectUrl, originalUrl: targetUrl },\n\t\t\t\t\t\t\"Redirect to unsafe URL blocked (SSRF)\"\n\t\t\t\t\t);\n\t\t\t\t\tthrow error(403, \"Redirect target is not allowed\");\n\t\t\t\t}\n\n\t\t\t\tcurrentUrl = redirectUrl;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tbreak;\n\t\t}\n\t} finally {\n\t\tclearTimeout(timeoutId);\n\t}\n\n\tif (!response.ok) {\n\t\tlogger.error({ targetUrl, response }, \"Error fetching URL. Response not ok.\");\n\t\tthrow error(response.status, `Failed to fetch: ${response.statusText}`);\n\t}\n\n\t// Check content length if available\n\tconst contentLength = response.headers.get(\"content-length\");\n\tif (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {\n\t\tthrow error(413, \"File too large (max 10MB)\");\n\t}\n\n\t// Stream the response back\n\tconst originalContentType = response.headers.get(\"content-type\") || \"application/octet-stream\";\n\t// Send as text/plain for safety; expose the original type via secondary header\n\tconst safeContentType = \"text/plain; charset=utf-8\";\n\tconst contentDisposition = response.headers.get(\"content-disposition\");\n\n\tconst headers: HeadersInit = {\n\t\t\"Content-Type\": safeContentType,\n\t\t\"X-Forwarded-Content-Type\": originalContentType,\n\t\t\"Cache-Control\": \"public, max-age=3600\",\n\t\t...(contentDisposition ? { \"Content-Disposition\": contentDisposition } : {}),\n\t\t...SECURITY_HEADERS,\n\t};\n\n\t// Get the body as array buffer to check size\n\tconst arrayBuffer = await response.arrayBuffer();\n\n\tif (arrayBuffer.byteLength > MAX_FILE_SIZE) {\n\t\tthrow error(413, \"File too large (max 10MB)\");\n\t}\n\n\treturn new Response(arrayBuffer, { headers });\n}\n"
  },
  {
    "path": "src/routes/api/mcp/health/+server.ts",
    "content": "import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport type { KeyValuePair } from \"$lib/types/Tool\";\nimport { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\nimport type { RequestHandler } from \"./$types\";\nimport { isValidUrl } from \"$lib/server/urlSafety\";\nimport { isStrictHfMcpLogin, hasNonEmptyToken, isExaMcpServer } from \"$lib/server/mcp/hf\";\n\ninterface HealthCheckRequest {\n\turl: string;\n\theaders?: KeyValuePair[];\n}\n\ninterface HealthCheckResponse {\n\tready: boolean;\n\ttools?: Array<{\n\t\tname: string;\n\t\tdescription?: string;\n\t\tinputSchema?: unknown;\n\t}>;\n\terror?: string;\n\tauthRequired?: boolean;\n}\n\nexport const POST: RequestHandler = async ({ request, locals }) => {\n\tlet client: Client | undefined;\n\n\ttry {\n\t\tconst body: HealthCheckRequest = await request.json();\n\t\tconst { url, headers } = body;\n\n\t\tif (!url) {\n\t\t\treturn new Response(JSON.stringify({ ready: false, error: \"URL is required\" }), {\n\t\t\t\tstatus: 400,\n\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t});\n\t\t}\n\n\t\t// URL validation handled above\n\n\t\tif (!isValidUrl(url)) {\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({\n\t\t\t\t\tready: false,\n\t\t\t\t\terror: \"Invalid or unsafe URL (only HTTPS is supported)\",\n\t\t\t\t} as HealthCheckResponse),\n\t\t\t\t{ status: 400, headers: { \"Content-Type\": \"application/json\" } }\n\t\t\t);\n\t\t}\n\n\t\t// Inject Exa API key for mcp.exa.ai servers via URL param\n\t\tlet finalUrl = url;\n\t\ttry {\n\t\t\tconst exaApiKey = config.EXA_API_KEY;\n\t\t\tif (isExaMcpServer(url) && hasNonEmptyToken(exaApiKey)) {\n\t\t\t\tconst urlObj = new URL(url);\n\t\t\t\tif (!urlObj.searchParams.has(\"exaApiKey\")) {\n\t\t\t\t\turlObj.searchParams.set(\"exaApiKey\", exaApiKey);\n\t\t\t\t\tfinalUrl = urlObj.toString();\n\t\t\t\t\tlogger.debug({}, \"[MCP Health] injected Exa API key\");\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// best-effort injection\n\t\t}\n\n\t\tconst baseUrl = new URL(finalUrl);\n\n\t\t// Minimal header handling\n\t\tconst headersRecord: Record<string, string> = headers?.length\n\t\t\t? Object.fromEntries(headers.map((h) => [h.key, h.value]))\n\t\t\t: {};\n\t\tif (!headersRecord[\"Accept\"]) {\n\t\t\theadersRecord[\"Accept\"] = \"application/json, text/event-stream\";\n\t\t}\n\n\t\t// If enabled, attach the logged-in user's HF token only for the official HF MCP endpoint\n\t\ttry {\n\t\t\tconst shouldForward = config.MCP_FORWARD_HF_USER_TOKEN === \"true\";\n\t\t\tconst userToken =\n\t\t\t\t(locals as unknown as { hfAccessToken?: string } | undefined)?.hfAccessToken ??\n\t\t\t\t(locals as unknown as { token?: string } | undefined)?.token;\n\t\t\tconst hasAuth = typeof headersRecord[\"Authorization\"] === \"string\";\n\t\t\tconst isHfMcpTarget = isStrictHfMcpLogin(url);\n\t\t\tif (shouldForward && !hasAuth && isHfMcpTarget && hasNonEmptyToken(userToken)) {\n\t\t\t\theadersRecord[\"Authorization\"] = `Bearer ${userToken}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// best-effort overlay\n\t\t}\n\n\t\t// Add an abort timeout to outbound requests (align with fetch-url: 30s)\n\t\tconst controller = new AbortController();\n\t\tconst timeoutId = setTimeout(() => controller.abort(), 30000);\n\t\tconst signal = controller.signal;\n\t\tconst requestInit: RequestInit = {\n\t\t\theaders: headersRecord,\n\t\t\tsignal,\n\t\t};\n\n\t\tlet httpError: Error | undefined;\n\t\tlet lastError: Error | undefined;\n\n\t\t// Try Streamable HTTP transport first\n\t\ttry {\n\t\t\tlogger.info({}, `[MCP Health] Trying HTTP transport for ${url}`);\n\t\t\tclient = new Client({\n\t\t\t\tname: \"chat-ui-health-check\",\n\t\t\t\tversion: \"1.0.0\",\n\t\t\t});\n\n\t\t\tconst transport = new StreamableHTTPClientTransport(baseUrl, { requestInit });\n\t\t\tlogger.info({}, `[MCP Health] Connecting to ${url}...`);\n\t\t\tawait client.connect(transport);\n\t\t\tlogger.info({}, `[MCP Health] Connected successfully via HTTP`);\n\n\t\t\t// Connection successful, get tools\n\t\t\tconst toolsResponse = await client.listTools();\n\n\t\t\t// Disconnect after getting tools\n\t\t\tawait client.close();\n\n\t\t\tif (toolsResponse && toolsResponse.tools) {\n\t\t\t\tconst response: HealthCheckResponse = {\n\t\t\t\t\tready: true,\n\t\t\t\t\ttools: toolsResponse.tools.map((tool) => ({\n\t\t\t\t\t\tname: tool.name,\n\t\t\t\t\t\tdescription: tool.description,\n\t\t\t\t\t\tinputSchema: tool.inputSchema,\n\t\t\t\t\t})),\n\t\t\t\t\tauthRequired: false,\n\t\t\t\t};\n\n\t\t\t\tconst res = new Response(JSON.stringify(response), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t});\n\t\t\t\tclearTimeout(timeoutId);\n\t\t\t\treturn res;\n\t\t\t} else {\n\t\t\t\tconst res = new Response(\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\tready: false,\n\t\t\t\t\t\terror: \"Connected but no tools available\",\n\t\t\t\t\t\tauthRequired: false,\n\t\t\t\t\t} as HealthCheckResponse),\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus: 503,\n\t\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t\t\tclearTimeout(timeoutId);\n\t\t\t\treturn res;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\thttpError = error instanceof Error ? error : new Error(String(error));\n\t\t\tlastError = httpError;\n\t\t\tlogger.warn(lastError.message, \"Streamable HTTP failed, trying SSE transport...\");\n\n\t\t\t// Close failed client\n\t\t\ttry {\n\t\t\t\tawait client?.close();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\n\t\t\t// Try SSE transport\n\t\t\ttry {\n\t\t\t\tlogger.info({}, `[MCP Health] Trying SSE transport for ${url}`);\n\t\t\t\tclient = new Client({\n\t\t\t\t\tname: \"chat-ui-health-check\",\n\t\t\t\t\tversion: \"1.0.0\",\n\t\t\t\t});\n\n\t\t\t\tconst sseTransport = new SSEClientTransport(baseUrl, { requestInit });\n\t\t\t\tlogger.info({}, `[MCP Health] Connecting via SSE...`);\n\t\t\t\tawait client.connect(sseTransport);\n\t\t\t\tlogger.info({}, `[MCP Health] Connected successfully via SSE`);\n\n\t\t\t\t// Connection successful, get tools\n\t\t\t\tconst toolsResponse = await client.listTools();\n\n\t\t\t\t// Disconnect after getting tools\n\t\t\t\tawait client.close();\n\n\t\t\t\tif (toolsResponse && toolsResponse.tools) {\n\t\t\t\t\tconst response: HealthCheckResponse = {\n\t\t\t\t\t\tready: true,\n\t\t\t\t\t\ttools: toolsResponse.tools.map((tool) => ({\n\t\t\t\t\t\t\tname: tool.name,\n\t\t\t\t\t\t\tdescription: tool.description,\n\t\t\t\t\t\t\tinputSchema: tool.inputSchema,\n\t\t\t\t\t\t})),\n\t\t\t\t\t\tauthRequired: false,\n\t\t\t\t\t};\n\n\t\t\t\t\tconst res = new Response(JSON.stringify(response), {\n\t\t\t\t\t\tstatus: 200,\n\t\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t\t});\n\t\t\t\t\tclearTimeout(timeoutId);\n\t\t\t\t\treturn res;\n\t\t\t\t} else {\n\t\t\t\t\tconst res = new Response(\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\tready: false,\n\t\t\t\t\t\t\terror: \"Connected but no tools available\",\n\t\t\t\t\t\t\tauthRequired: false,\n\t\t\t\t\t\t} as HealthCheckResponse),\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tstatus: 503,\n\t\t\t\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t\t\t\t}\n\t\t\t\t\t);\n\t\t\t\t\tclearTimeout(timeoutId);\n\t\t\t\t\treturn res;\n\t\t\t\t}\n\t\t\t} catch (sseError) {\n\t\t\t\tlastError = sseError instanceof Error ? sseError : new Error(String(sseError));\n\t\t\t\t// Prefer the HTTP error when both failed so UI shows the primary failure (e.g., HTTP 500) instead\n\t\t\t\t// of the fallback SSE message.\n\t\t\t\tif (httpError) {\n\t\t\t\t\tlastError = new Error(\n\t\t\t\t\t\t`HTTP transport failed: ${httpError.message}; SSE fallback failed: ${lastError.message}`,\n\t\t\t\t\t\t{ cause: sseError instanceof Error ? sseError : undefined }\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tlogger.error(lastError, \"Both transports failed.\");\n\t\t\t}\n\t\t}\n\n\t\t// Both transports failed\n\t\tlet errorMessage = lastError?.message || \"Failed to connect to MCP server\";\n\n\t\t// Detect unauthorized to signal auth requirement\n\t\tconst lower = (errorMessage || \"\").toLowerCase();\n\t\tconst authRequired =\n\t\t\tlower.includes(\"unauthorized\") ||\n\t\t\tlower.includes(\"forbidden\") ||\n\t\t\tlower.includes(\"401\") ||\n\t\t\tlower.includes(\"403\");\n\n\t\t// Provide more helpful error messages\n\t\tif (authRequired) {\n\t\t\terrorMessage =\n\t\t\t\t\"Authentication required. Provide appropriate Authorization headers in the server configuration.\";\n\t\t} else if (errorMessage.includes(\"not valid JSON\")) {\n\t\t\terrorMessage =\n\t\t\t\t\"Server returned invalid response. This might not be a valid MCP endpoint. MCP servers should respond to POST requests at /mcp with JSON-RPC messages.\";\n\t\t} else if (errorMessage.includes(\"fetch failed\") || errorMessage.includes(\"ECONNREFUSED\")) {\n\t\t\terrorMessage = `Cannot connect to ${url}. Please verify the server is running and accessible.`;\n\t\t} else if (errorMessage.includes(\"CORS\")) {\n\t\t\terrorMessage = `CORS error. The MCP server needs to allow requests from this origin.`;\n\t\t}\n\n\t\tconst res = new Response(\n\t\t\tJSON.stringify({\n\t\t\t\tready: false,\n\t\t\t\terror: errorMessage,\n\t\t\t\tauthRequired,\n\t\t\t} as HealthCheckResponse),\n\t\t\t{\n\t\t\t\tstatus: 503,\n\t\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\t}\n\t\t);\n\t\tclearTimeout(timeoutId);\n\t\treturn res;\n\t} catch (error) {\n\t\tlogger.error(error, \"MCP health check failed\");\n\n\t\t// Clean up client if it exists\n\t\ttry {\n\t\t\tawait client?.close();\n\t\t} catch {\n\t\t\t// Ignore\n\t\t}\n\n\t\tconst response: HealthCheckResponse = {\n\t\t\tready: false,\n\t\t\terror: error instanceof Error ? error.message : \"Unknown error\",\n\t\t};\n\n\t\tconst res = new Response(JSON.stringify(response), {\n\t\t\tstatus: 503,\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t});\n\t\treturn res;\n\t}\n};\n"
  },
  {
    "path": "src/routes/api/mcp/servers/+server.ts",
    "content": "import type { MCPServer } from \"$lib/types/Tool\";\nimport { config } from \"$lib/server/config\";\n\nexport async function GET() {\n\t// Parse MCP_SERVERS environment variable\n\tconst mcpServersEnv = config.MCP_SERVERS || \"[]\";\n\n\tlet servers: Array<{ name: string; url: string; headers?: Record<string, string> }> = [];\n\n\ttry {\n\t\tservers = JSON.parse(mcpServersEnv);\n\t\tif (!Array.isArray(servers)) {\n\t\t\tservers = [];\n\t\t}\n\t} catch (error) {\n\t\tconsole.error(\"Failed to parse MCP_SERVERS env variable:\", error);\n\t\tservers = [];\n\t}\n\n\t// Convert internal server config to client MCPServer format\n\tconst mcpServers: MCPServer[] = servers.map((server) => ({\n\t\tid: `base-${server.name}`, // Stable ID based on name\n\t\tname: server.name,\n\t\turl: server.url,\n\t\ttype: \"base\" as const,\n\t\t// headers intentionally omitted\n\t\tisLocked: false, // Base servers can be toggled by users\n\t\tstatus: undefined, // Status determined client-side via health check\n\t}));\n\n\treturn Response.json(mcpServers);\n}\n"
  },
  {
    "path": "src/routes/api/models/+server.ts",
    "content": "import { models } from \"$lib/server/models\";\n\nexport async function GET() {\n\tconst res = models\n\t\t.filter((m) => m.unlisted == false)\n\t\t.map((model) => ({\n\t\t\tid: model.id,\n\t\t\tname: model.name,\n\t\t\twebsiteUrl: model.websiteUrl ?? \"https://huggingface.co\",\n\t\t\tmodelUrl: model.modelUrl ?? \"https://huggingface.co\",\n\t\t\t// tokenizer removed in this build\n\t\t\tdatasetName: model.datasetName,\n\t\t\tdatasetUrl: model.datasetUrl,\n\t\t\tdisplayName: model.displayName,\n\t\t\tdescription: model.description ?? \"\",\n\t\t\tlogoUrl: model.logoUrl,\n\t\t\tpromptExamples: model.promptExamples ?? [],\n\t\t\tpreprompt: model.preprompt ?? \"\",\n\t\t\tmultimodal: model.multimodal ?? false,\n\t\t\tsupportsTools: (model as unknown as { supportsTools?: boolean }).supportsTools ?? false,\n\t\t\tunlisted: model.unlisted ?? false,\n\t\t\thasInferenceAPI: model.hasInferenceAPI ?? false,\n\t\t}));\n\treturn Response.json(res);\n}\n"
  },
  {
    "path": "src/routes/api/transcribe/+server.ts",
    "content": "import { error, json } from \"@sveltejs/kit\";\nimport { config } from \"$lib/server/config\";\nimport { getApiToken } from \"$lib/server/apiToken\";\nimport { logger } from \"$lib/server/logger\";\n\nconst MAX_AUDIO_SIZE = 25 * 1024 * 1024; // 25MB\nconst TRANSCRIPTION_TIMEOUT = 60000; // 60 seconds\n\nconst ALLOWED_CONTENT_TYPES = [\n\t\"audio/webm\",\n\t\"audio/ogg\",\n\t\"audio/wav\",\n\t\"audio/flac\",\n\t\"audio/mpeg\",\n\t\"audio/mp4\",\n\t\"audio/x-wav\",\n];\n\nexport async function POST({ request, locals }) {\n\tconst transcriptionModel = config.get(\"TRANSCRIPTION_MODEL\");\n\n\tif (!transcriptionModel) {\n\t\tthrow error(503, \"Transcription is not configured\");\n\t}\n\n\tconst token = getApiToken(locals);\n\n\tif (!token) {\n\t\tthrow error(401, \"Authentication required\");\n\t}\n\n\tconst rawContentType = request.headers.get(\"content-type\") || \"\";\n\t// Normalize content-type: Safari sends \"audio/webm; codecs=opus\" (with space)\n\t// but HF API expects \"audio/webm;codecs=opus\" (no space)\n\tconst contentType = rawContentType.replace(/;\\s+/g, \";\");\n\tconst isAllowed = ALLOWED_CONTENT_TYPES.some((type) => contentType.includes(type));\n\n\tif (!isAllowed) {\n\t\tlogger.warn({ contentType }, \"Unsupported audio format for transcription\");\n\t\tthrow error(400, `Unsupported audio format: ${contentType}`);\n\t}\n\n\tconst contentLength = parseInt(request.headers.get(\"content-length\") || \"0\");\n\tif (contentLength > MAX_AUDIO_SIZE) {\n\t\tthrow error(413, \"Audio file too large (max 25MB)\");\n\t}\n\n\ttry {\n\t\tconst audioBuffer = await request.arrayBuffer();\n\n\t\tif (audioBuffer.byteLength > MAX_AUDIO_SIZE) {\n\t\t\tthrow error(413, \"Audio file too large (max 25MB)\");\n\t\t}\n\n\t\tconst baseUrl =\n\t\t\tconfig.get(\"TRANSCRIPTION_BASE_URL\") || \"https://router.huggingface.co/hf-inference/models\";\n\t\tconst apiUrl = `${baseUrl}/${transcriptionModel}`;\n\n\t\tconst controller = new AbortController();\n\t\tconst timeoutId = setTimeout(() => controller.abort(), TRANSCRIPTION_TIMEOUT);\n\n\t\tconst response = await fetch(apiUrl, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${token}`,\n\t\t\t\t\"Content-Type\": contentType,\n\t\t\t\t// Bill to organization if configured\n\t\t\t\t...(locals?.billingOrganization ? { \"X-HF-Bill-To\": locals.billingOrganization } : {}),\n\t\t\t},\n\t\t\tbody: audioBuffer,\n\t\t\tsignal: controller.signal,\n\t\t}).finally(() => clearTimeout(timeoutId));\n\n\t\tif (!response.ok) {\n\t\t\tconst errorText = await response.text();\n\t\t\tlogger.error(\n\t\t\t\t{ status: response.status, error: errorText, model: transcriptionModel },\n\t\t\t\t\"Whisper API error\"\n\t\t\t);\n\t\t\tthrow error(response.status, `Transcription failed: ${errorText}`);\n\t\t}\n\n\t\tconst result = await response.json();\n\n\t\t// Whisper API returns { text: \"transcribed text\" }\n\t\t// Filter out responses that only contain dots (e.g. \"...\" returned for silence/unclear audio)\n\t\tconst text = (result.text || \"\").trim();\n\t\tconst isOnlyDots = /^\\.+$/.test(text);\n\t\treturn json({ text: isOnlyDots ? \"\" : text });\n\t} catch (err) {\n\t\tif (err instanceof Error && err.name === \"AbortError\") {\n\t\t\tlogger.error({ model: transcriptionModel }, \"Transcription timeout\");\n\t\t\tthrow error(504, \"Transcription took too long. Please try a shorter recording.\");\n\t\t}\n\n\t\t// Re-throw SvelteKit errors\n\t\tif (err && typeof err === \"object\" && \"status\" in err) {\n\t\t\tthrow err;\n\t\t}\n\n\t\tlogger.error(err, \"Transcription error\");\n\t\tthrow error(500, \"Failed to transcribe audio\");\n\t}\n}\n"
  },
  {
    "path": "src/routes/api/user/+server.ts",
    "content": "export async function GET({ locals }) {\n\tif (locals.user) {\n\t\tconst res = {\n\t\t\tid: locals.user._id,\n\t\t\tusername: locals.user.username,\n\t\t\tname: locals.user.name,\n\t\t\temail: locals.user.email,\n\t\t\tavatarUrl: locals.user.avatarUrl,\n\t\t\thfUserId: locals.user.hfUserId,\n\t\t};\n\n\t\treturn Response.json(res);\n\t}\n\treturn Response.json({ message: \"Must be signed in\" }, { status: 401 });\n}\n"
  },
  {
    "path": "src/routes/api/user/validate-token/+server.ts",
    "content": "import { adminTokenManager } from \"$lib/server/adminToken\";\nimport { z } from \"zod\";\n\nconst validateTokenSchema = z.object({\n\ttoken: z.string(),\n});\n\nexport const POST = async ({ request, locals }) => {\n\tconst { success, data } = validateTokenSchema.safeParse(await request.json());\n\n\tif (!success) {\n\t\treturn new Response(JSON.stringify({ error: \"Invalid token\" }), { status: 400 });\n\t}\n\n\tif (adminTokenManager.checkToken(data.token, locals.sessionId)) {\n\t\treturn new Response(JSON.stringify({ valid: true }));\n\t}\n\n\treturn new Response(JSON.stringify({ valid: false }));\n};\n"
  },
  {
    "path": "src/routes/api/v2/conversations/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { requireAuth } from \"$lib/server/api/utils/requireAuth\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport type { Conversation } from \"$lib/types/Conversation\";\nimport { CONV_NUM_PER_PAGE } from \"$lib/constants/pagination\";\n\nexport const GET: RequestHandler = async ({ locals, url }) => {\n\trequireAuth(locals);\n\n\tconst pageSize = CONV_NUM_PER_PAGE;\n\tconst p = parseInt(url.searchParams.get(\"p\") ?? \"0\") || 0;\n\n\tconst convs = await collections.conversations\n\t\t.find(authCondition(locals))\n\t\t.project<Pick<Conversation, \"_id\" | \"title\" | \"updatedAt\" | \"model\">>({\n\t\t\ttitle: 1,\n\t\t\tupdatedAt: 1,\n\t\t\tmodel: 1,\n\t\t})\n\t\t.sort({ updatedAt: -1 })\n\t\t.skip(p * pageSize)\n\t\t.limit(pageSize + 1)\n\t\t.toArray();\n\n\tconst hasMore = convs.length > pageSize;\n\tconst res = (hasMore ? convs.slice(0, pageSize) : convs).map((conv) => ({\n\t\t_id: conv._id,\n\t\tid: conv._id, // legacy param iOS\n\t\ttitle: conv.title,\n\t\tupdatedAt: conv.updatedAt,\n\t\tmodel: conv.model,\n\t\tmodelId: conv.model, // legacy param iOS\n\t}));\n\n\treturn superjsonResponse({ conversations: res, hasMore });\n};\n\nexport const DELETE: RequestHandler = async ({ locals }) => {\n\trequireAuth(locals);\n\n\tconst res = await collections.conversations.deleteMany({\n\t\t...authCondition(locals),\n\t});\n\n\treturn superjsonResponse(res.deletedCount);\n};\n"
  },
  {
    "path": "src/routes/api/v2/conversations/[id]/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { requireAuth } from \"$lib/server/api/utils/requireAuth\";\nimport { resolveConversation } from \"$lib/server/api/utils/resolveConversation\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { ObjectId } from \"mongodb\";\nimport { validModelIdSchema } from \"$lib/server/models\";\n\nexport const GET: RequestHandler = async ({ locals, params, url }) => {\n\trequireAuth(locals);\n\n\tconst conversation = await resolveConversation(\n\t\tparams.id ?? \"\",\n\t\tlocals,\n\t\turl.searchParams.get(\"fromShare\")\n\t);\n\n\treturn superjsonResponse({\n\t\tmessages: conversation.messages,\n\t\ttitle: conversation.title,\n\t\tmodel: conversation.model,\n\t\tpreprompt: conversation.preprompt,\n\t\trootMessageId: conversation.rootMessageId,\n\t\tid: conversation._id.toString(),\n\t\tupdatedAt: conversation.updatedAt,\n\t\tmodelId: conversation.model,\n\t\tshared: conversation.shared,\n\t});\n};\n\nexport const DELETE: RequestHandler = async ({ locals, params }) => {\n\trequireAuth(locals);\n\n\tconst id = params.id ?? \"\";\n\tif (!ObjectId.isValid(id)) {\n\t\terror(400, \"Invalid conversation ID\");\n\t}\n\tconst res = await collections.conversations.deleteOne({\n\t\t_id: new ObjectId(id),\n\t\t...authCondition(locals),\n\t});\n\n\tif (res.deletedCount === 0) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\treturn superjsonResponse({ success: true });\n};\n\nexport const PATCH: RequestHandler = async ({ locals, params, request }) => {\n\trequireAuth(locals);\n\n\tconst body = await request.json();\n\tconst title = body?.title as string | undefined;\n\tconst model = body?.model as string | undefined;\n\n\tif (title !== undefined) {\n\t\tif (typeof title !== \"string\" || title.length === 0 || title.length > 100) {\n\t\t\terror(400, \"Title must be a string between 1 and 100 characters\");\n\t\t}\n\t}\n\n\tif (model !== undefined) {\n\t\tif (!validModelIdSchema.safeParse(model).success) {\n\t\t\terror(400, \"Invalid model ID\");\n\t\t}\n\t}\n\n\tconst updateValues = {\n\t\t...(title !== undefined && {\n\t\t\ttitle: title.replace(/<\\/?think>/gi, \"\").trim(),\n\t\t}),\n\t\t...(model !== undefined && { model }),\n\t};\n\n\tconst id = params.id ?? \"\";\n\tif (!ObjectId.isValid(id)) {\n\t\terror(400, \"Invalid conversation ID\");\n\t}\n\tconst res = await collections.conversations.updateOne(\n\t\t{\n\t\t\t_id: new ObjectId(id),\n\t\t\t...authCondition(locals),\n\t\t},\n\t\t{ $set: updateValues }\n\t);\n\n\tif (typeof res.matchedCount === \"number\" ? res.matchedCount === 0 : res.modifiedCount === 0) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\treturn superjsonResponse({ success: true });\n};\n"
  },
  {
    "path": "src/routes/api/v2/conversations/[id]/message/[messageId]/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { requireAuth } from \"$lib/server/api/utils/requireAuth\";\nimport { resolveConversation } from \"$lib/server/api/utils/resolveConversation\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { ObjectId } from \"mongodb\";\n\nexport const DELETE: RequestHandler = async ({ locals, params }) => {\n\trequireAuth(locals);\n\n\tconst id = params.id ?? \"\";\n\tconst messageId = params.messageId ?? \"\";\n\n\tconst conversation = await resolveConversation(id, locals);\n\n\tif (!conversation.messages.map((m) => m.id).includes(messageId)) {\n\t\terror(404, \"Message not found\");\n\t}\n\n\tconst filteredMessages = conversation.messages\n\t\t.filter(\n\t\t\t(message) =>\n\t\t\t\t!(message.id === messageId) && message.ancestors && !message.ancestors.includes(messageId)\n\t\t)\n\t\t.map((message) => {\n\t\t\tif (message.children && message.children.includes(messageId)) {\n\t\t\t\tmessage.children = message.children.filter((child) => child !== messageId);\n\t\t\t}\n\t\t\treturn message;\n\t\t});\n\n\tconst res = await collections.conversations.updateOne(\n\t\t{ _id: new ObjectId(conversation._id), ...authCondition(locals) },\n\t\t{ $set: { messages: filteredMessages } }\n\t);\n\n\tif (res.modifiedCount === 0) {\n\t\terror(500, \"Deleting message failed\");\n\t}\n\n\treturn superjsonResponse({ success: true });\n};\n"
  },
  {
    "path": "src/routes/api/v2/conversations/import-share/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { requireAuth } from \"$lib/server/api/utils/requireAuth\";\nimport { createConversationFromShare } from \"$lib/server/conversation\";\n\nexport const POST: RequestHandler = async ({ locals, request }) => {\n\trequireAuth(locals);\n\n\tconst body = await request.json();\n\tconst shareId = body?.shareId;\n\n\tif (!shareId || typeof shareId !== \"string\" || shareId.length === 0) {\n\t\terror(400, \"shareId is required\");\n\t}\n\n\tconst conversationId = await createConversationFromShare(\n\t\tshareId,\n\t\tlocals,\n\t\trequest.headers.get(\"User-Agent\") ?? undefined\n\t);\n\n\treturn superjsonResponse({ conversationId });\n};\n"
  },
  {
    "path": "src/routes/api/v2/debug/config/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { config } from \"$lib/server/config\";\nimport { requireAdmin } from \"$lib/server/api/utils/requireAuth\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\trequireAdmin(locals);\n\tconst { models } = await import(\"$lib/server/models\");\n\treturn superjsonResponse({\n\t\tOPENAI_BASE_URL: config.OPENAI_BASE_URL,\n\t\tOPENAI_API_KEY_SET: Boolean(config.OPENAI_API_KEY || config.HF_TOKEN),\n\t\tLEGACY_HF_TOKEN_SET: Boolean(config.HF_TOKEN && !config.OPENAI_API_KEY),\n\t\tMODELS_COUNT: models.length,\n\t\tNODE_VERSION: process.versions.node,\n\t});\n};\n"
  },
  {
    "path": "src/routes/api/v2/debug/refresh/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { config } from \"$lib/server/config\";\nimport { requireAdmin } from \"$lib/server/api/utils/requireAuth\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\trequireAdmin(locals);\n\tconst base = (config.OPENAI_BASE_URL || \"https://router.huggingface.co/v1\").replace(/\\/$/, \"\");\n\tconst res = await fetch(`${base}/models`);\n\tconst body = await res.text();\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(body);\n\t} catch {\n\t\tparsed = undefined;\n\t}\n\treturn superjsonResponse({\n\t\tstatus: res.status,\n\t\tok: res.ok,\n\t\tbase,\n\t\tlength: (() => {\n\t\t\tif (parsed && typeof parsed === \"object\" && \"data\" in parsed) {\n\t\t\t\tconst data = (parsed as { data?: unknown }).data;\n\t\t\t\treturn Array.isArray(data) ? data.length : null;\n\t\t\t}\n\t\t\treturn null;\n\t\t})(),\n\t\tsample: body.slice(0, 2000),\n\t});\n};\n"
  },
  {
    "path": "src/routes/api/v2/export/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { config } from \"$lib/server/config\";\nimport yazl from \"yazl\";\nimport { downloadFile } from \"$lib/server/files/downloadFile\";\nimport mimeTypes from \"mime-types\";\nimport { logger } from \"$lib/server/logger\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\tif (!locals.user) {\n\t\terror(401, \"Not logged in\");\n\t}\n\n\tif (!locals.isAdmin) {\n\t\terror(403, \"Not admin\");\n\t}\n\n\tif (config.ENABLE_DATA_EXPORT !== \"true\") {\n\t\terror(403, \"Data export is not enabled\");\n\t}\n\n\tconst nExports = await collections.messageEvents.countDocuments({\n\t\tuserId: locals.user._id,\n\t\ttype: \"export\",\n\t\texpiresAt: { $gt: new Date() },\n\t});\n\n\tif (nExports >= 1) {\n\t\terror(\n\t\t\t429,\n\t\t\t\"You have already exported your data recently. Please wait 1 hour before exporting again.\"\n\t\t);\n\t}\n\n\tconst stats: {\n\t\tnConversations: number;\n\t\tnMessages: number;\n\t\tnFiles: number;\n\t\tnAssistants: number;\n\t\tnAvatars: number;\n\t} = {\n\t\tnConversations: 0,\n\t\tnMessages: 0,\n\t\tnFiles: 0,\n\t\tnAssistants: 0,\n\t\tnAvatars: 0,\n\t};\n\n\tconst zipfile = new yazl.ZipFile();\n\n\tconst promises = [\n\t\tcollections.conversations\n\t\t\t.find({ ...authCondition(locals) })\n\t\t\t.toArray()\n\t\t\t.then(async (conversations) => {\n\t\t\t\tconst formattedConversations = await Promise.all(\n\t\t\t\t\tconversations.map(async (conversation) => {\n\t\t\t\t\t\tstats.nConversations++;\n\t\t\t\t\t\tconst hashes: string[] = [];\n\t\t\t\t\t\tconversation.messages.forEach(async (message) => {\n\t\t\t\t\t\t\tstats.nMessages++;\n\t\t\t\t\t\t\tif (message.files) {\n\t\t\t\t\t\t\t\tmessage.files.forEach((file) => {\n\t\t\t\t\t\t\t\t\thashes.push(file.value);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst files = await Promise.all(\n\t\t\t\t\t\t\thashes.map(async (hash) => {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tconst fileData = await downloadFile(hash, conversation._id);\n\t\t\t\t\t\t\t\t\treturn fileData;\n\t\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tconst filenames: string[] = [];\n\t\t\t\t\t\tfiles.forEach((file) => {\n\t\t\t\t\t\t\tif (!file) return;\n\n\t\t\t\t\t\t\tconst extension = mimeTypes.extension(file.mime) || null;\n\t\t\t\t\t\t\tconst convId = conversation._id.toString();\n\t\t\t\t\t\t\tconst fileId = file.name.split(\"-\")[1].slice(0, 8);\n\t\t\t\t\t\t\tconst fileName = `file-${convId}-${fileId}` + (extension ? `.${extension}` : \"\");\n\t\t\t\t\t\t\tfilenames.push(fileName);\n\t\t\t\t\t\t\tzipfile.addBuffer(Buffer.from(file.value, \"base64\"), fileName);\n\t\t\t\t\t\t\tstats.nFiles++;\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t...conversation,\n\t\t\t\t\t\t\tmessages: conversation.messages.map((message) => {\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\t...message,\n\t\t\t\t\t\t\t\t\tfiles: filenames,\n\t\t\t\t\t\t\t\t\tupdates: undefined,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t};\n\t\t\t\t\t})\n\t\t\t\t);\n\n\t\t\t\tzipfile.addBuffer(\n\t\t\t\t\tBuffer.from(JSON.stringify(formattedConversations, null, 2)),\n\t\t\t\t\t\"conversations.json\"\n\t\t\t\t);\n\t\t\t}),\n\t\tcollections.assistants\n\t\t\t.find({ createdById: locals.user._id })\n\t\t\t.toArray()\n\t\t\t.then(async (assistants) => {\n\t\t\t\tconst formattedAssistants = await Promise.all(\n\t\t\t\t\tassistants.map(async (assistant) => {\n\t\t\t\t\t\tif (assistant.avatar) {\n\t\t\t\t\t\t\tconst fileId = collections.bucket.find({\n\t\t\t\t\t\t\t\tfilename: assistant._id.toString(),\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tconst content = await fileId.next().then(async (file) => {\n\t\t\t\t\t\t\t\tif (!file?._id) return;\n\n\t\t\t\t\t\t\t\tconst fileStream = collections.bucket.openDownloadStream(file?._id);\n\n\t\t\t\t\t\t\t\tconst fileBuffer = await new Promise<Buffer>((resolve, reject) => {\n\t\t\t\t\t\t\t\t\tconst chunks: Uint8Array[] = [];\n\t\t\t\t\t\t\t\t\tfileStream.on(\"data\", (chunk) => chunks.push(chunk));\n\t\t\t\t\t\t\t\t\tfileStream.on(\"error\", reject);\n\t\t\t\t\t\t\t\t\tfileStream.on(\"end\", () => resolve(Buffer.concat(chunks)));\n\t\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t\treturn fileBuffer;\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tif (!content) return;\n\n\t\t\t\t\t\t\tzipfile.addBuffer(content, `avatar-${assistant._id.toString()}.jpg`);\n\t\t\t\t\t\t\tstats.nAvatars++;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tstats.nAssistants++;\n\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t_id: assistant._id.toString(),\n\t\t\t\t\t\t\tname: assistant.name,\n\t\t\t\t\t\t\tcreatedById: assistant.createdById.toString(),\n\t\t\t\t\t\t\tcreatedByName: assistant.createdByName,\n\t\t\t\t\t\t\tavatar: `avatar-${assistant._id.toString()}.jpg`,\n\t\t\t\t\t\t\tmodelId: assistant.modelId,\n\t\t\t\t\t\t\tpreprompt: assistant.preprompt,\n\t\t\t\t\t\t\tdescription: assistant.description,\n\t\t\t\t\t\t\tdynamicPrompt: assistant.dynamicPrompt,\n\t\t\t\t\t\t\texampleInputs: assistant.exampleInputs,\n\t\t\t\t\t\t\tgenerateSettings: assistant.generateSettings,\n\t\t\t\t\t\t\tcreatedAt: assistant.createdAt.toISOString(),\n\t\t\t\t\t\t\tupdatedAt: assistant.updatedAt.toISOString(),\n\t\t\t\t\t\t};\n\t\t\t\t\t})\n\t\t\t\t);\n\n\t\t\t\tzipfile.addBuffer(\n\t\t\t\t\tBuffer.from(JSON.stringify(formattedAssistants, null, 2)),\n\t\t\t\t\t\"assistants.json\"\n\t\t\t\t);\n\t\t\t}),\n\t];\n\n\tPromise.all(promises).then(async () => {\n\t\tlogger.info(\n\t\t\t{\n\t\t\t\tuserId: locals.user?._id,\n\t\t\t\t...stats,\n\t\t\t},\n\t\t\t\"Exported user data\"\n\t\t);\n\t\tzipfile.end();\n\t\tif (locals.user?._id) {\n\t\t\tawait collections.messageEvents.insertOne({\n\t\t\t\tuserId: locals.user?._id,\n\t\t\t\ttype: \"export\",\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\texpiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour\n\t\t\t});\n\t\t}\n\t});\n\n\t// @ts-expect-error - zipfile.outputStream is not typed correctly\n\treturn new Response(zipfile.outputStream, {\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/zip\",\n\t\t\t\"Content-Disposition\": 'attachment; filename=\"export.zip\"',\n\t\t},\n\t});\n};\n"
  },
  {
    "path": "src/routes/api/v2/feature-flags/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { loginEnabled } from \"$lib/server/auth\";\nimport { config } from \"$lib/server/config\";\nimport type { FeatureFlags } from \"$lib/server/api/types\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\treturn superjsonResponse({\n\t\tenableAssistants: config.ENABLE_ASSISTANTS === \"true\",\n\t\tloginEnabled,\n\t\tisAdmin: locals.isAdmin,\n\t\ttranscriptionEnabled: !!config.get(\"TRANSCRIPTION_MODEL\"),\n\t} satisfies FeatureFlags);\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport type { GETModelsResponse } from \"$lib/server/api/types\";\n\nexport const GET: RequestHandler = async () => {\n\ttry {\n\t\tconst { models } = await import(\"$lib/server/models\");\n\t\treturn superjsonResponse(\n\t\t\tmodels\n\t\t\t\t.filter((m) => m.unlisted == false)\n\t\t\t\t.map((model) => ({\n\t\t\t\t\tid: model.id,\n\t\t\t\t\tname: model.name,\n\t\t\t\t\twebsiteUrl: model.websiteUrl,\n\t\t\t\t\tmodelUrl: model.modelUrl,\n\t\t\t\t\tdatasetName: model.datasetName,\n\t\t\t\t\tdatasetUrl: model.datasetUrl,\n\t\t\t\t\tdisplayName: model.displayName,\n\t\t\t\t\tdescription: model.description,\n\t\t\t\t\tlogoUrl: model.logoUrl,\n\t\t\t\t\tproviders: model.providers as unknown as Array<\n\t\t\t\t\t\t{ provider: string } & Record<string, unknown>\n\t\t\t\t\t>,\n\t\t\t\t\tpromptExamples: model.promptExamples,\n\t\t\t\t\tparameters: model.parameters,\n\t\t\t\t\tpreprompt: model.preprompt,\n\t\t\t\t\tmultimodal: model.multimodal,\n\t\t\t\t\tmultimodalAcceptedMimetypes: model.multimodalAcceptedMimetypes,\n\t\t\t\t\tsupportsTools: (model as unknown as { supportsTools?: boolean }).supportsTools ?? false,\n\t\t\t\t\tunlisted: model.unlisted,\n\t\t\t\t\thasInferenceAPI: model.hasInferenceAPI,\n\t\t\t\t\tisRouter: model.isRouter,\n\t\t\t\t})) satisfies GETModelsResponse\n\t\t);\n\t} catch {\n\t\treturn superjsonResponse([] as GETModelsResponse);\n\t}\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/[namespace]/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { resolveModel } from \"$lib/server/api/utils/resolveModel\";\n\nexport const GET: RequestHandler = async ({ params }) => {\n\tconst model = await resolveModel(params.namespace ?? \"\");\n\treturn superjsonResponse(model);\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/[namespace]/[model]/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { resolveModel } from \"$lib/server/api/utils/resolveModel\";\n\nexport const GET: RequestHandler = async ({ params }) => {\n\tconst model = await resolveModel(params.namespace ?? \"\", params.model ?? \"\");\n\treturn superjsonResponse(model);\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/[namespace]/[model]/subscribe/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { resolveModel } from \"$lib/server/api/utils/resolveModel\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\n\nexport const POST: RequestHandler = async ({ params, locals }) => {\n\tif (!locals.sessionId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\n\tconst model = await resolveModel(params.namespace ?? \"\", params.model ?? \"\");\n\n\tawait collections.settings.updateOne(\n\t\tauthCondition(locals),\n\t\t{\n\t\t\t$set: {\n\t\t\t\tactiveModel: model.id,\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\t$setOnInsert: {\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t},\n\t\t},\n\t\t{ upsert: true }\n\t);\n\n\treturn new Response();\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/[namespace]/subscribe/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { resolveModel } from \"$lib/server/api/utils/resolveModel\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\n\nexport const POST: RequestHandler = async ({ params, locals }) => {\n\tif (!locals.sessionId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\n\tconst model = await resolveModel(params.namespace ?? \"\");\n\n\tawait collections.settings.updateOne(\n\t\tauthCondition(locals),\n\t\t{\n\t\t\t$set: {\n\t\t\t\tactiveModel: model.id,\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\t$setOnInsert: {\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t},\n\t\t},\n\t\t{ upsert: true }\n\t);\n\n\treturn new Response();\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/old/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport type { GETOldModelsResponse } from \"$lib/server/api/types\";\n\nexport const GET: RequestHandler = async () => {\n\treturn superjsonResponse([] as GETOldModelsResponse);\n};\n"
  },
  {
    "path": "src/routes/api/v2/models/refresh/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { requireAdmin } from \"$lib/server/api/utils/requireAuth\";\nimport { refreshModels, lastModelRefreshSummary } from \"$lib/server/models\";\n\nexport const POST: RequestHandler = async ({ locals }) => {\n\trequireAdmin(locals);\n\n\tconst previous = lastModelRefreshSummary;\n\n\ttry {\n\t\tconst summary = await refreshModels();\n\t\treturn superjsonResponse({\n\t\t\trefreshedAt: summary.refreshedAt.toISOString(),\n\t\t\tdurationMs: summary.durationMs,\n\t\t\tadded: summary.added,\n\t\t\tremoved: summary.removed,\n\t\t\tchanged: summary.changed,\n\t\t\ttotal: summary.total,\n\t\t\thadChanges:\n\t\t\t\tsummary.added.length > 0 || summary.removed.length > 0 || summary.changed.length > 0,\n\t\t\tprevious:\n\t\t\t\tprevious.refreshedAt.getTime() > 0\n\t\t\t\t\t? {\n\t\t\t\t\t\t\trefreshedAt: previous.refreshedAt.toISOString(),\n\t\t\t\t\t\t\ttotal: previous.total,\n\t\t\t\t\t\t}\n\t\t\t\t\t: null,\n\t\t});\n\t} catch {\n\t\terror(502, \"Model refresh failed\");\n\t}\n};\n"
  },
  {
    "path": "src/routes/api/v2/public-config/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { config } from \"$lib/server/config\";\n\nexport const GET: RequestHandler = async () => {\n\treturn superjsonResponse(await config.getPublicConfig());\n};\n"
  },
  {
    "path": "src/routes/api/v2/user/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\treturn superjsonResponse(\n\t\tlocals.user\n\t\t\t? {\n\t\t\t\t\tid: locals.user._id.toString(),\n\t\t\t\t\tusername: locals.user.username,\n\t\t\t\t\tavatarUrl: locals.user.avatarUrl,\n\t\t\t\t\temail: locals.user.email,\n\t\t\t\t\tisAdmin: locals.user.isAdmin ?? false,\n\t\t\t\t\tisEarlyAccess: locals.user.isEarlyAccess ?? false,\n\t\t\t\t}\n\t\t\t: null\n\t);\n};\n"
  },
  {
    "path": "src/routes/api/v2/user/billing-orgs/+server.ts",
    "content": "import { error, type RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { config } from \"$lib/server/config\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { logger } from \"$lib/server/logger\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\tif (!config.isHuggingChat) {\n\t\terror(404, \"Not available\");\n\t}\n\n\tif (!locals.user) {\n\t\terror(401, \"Login required\");\n\t}\n\n\tif (!locals.token) {\n\t\terror(401, \"OAuth token not available. Please log out and log back in.\");\n\t}\n\n\ttry {\n\t\tconst response = await fetch(\"https://huggingface.co/oauth/userinfo\", {\n\t\t\theaders: { Authorization: `Bearer ${locals.token}` },\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tlogger.error(`Failed to fetch billing orgs: ${response.status}`);\n\t\t\terror(502, \"Failed to fetch billing information\");\n\t\t}\n\n\t\tconst data = await response.json();\n\n\t\tconst settings = await collections.settings.findOne(authCondition(locals));\n\t\tconst currentBillingOrg = settings?.billingOrganization;\n\n\t\tconst billingOrgs = (data.orgs ?? [])\n\t\t\t.filter((org: { canPay?: boolean }) => org.canPay === true)\n\t\t\t.map((org: { sub: string; name: string; preferred_username: string }) => ({\n\t\t\t\tsub: org.sub,\n\t\t\t\tname: org.name,\n\t\t\t\tpreferred_username: org.preferred_username,\n\t\t\t}));\n\n\t\tconst isCurrentOrgValid =\n\t\t\t!currentBillingOrg ||\n\t\t\tbillingOrgs.some(\n\t\t\t\t(org: { preferred_username: string }) => org.preferred_username === currentBillingOrg\n\t\t\t);\n\n\t\tif (!isCurrentOrgValid && currentBillingOrg) {\n\t\t\tlogger.info(\n\t\t\t\t`Clearing invalid billingOrganization '${currentBillingOrg}' for user ${locals.user._id}`\n\t\t\t);\n\t\t\tawait collections.settings.updateOne(authCondition(locals), {\n\t\t\t\t$unset: { billingOrganization: \"\" },\n\t\t\t\t$set: { updatedAt: new Date() },\n\t\t\t});\n\t\t}\n\n\t\treturn superjsonResponse({\n\t\t\tuserCanPay: data.canPay ?? false,\n\t\t\torganizations: billingOrgs,\n\t\t\tcurrentBillingOrg: isCurrentOrgValid ? currentBillingOrg : undefined,\n\t\t});\n\t} catch (err) {\n\t\t// Re-throw SvelteKit HttpErrors\n\t\tif (err && typeof err === \"object\" && \"status\" in err) {\n\t\t\tthrow err;\n\t\t}\n\t\tlogger.error(err, \"Error fetching billing orgs:\");\n\t\terror(500, \"Internal server error\");\n\t}\n};\n"
  },
  {
    "path": "src/routes/api/v2/user/reports/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { collections } from \"$lib/server/database\";\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\tif (!locals.user || !locals.sessionId) {\n\t\treturn superjsonResponse([]);\n\t}\n\n\tconst reports = await collections.reports\n\t\t.find({\n\t\t\tcreatedBy: locals.user?._id ?? locals.sessionId,\n\t\t})\n\t\t.toArray();\n\n\treturn superjsonResponse(reports);\n};\n"
  },
  {
    "path": "src/routes/api/v2/user/settings/+server.ts",
    "content": "import type { RequestHandler } from \"@sveltejs/kit\";\nimport { superjsonResponse } from \"$lib/server/api/utils/superjsonResponse\";\nimport { collections } from \"$lib/server/database\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { requireAuth } from \"$lib/server/api/utils/requireAuth\";\nimport { defaultModel, models, validateModel } from \"$lib/server/models\";\nimport { DEFAULT_SETTINGS, type SettingsEditable } from \"$lib/types/Settings\";\nimport { resolveStreamingMode } from \"$lib/utils/messageUpdates\";\nimport { z } from \"zod\";\n\nconst settingsSchema = z.object({\n\tshareConversationsWithModelAuthors: z\n\t\t.boolean()\n\t\t.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),\n\twelcomeModalSeen: z.boolean().optional(),\n\tactiveModel: z.string().default(DEFAULT_SETTINGS.activeModel),\n\tcustomPrompts: z.record(z.string()).default({}),\n\tmultimodalOverrides: z.record(z.boolean()).default({}),\n\ttoolsOverrides: z.record(z.boolean()).default({}),\n\tproviderOverrides: z.record(z.string()).default({}),\n\tstreamingMode: z.enum([\"raw\", \"smooth\"]).optional(),\n\tdirectPaste: z.boolean().default(false),\n\thapticsEnabled: z.boolean().default(true),\n\thidePromptExamples: z.record(z.boolean()).default({}),\n\tbillingOrganization: z.string().optional(),\n});\n\nexport const GET: RequestHandler = async ({ locals }) => {\n\trequireAuth(locals);\n\tconst settings = await collections.settings.findOne(authCondition(locals));\n\n\tif (settings && !validateModel(models).safeParse(settings?.activeModel).success) {\n\t\tsettings.activeModel = defaultModel.id;\n\t\tawait collections.settings.updateOne(authCondition(locals), {\n\t\t\t$set: { activeModel: defaultModel.id },\n\t\t});\n\t}\n\n\t// if the model is unlisted, set the active model to the default model\n\tif (\n\t\tsettings?.activeModel &&\n\t\tmodels.find((m) => m.id === settings?.activeModel)?.unlisted === true\n\t) {\n\t\tsettings.activeModel = defaultModel.id;\n\t\tawait collections.settings.updateOne(authCondition(locals), {\n\t\t\t$set: { activeModel: defaultModel.id },\n\t\t});\n\t}\n\n\tconst streamingMode = resolveStreamingMode(settings ?? {});\n\n\treturn superjsonResponse({\n\t\twelcomeModalSeen: !!settings?.welcomeModalSeenAt,\n\t\twelcomeModalSeenAt: settings?.welcomeModalSeenAt ?? null,\n\n\t\tactiveModel: settings?.activeModel ?? DEFAULT_SETTINGS.activeModel,\n\t\tstreamingMode,\n\t\tdirectPaste: settings?.directPaste ?? DEFAULT_SETTINGS.directPaste,\n\t\thapticsEnabled: settings?.hapticsEnabled ?? DEFAULT_SETTINGS.hapticsEnabled,\n\t\thidePromptExamples: settings?.hidePromptExamples ?? DEFAULT_SETTINGS.hidePromptExamples,\n\t\tshareConversationsWithModelAuthors:\n\t\t\tsettings?.shareConversationsWithModelAuthors ??\n\t\t\tDEFAULT_SETTINGS.shareConversationsWithModelAuthors,\n\n\t\tcustomPrompts: settings?.customPrompts ?? {},\n\t\tmultimodalOverrides: settings?.multimodalOverrides ?? {},\n\t\ttoolsOverrides: settings?.toolsOverrides ?? {},\n\t\tproviderOverrides: settings?.providerOverrides ?? {},\n\t\tbillingOrganization: settings?.billingOrganization ?? undefined,\n\t});\n};\n\nexport const POST: RequestHandler = async ({ locals, request }) => {\n\trequireAuth(locals);\n\tconst body = await request.json();\n\n\tconst { welcomeModalSeen, ...parsedSettings } = settingsSchema.parse(body);\n\tconst streamingMode = resolveStreamingMode(parsedSettings);\n\n\tconst settings = {\n\t\t...parsedSettings,\n\t\tstreamingMode,\n\t} satisfies SettingsEditable;\n\n\tawait collections.settings.updateOne(\n\t\tauthCondition(locals),\n\t\t{\n\t\t\t$set: {\n\t\t\t\t...settings,\n\t\t\t\t...(welcomeModalSeen && { welcomeModalSeenAt: new Date() }),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\t$setOnInsert: {\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t},\n\t\t},\n\t\t{ upsert: true }\n\t);\n\n\treturn new Response();\n};\n"
  },
  {
    "path": "src/routes/conversation/+server.ts",
    "content": "import type { RequestHandler } from \"./$types\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { error, redirect } from \"@sveltejs/kit\";\nimport { base } from \"$app/paths\";\nimport { z } from \"zod\";\nimport type { Message } from \"$lib/types/Message\";\nimport { models, validateModel } from \"$lib/server/models\";\nimport { v4 } from \"uuid\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { usageLimits } from \"$lib/server/usageLimits\";\nimport { MetricsServer } from \"$lib/server/metrics\";\n\nexport const POST: RequestHandler = async ({ locals, request }) => {\n\tconst body = await request.text();\n\n\tlet title = \"\";\n\n\tconst parsedBody = z\n\t\t.object({\n\t\t\tfromShare: z.string().optional(),\n\t\t\tmodel: validateModel(models),\n\t\t\tpreprompt: z.string().optional(),\n\t\t})\n\t\t.safeParse(JSON.parse(body));\n\n\tif (!parsedBody.success) {\n\t\terror(400, \"Invalid request\");\n\t}\n\tconst values = parsedBody.data;\n\n\tconst convCount = await collections.conversations.countDocuments(authCondition(locals));\n\n\tif (usageLimits?.conversations && convCount > usageLimits?.conversations) {\n\t\terror(429, \"You have reached the maximum number of conversations. Delete some to continue.\");\n\t}\n\n\tconst model = models.find((m) => (m.id || m.name) === values.model);\n\n\tif (!model) {\n\t\terror(400, \"Invalid model\");\n\t}\n\n\tlet messages: Message[] = [\n\t\t{\n\t\t\tid: v4(),\n\t\t\tfrom: \"system\",\n\t\t\tcontent: values.preprompt ?? \"\",\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tchildren: [],\n\t\t\tancestors: [],\n\t\t},\n\t];\n\n\tlet rootMessageId: Message[\"id\"] = messages[0].id;\n\n\tif (values.fromShare) {\n\t\tconst conversation = await collections.sharedConversations.findOne({\n\t\t\t_id: values.fromShare,\n\t\t});\n\n\t\tif (!conversation) {\n\t\t\terror(404, \"Conversation not found\");\n\t\t}\n\n\t\t// Strip <think> markers from imported titles\n\t\ttitle = conversation.title.replace(/<\\/?think>/gi, \"\").trim();\n\t\tmessages = conversation.messages;\n\t\trootMessageId = conversation.rootMessageId ?? rootMessageId;\n\t\tvalues.model = conversation.model;\n\t\tvalues.preprompt = conversation.preprompt;\n\t}\n\n\tif (model.unlisted) {\n\t\terror(400, \"Can't start a conversation with an unlisted model\");\n\t}\n\n\t// use provided preprompt or model preprompt\n\tvalues.preprompt ??= model?.preprompt ?? \"\";\n\n\tif (messages && messages.length > 0 && messages[0].from === \"system\") {\n\t\tmessages[0].content = values.preprompt;\n\t}\n\n\tconst res = await collections.conversations.insertOne({\n\t\t_id: new ObjectId(),\n\t\t// Always store sanitized titles\n\t\ttitle: (title || \"New Chat\").replace(/<\\/?think>/gi, \"\").trim(),\n\t\trootMessageId,\n\t\tmessages,\n\t\tmodel: values.model,\n\t\tpreprompt: values.preprompt,\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\tuserAgent: request.headers.get(\"User-Agent\") ?? undefined,\n\t\t...(locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId }),\n\t\t...(values.fromShare ? { meta: { fromShareId: values.fromShare } } : {}),\n\t});\n\n\tif (MetricsServer.isEnabled()) {\n\t\tMetricsServer.getMetrics().model.conversationsTotal.inc({ model: values.model });\n\t}\n\n\treturn new Response(\n\t\tJSON.stringify({\n\t\t\tconversationId: res.insertedId.toString(),\n\t\t}),\n\t\t{ headers: { \"Content-Type\": \"application/json\" } }\n\t);\n};\n\nexport const GET: RequestHandler = async () => {\n\tredirect(302, `${base}/`);\n};\n"
  },
  {
    "path": "src/routes/conversation/[id]/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport ChatWindow from \"$lib/components/chat/ChatWindow.svelte\";\n\timport { pendingMessage } from \"$lib/stores/pendingMessage\";\n\timport { isAborted } from \"$lib/stores/isAborted\";\n\timport { onMount } from \"svelte\";\n\timport { page } from \"$app/state\";\n\timport { beforeNavigate, invalidateAll } from \"$app/navigation\";\n\timport { base } from \"$app/paths\";\n\timport { ERROR_MESSAGES, error } from \"$lib/stores/errors\";\n\timport { findCurrentModel } from \"$lib/utils/models\";\n\timport type { Message } from \"$lib/types/Message\";\n\timport { MessageUpdateStatus, MessageUpdateType } from \"$lib/types/MessageUpdate\";\n\timport titleUpdate from \"$lib/stores/titleUpdate\";\n\timport file2base64 from \"$lib/utils/file2base64\";\n\timport { addChildren } from \"$lib/utils/tree/addChildren\";\n\timport { addSibling } from \"$lib/utils/tree/addSibling\";\n\timport { fetchMessageUpdates, resolveStreamingMode } from \"$lib/utils/messageUpdates\";\n\timport type { v4 } from \"uuid\";\n\timport { useSettingsStore } from \"$lib/stores/settings.js\";\n\timport { enabledServers } from \"$lib/stores/mcpServers\";\n\timport { browser } from \"$app/environment\";\n\timport {\n\t\taddBackgroundGeneration,\n\t\tremoveBackgroundGeneration,\n\t} from \"$lib/stores/backgroundGenerations\";\n\timport type { TreeNode, TreeId } from \"$lib/utils/tree/tree\";\n\timport \"katex/dist/katex.min.css\";\n\timport { updateDebouncer } from \"$lib/utils/updates.js\";\n\timport SubscribeModal from \"$lib/components/SubscribeModal.svelte\";\n\timport { loading } from \"$lib/stores/loading.js\";\n\timport { streamStart } from \"$lib/utils/haptics\";\n\timport { requireAuthUser } from \"$lib/utils/auth.js\";\n\timport { isConversationGenerationActive } from \"$lib/utils/generationState\";\n\n\tlet { data = $bindable() } = $props();\n\n\tlet convId = $derived(page.params.id ?? \"\");\n\tlet pending = $state(false);\n\tlet initialRun = true;\n\tlet showSubscribeModal = $state(false);\n\tlet stopRequested = $state(false);\n\n\tlet files: File[] = $state([]);\n\n\tlet conversations = $state(data.conversations);\n\t$effect(() => {\n\t\tconversations = data.conversations;\n\t});\n\n\tfunction createMessagesPath<T>(messages: TreeNode<T>[], msgId?: TreeId): TreeNode<T>[] {\n\t\tif (initialRun) {\n\t\t\tif (!msgId && page.url.searchParams.get(\"leafId\")) {\n\t\t\t\tmsgId = page.url.searchParams.get(\"leafId\") as string;\n\t\t\t\tpage.url.searchParams.delete(\"leafId\");\n\t\t\t}\n\t\t\tif (!msgId && browser && localStorage.getItem(\"leafId\")) {\n\t\t\t\tmsgId = localStorage.getItem(\"leafId\") as string;\n\t\t\t}\n\t\t\tinitialRun = false;\n\t\t}\n\n\t\tconst msg = messages.find((msg) => msg.id === msgId) ?? messages.at(-1);\n\t\tif (!msg) return [];\n\t\t// ancestor path\n\t\tconst { ancestors } = msg;\n\t\tconst path = [];\n\t\tif (ancestors?.length) {\n\t\t\tfor (const ancestorId of ancestors) {\n\t\t\t\tconst ancestor = messages.find((msg) => msg.id === ancestorId);\n\t\t\t\tif (ancestor) {\n\t\t\t\t\tpath.push(ancestor);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// push the node itself in the middle\n\t\tpath.push(msg);\n\n\t\t// children path\n\t\tlet childrenIds = msg.children;\n\t\twhile (childrenIds?.length) {\n\t\t\tlet lastChildId = childrenIds.at(-1);\n\t\t\tconst lastChild = messages.find((msg) => msg.id === lastChildId);\n\t\t\tif (lastChild) {\n\t\t\t\tpath.push(lastChild);\n\t\t\t}\n\t\t\tchildrenIds = lastChild?.children;\n\t\t}\n\n\t\treturn path;\n\t}\n\n\tfunction createMessagesAlternatives<T>(messages: TreeNode<T>[]): TreeId[][] {\n\t\tconst alternatives = [];\n\t\tfor (const message of messages) {\n\t\t\tif (message.children?.length) {\n\t\t\t\talternatives.push(message.children);\n\t\t\t}\n\t\t}\n\t\treturn alternatives;\n\t}\n\n\t// this function is used to send new message to the backends\n\tasync function writeMessage({\n\t\tprompt,\n\t\tmessageId = messagesPath.at(-1)?.id ?? undefined,\n\t\tisRetry = false,\n\t}: {\n\t\tprompt?: string;\n\t\tmessageId?: ReturnType<typeof v4>;\n\t\tisRetry?: boolean;\n\t}): Promise<void> {\n\t\ttry {\n\t\t\tstopRequested = false;\n\t\t\t$isAborted = false;\n\t\t\t$loading = true;\n\t\t\tpending = true;\n\t\t\tconst base64Files = await Promise.all(\n\t\t\t\t(files ?? []).map((file) =>\n\t\t\t\t\tfile2base64(file).then((value) => ({\n\t\t\t\t\t\ttype: \"base64\" as const,\n\t\t\t\t\t\tvalue,\n\t\t\t\t\t\tmime: file.type,\n\t\t\t\t\t\tname: file.name,\n\t\t\t\t\t}))\n\t\t\t\t)\n\t\t\t);\n\n\t\t\tlet messageToWriteToId: Message[\"id\"] | undefined = undefined;\n\t\t\t// used for building the prompt, subtree of the conversation that goes from the latest message to the root\n\n\t\t\tif (isRetry && messageId) {\n\t\t\t\t// two cases, if we're retrying a user message with a newPrompt set,\n\t\t\t\t// it means we're editing a user message\n\t\t\t\t// if we're retrying on an assistant message, newPrompt cannot be set\n\t\t\t\t// it means we're retrying the last assistant message for a new answer\n\n\t\t\t\tconst messageToRetry = messages.find((message) => message.id === messageId);\n\n\t\t\t\tif (!messageToRetry) {\n\t\t\t\t\t$error = \"Message not found\";\n\t\t\t\t}\n\n\t\t\t\tif (messageToRetry?.from === \"user\" && prompt) {\n\t\t\t\t\t// add a sibling to this message from the user, with the alternative prompt\n\t\t\t\t\t// add a children to that sibling, where we can write to\n\t\t\t\t\tconst newUserMessageId = addSibling(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmessages,\n\t\t\t\t\t\t\trootMessageId: data.rootMessageId,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tfrom: \"user\",\n\t\t\t\t\t\t\tcontent: prompt,\n\t\t\t\t\t\t\tfiles: messageToRetry.files,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmessageId\n\t\t\t\t\t);\n\t\t\t\t\tmessageToWriteToId = addChildren(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmessages,\n\t\t\t\t\t\t\trootMessageId: data.rootMessageId,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ from: \"assistant\", content: \"\" },\n\t\t\t\t\t\tnewUserMessageId\n\t\t\t\t\t);\n\t\t\t\t} else if (messageToRetry?.from === \"assistant\") {\n\t\t\t\t\t// we're retrying an assistant message, to generate a new answer\n\t\t\t\t\t// just add a sibling to the assistant answer where we can write to\n\t\t\t\t\tmessageToWriteToId = addSibling(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmessages,\n\t\t\t\t\t\t\trootMessageId: data.rootMessageId,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ from: \"assistant\", content: \"\" },\n\t\t\t\t\t\tmessageId\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// just a normal linear conversation, so we add the user message\n\t\t\t\t// and the blank assistant message back to back\n\t\t\t\tconst newUserMessageId = addChildren(\n\t\t\t\t\t{\n\t\t\t\t\t\tmessages,\n\t\t\t\t\t\trootMessageId: data.rootMessageId,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tfrom: \"user\",\n\t\t\t\t\t\tcontent: prompt ?? \"\",\n\t\t\t\t\t\tfiles: base64Files,\n\t\t\t\t\t},\n\t\t\t\t\tmessageId\n\t\t\t\t);\n\n\t\t\t\tif (!data.rootMessageId) {\n\t\t\t\t\tdata.rootMessageId = newUserMessageId;\n\t\t\t\t}\n\n\t\t\t\tmessageToWriteToId = addChildren(\n\t\t\t\t\t{\n\t\t\t\t\t\tmessages,\n\t\t\t\t\t\trootMessageId: data.rootMessageId,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tfrom: \"assistant\",\n\t\t\t\t\t\tcontent: \"\",\n\t\t\t\t\t},\n\t\t\t\t\tnewUserMessageId\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst userMessage = messages.find((message) => message.id === messageId);\n\t\t\tconst messageToWriteTo = messages.find((message) => message.id === messageToWriteToId);\n\t\t\tif (!messageToWriteTo) {\n\t\t\t\tthrow new Error(\"Message to write to not found\");\n\t\t\t}\n\n\t\t\tconst messageUpdatesAbortController = new AbortController();\n\t\t\tconst streamingMode = resolveStreamingMode($settings);\n\n\t\t\tconst messageUpdatesIterator = await fetchMessageUpdates(\n\t\t\t\tconvId,\n\t\t\t\t{\n\t\t\t\t\tbase,\n\t\t\t\t\tinputs: prompt,\n\t\t\t\t\tmessageId,\n\t\t\t\t\tisRetry,\n\t\t\t\t\tfiles: isRetry ? userMessage?.files : base64Files,\n\t\t\t\t\tselectedMcpServerNames: $enabledServers.map((s) => s.name),\n\t\t\t\t\tselectedMcpServers: $enabledServers.map((s) => ({\n\t\t\t\t\t\tname: s.name,\n\t\t\t\t\t\turl: s.url,\n\t\t\t\t\t\theaders: s.headers,\n\t\t\t\t\t})),\n\t\t\t\t\tstreamingMode,\n\t\t\t\t},\n\t\t\t\tmessageUpdatesAbortController.signal\n\t\t\t).catch((err) => {\n\t\t\t\terror.set(err.message);\n\t\t\t});\n\t\t\tif (messageUpdatesIterator === undefined) return;\n\n\t\t\tfiles = [];\n\t\t\tlet buffer = \"\";\n\t\t\t// Initialize lastUpdateTime outside the loop to persist between updates\n\t\t\tlet lastUpdateTime = new Date();\n\t\t\tlet frameFlushScheduled = false;\n\n\t\t\tconst flushBuffer = (currentTime: Date) => {\n\t\t\t\tif (buffer.length === 0) return;\n\t\t\t\tmessageToWriteTo.content += buffer;\n\t\t\t\tbuffer = \"\";\n\t\t\t\tlastUpdateTime = currentTime;\n\t\t\t};\n\n\t\t\tconst scheduleFrameFlush = () => {\n\t\t\t\tif (frameFlushScheduled) return;\n\t\t\t\tframeFlushScheduled = true;\n\t\t\t\tconst flush = () => {\n\t\t\t\t\tframeFlushScheduled = false;\n\t\t\t\t\tflushBuffer(new Date());\n\t\t\t\t};\n\t\t\t\tif (typeof requestAnimationFrame === \"function\") {\n\t\t\t\t\trequestAnimationFrame(flush);\n\t\t\t\t} else {\n\t\t\t\t\tsetTimeout(flush, 0);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tfor await (const update of messageUpdatesIterator) {\n\t\t\t\tif ($isAborted) {\n\t\t\t\t\tmessageUpdatesAbortController.abort();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Remove null characters added due to remote keylogging prevention\n\t\t\t\t// See server code for more details\n\t\t\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\t\t\tupdate.token = update.token.replaceAll(\"\\0\", \"\");\n\t\t\t\t}\n\n\t\t\t\tconst isKeepAlive =\n\t\t\t\t\tupdate.type === MessageUpdateType.Status &&\n\t\t\t\t\tupdate.status === MessageUpdateStatus.KeepAlive;\n\n\t\t\t\tif (!isKeepAlive) {\n\t\t\t\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\t\t\t\tconst existingUpdates = messageToWriteTo.updates ?? [];\n\t\t\t\t\t\tconst lastUpdate = existingUpdates.at(-1);\n\t\t\t\t\t\tif (lastUpdate?.type === MessageUpdateType.Stream) {\n\t\t\t\t\t\t\t// Create fresh objects/arrays so the UI reacts to merged tokens\n\t\t\t\t\t\t\tconst merged = {\n\t\t\t\t\t\t\t\t...lastUpdate,\n\t\t\t\t\t\t\t\ttoken: (lastUpdate.token ?? \"\") + (update.token ?? \"\"),\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tmessageToWriteTo.updates = [...existingUpdates.slice(0, -1), merged];\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tmessageToWriteTo.updates = [...existingUpdates, update];\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tmessageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst currentTime = new Date();\n\n\t\t\t\t// If we receive a non-stream update (e.g. tool/status/final answer),\n\t\t\t\t// flush any buffered stream tokens so the UI doesn't appear to cut\n\t\t\t\t// mid-sentence while tools are running or the final answer arrives.\n\t\t\t\tif (update.type !== MessageUpdateType.Stream && buffer.length > 0) {\n\t\t\t\t\tflushBuffer(currentTime);\n\t\t\t\t}\n\n\t\t\t\tif (update.type === MessageUpdateType.Stream) {\n\t\t\t\t\tbuffer += update.token;\n\t\t\t\t\tif (streamingMode === \"smooth\") {\n\t\t\t\t\t\t// Coalesce UI updates to animation frames for smooth mode.\n\t\t\t\t\t\tscheduleFrameFlush();\n\t\t\t\t\t} else if (\n\t\t\t\t\t\tcurrentTime.getTime() - lastUpdateTime.getTime() >\n\t\t\t\t\t\tupdateDebouncer.maxUpdateTime\n\t\t\t\t\t) {\n\t\t\t\t\t\tflushBuffer(currentTime);\n\t\t\t\t\t}\n\t\t\t\t\tif (pending) {\n\t\t\t\t\t\tstreamStart();\n\t\t\t\t\t}\n\t\t\t\t\tpending = false;\n\t\t\t\t} else if (update.type === MessageUpdateType.FinalAnswer) {\n\t\t\t\t\t// Mirror server-side merge behavior so the UI reflects the\n\t\t\t\t\t// final text once tools complete, while preserving any\n\t\t\t\t\t// pre‑tool streamed content when appropriate.\n\t\t\t\t\tconst finalText = update.text ?? \"\";\n\t\t\t\t\tconst isInterrupted = update.interrupted === true;\n\t\t\t\t\tconst hadTools =\n\t\t\t\t\t\tmessageToWriteTo.updates?.some((u) => u.type === MessageUpdateType.Tool) ?? false;\n\n\t\t\t\t\tif (isInterrupted) {\n\t\t\t\t\t\t// Preserve streamed content on abort. If we never streamed, fall back to finalText.\n\t\t\t\t\t\tif (!messageToWriteTo.content) {\n\t\t\t\t\t\t\tmessageToWriteTo.content = finalText;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (hadTools) {\n\t\t\t\t\t\tconst existing = messageToWriteTo.content;\n\t\t\t\t\t\tconst trimmedExistingSuffix = existing.replace(/\\s+$/, \"\");\n\t\t\t\t\t\tconst trimmedFinalPrefix = finalText.replace(/^\\s+/, \"\");\n\t\t\t\t\t\tconst alreadyStreamed =\n\t\t\t\t\t\t\tfinalText &&\n\t\t\t\t\t\t\t(existing.endsWith(finalText) ||\n\t\t\t\t\t\t\t\t(trimmedFinalPrefix.length > 0 &&\n\t\t\t\t\t\t\t\t\ttrimmedExistingSuffix.endsWith(trimmedFinalPrefix)));\n\n\t\t\t\t\t\tif (existing && existing.length > 0) {\n\t\t\t\t\t\t\tif (alreadyStreamed) {\n\t\t\t\t\t\t\t\t// A. Already streamed the same final text; keep as-is.\n\t\t\t\t\t\t\t\tmessageToWriteTo.content = existing;\n\t\t\t\t\t\t\t} else if (\n\t\t\t\t\t\t\t\tfinalText &&\n\t\t\t\t\t\t\t\t(finalText.startsWith(existing) ||\n\t\t\t\t\t\t\t\t\t(trimmedExistingSuffix.length > 0 &&\n\t\t\t\t\t\t\t\t\t\ttrimmedFinalPrefix.startsWith(trimmedExistingSuffix)))\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\t// B. Final text already includes streamed prefix; use it verbatim.\n\t\t\t\t\t\t\t\tmessageToWriteTo.content = finalText;\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// C. Merge with a paragraph break for readability.\n\t\t\t\t\t\t\t\tconst needsGap = !/\\n\\n$/.test(existing) && !/^\\n/.test(finalText ?? \"\");\n\t\t\t\t\t\t\t\tmessageToWriteTo.content = existing + (needsGap ? \"\\n\\n\" : \"\") + finalText;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tmessageToWriteTo.content = finalText;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// No tools: final answer replaces streamed content so\n\t\t\t\t\t\t// the provider's final text is authoritative.\n\t\t\t\t\t\tmessageToWriteTo.content = finalText;\n\t\t\t\t\t}\n\t\t\t\t} else if (\n\t\t\t\t\tupdate.type === MessageUpdateType.Status &&\n\t\t\t\t\tupdate.status === MessageUpdateStatus.Error\n\t\t\t\t) {\n\t\t\t\t\t// Check if this is a 402 payment required error\n\t\t\t\t\tif (update.statusCode === 402) {\n\t\t\t\t\t\tshowSubscribeModal = true;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t$error = update.message ?? \"An error has occurred\";\n\t\t\t\t\t}\n\t\t\t\t} else if (update.type === MessageUpdateType.Title) {\n\t\t\t\t\tconst convInData = conversations.find(({ id }) => id === page.params.id);\n\t\t\t\t\tif (convInData) {\n\t\t\t\t\t\tconvInData.title = update.title;\n\n\t\t\t\t\t\t$titleUpdate = {\n\t\t\t\t\t\t\ttitle: update.title,\n\t\t\t\t\t\t\tconvId,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else if (update.type === MessageUpdateType.File) {\n\t\t\t\t\tmessageToWriteTo.files = [\n\t\t\t\t\t\t...(messageToWriteTo.files ?? []),\n\t\t\t\t\t\t{ type: \"hash\", value: update.sha, mime: update.mime, name: update.name },\n\t\t\t\t\t];\n\t\t\t\t} else if (update.type === MessageUpdateType.RouterMetadata) {\n\t\t\t\t\t// Update router metadata immediately when received\n\t\t\t\t\tmessageToWriteTo.routerMetadata = {\n\t\t\t\t\t\troute: update.route,\n\t\t\t\t\t\tmodel: update.model,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (buffer.length > 0) {\n\t\t\t\tflushBuffer(new Date());\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (err instanceof Error && err.message.includes(\"overloaded\")) {\n\t\t\t\t$error = \"Too much traffic, please try again.\";\n\t\t\t} else if (err instanceof Error && err.message.includes(\"429\")) {\n\t\t\t\t$error = ERROR_MESSAGES.rateLimited;\n\t\t\t} else if (err instanceof Error) {\n\t\t\t\t$error = err.message;\n\t\t\t} else {\n\t\t\t\t$error = ERROR_MESSAGES.default;\n\t\t\t}\n\t\t\tconsole.error(err);\n\t\t} finally {\n\t\t\t$loading = false;\n\t\t\tpending = false;\n\t\t\tawait invalidateAll();\n\t\t}\n\t}\n\n\tasync function stopGeneration() {\n\t\tstopRequested = true;\n\t\t$isAborted = true;\n\t\t$loading = false;\n\n\t\tconst sendStopRequest = async () => {\n\t\t\tconst response = await fetch(`${base}/conversation/${page.params.id}/stop-generating`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t});\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Stop request failed: ${response.status}`);\n\t\t\t}\n\t\t};\n\n\t\ttry {\n\t\t\tawait sendStopRequest();\n\t\t} catch (firstErr) {\n\t\t\ttry {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 300));\n\t\t\t\tawait sendStopRequest();\n\t\t\t} catch (retryErr) {\n\t\t\t\tconsole.error(\"Failed to stop generation\", firstErr, retryErr);\n\t\t\t\t$error = \"Failed to stop generation. Please try again.\";\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction handleKeydown(event: KeyboardEvent) {\n\t\t// Stop generation on ESC key when loading\n\t\tif (event.key === \"Escape\" && $loading) {\n\t\t\tevent.preventDefault();\n\t\t\tstopGeneration();\n\t\t}\n\t}\n\n\tonMount(async () => {\n\t\tif ($pendingMessage) {\n\t\t\tfiles = $pendingMessage.files;\n\t\t\tawait writeMessage({ prompt: $pendingMessage.content });\n\t\t\t$pendingMessage = undefined;\n\t\t}\n\n\t\tconst streaming = isConversationGenerationActive(messages);\n\t\tif (streaming) {\n\t\t\taddBackgroundGeneration({ id: convId, startedAt: Date.now() });\n\t\t\t$loading = true;\n\t\t}\n\t});\n\n\tasync function onMessage(content: string) {\n\t\tawait writeMessage({ prompt: content });\n\t}\n\n\tasync function onRetry(payload: { id: Message[\"id\"]; content?: string }) {\n\t\tif (requireAuthUser()) return;\n\n\t\tconst lastMsgId = payload.id;\n\t\tmessagesPath = createMessagesPath(messages, lastMsgId);\n\n\t\tawait writeMessage({\n\t\t\tprompt: payload.content,\n\t\t\tmessageId: payload.id,\n\t\t\tisRetry: true,\n\t\t});\n\t}\n\n\tasync function onShowAlternateMsg(payload: { id: Message[\"id\"] }) {\n\t\tconst msgId = payload.id;\n\t\tmessagesPath = createMessagesPath(messages, msgId);\n\t}\n\n\tconst settings = useSettingsStore();\n\tlet messages = $state(data.messages);\n\t$effect(() => {\n\t\tmessages = data.messages;\n\t});\n\n\t$effect(() => {\n\t\tpage.params.id;\n\t\tstopRequested = false;\n\t});\n\n\t$effect(() => {\n\t\tconst streaming = isConversationGenerationActive(messages);\n\t\tif (stopRequested) {\n\t\t\t$loading = false;\n\t\t} else if (streaming) {\n\t\t\t$loading = true;\n\t\t} else if (!pending) {\n\t\t\t$loading = false;\n\t\t}\n\n\t\tif (!streaming && browser) {\n\t\t\tremoveBackgroundGeneration(convId);\n\t\t}\n\t});\n\n\t// create a linear list of `messagesPath` from `messages` that is a tree of threaded messages\n\tlet messagesPath = $derived(createMessagesPath(messages));\n\tlet messagesAlternatives = $derived(createMessagesAlternatives(messages));\n\n\t$effect(() => {\n\t\tif (browser && messagesPath.at(-1)?.id) {\n\t\t\tlocalStorage.setItem(\"leafId\", messagesPath.at(-1)?.id as string);\n\t\t}\n\t});\n\n\tbeforeNavigate((navigation) => {\n\t\tif (!page.params.id) return;\n\n\t\tconst navigatingAway =\n\t\t\tnavigation.to?.route.id !== page.route.id || navigation.to?.params?.id !== page.params.id;\n\n\t\tif ($loading && navigatingAway) {\n\t\t\taddBackgroundGeneration({ id: page.params.id, startedAt: Date.now() });\n\t\t}\n\n\t\t$isAborted = true;\n\t\t$loading = false;\n\t});\n\n\tlet title = $derived.by(() => {\n\t\tconst rawTitle = conversations.find((conv) => conv.id === page.params.id)?.title ?? data.title;\n\t\treturn rawTitle ? rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1) : rawTitle;\n\t});\n</script>\n\n<svelte:window onkeydown={handleKeydown} />\n\n<svelte:head>\n\t<title>{title}</title>\n</svelte:head>\n\n<ChatWindow\n\tloading={$loading}\n\t{pending}\n\tmessages={messagesPath as Message[]}\n\t{messagesAlternatives}\n\tshared={data.shared}\n\tpreprompt={data.preprompt}\n\tbind:files\n\tonmessage={onMessage}\n\tonretry={onRetry}\n\tonshowAlternateMsg={onShowAlternateMsg}\n\tonstop={stopGeneration}\n\tmodels={data.models}\n\tcurrentModel={findCurrentModel(data.models, data.oldModels, data.model)}\n/>\n\n{#if showSubscribeModal}\n\t<SubscribeModal close={() => (showSubscribeModal = false)} />\n{/if}\n"
  },
  {
    "path": "src/routes/conversation/[id]/+page.ts",
    "content": "import { useAPIClient, handleResponse } from \"$lib/APIClient\";\nimport { UrlDependency } from \"$lib/types/UrlDependency\";\nimport { redirect } from \"@sveltejs/kit\";\nimport { base } from \"$app/paths\";\nimport type { PageLoad } from \"./$types\";\nimport type { Message } from \"$lib/types/Message\";\n\ninterface ConversationData {\n\tmessages: Message[];\n\ttitle: string;\n\tmodel: string;\n\tpreprompt?: string;\n\trootMessageId?: string;\n\tid: string;\n\tupdatedAt: Date;\n\tmodelId: string;\n\tshared: boolean;\n}\n\nexport const load: PageLoad = async ({ params, depends, fetch, url, parent }) => {\n\tdepends(UrlDependency.Conversation);\n\n\tconst client = useAPIClient({ fetch, origin: url.origin });\n\n\t// Handle share import for logged-in users (7-char IDs are share IDs)\n\tif (params.id.length === 7) {\n\t\tconst parentData = await parent();\n\n\t\tif (parentData.loginEnabled && parentData.user) {\n\t\t\tconst leafId = url.searchParams.get(\"leafId\");\n\n\t\t\tlet importedConversationId: string | undefined;\n\t\t\ttry {\n\t\t\t\tconst result = await client.conversations[\"import-share\"]\n\t\t\t\t\t.post({ shareId: params.id })\n\t\t\t\t\t.then(handleResponse);\n\t\t\t\timportedConversationId = result.conversationId;\n\t\t\t} catch {\n\t\t\t\t// Import failed, continue to load shared conversation for viewing\n\t\t\t}\n\n\t\t\tif (importedConversationId) {\n\t\t\t\tredirect(\n\t\t\t\t\t302,\n\t\t\t\t\t`${base}/conversation/${importedConversationId}?leafId=${leafId ?? \"\"}&fromShare=${params.id}`\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Load conversation (works for both owned and shared conversations)\n\ttry {\n\t\treturn (await client\n\t\t\t.conversations({ id: params.id })\n\t\t\t.get({ query: { fromShare: url.searchParams.get(\"fromShare\") ?? undefined } })\n\t\t\t.then(handleResponse)) as ConversationData;\n\t} catch {\n\t\tredirect(302, `${base}/`);\n\t}\n};\n"
  },
  {
    "path": "src/routes/conversation/[id]/+server.ts",
    "content": "import { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { config } from \"$lib/server/config\";\nimport { models, validModelIdSchema } from \"$lib/server/models\";\nimport { ERROR_MESSAGES } from \"$lib/stores/errors\";\nimport type { Message } from \"$lib/types/Message\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\nimport { z } from \"zod\";\nimport {\n\tMessageUpdateStatus,\n\tMessageUpdateType,\n\tMessageReasoningUpdateType,\n\ttype MessageUpdate,\n\ttype MessageStreamUpdate,\n} from \"$lib/types/MessageUpdate\";\nimport { uploadFile } from \"$lib/server/files/uploadFile\";\nimport { convertLegacyConversation } from \"$lib/utils/tree/convertLegacyConversation\";\nimport { isMessageId } from \"$lib/utils/tree/isMessageId\";\nimport { buildSubtree } from \"$lib/utils/tree/buildSubtree.js\";\nimport { addChildren } from \"$lib/utils/tree/addChildren.js\";\nimport { addSibling } from \"$lib/utils/tree/addSibling.js\";\nimport { usageLimits } from \"$lib/server/usageLimits\";\nimport { textGeneration } from \"$lib/server/textGeneration\";\nimport type { TextGenerationContext } from \"$lib/server/textGeneration/types\";\nimport { logger } from \"$lib/server/logger.js\";\nimport { AbortRegistry } from \"$lib/server/abortRegistry\";\nimport { MetricsServer } from \"$lib/server/metrics\";\n\nexport async function POST({ request, locals, params, getClientAddress }) {\n\tconst id = z.string().parse(params.id);\n\tconst convId = new ObjectId(id);\n\tconst promptedAt = new Date();\n\n\tconst userId = locals.user?._id ?? locals.sessionId;\n\n\t// check user\n\tif (!userId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\n\t// check if the user has access to the conversation\n\tconst convBeforeCheck = await collections.conversations.findOne({\n\t\t_id: convId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (convBeforeCheck && !convBeforeCheck.rootMessageId) {\n\t\tconst res = await collections.conversations.updateOne(\n\t\t\t{\n\t\t\t\t_id: convId,\n\t\t\t},\n\t\t\t{\n\t\t\t\t$set: {\n\t\t\t\t\t...convBeforeCheck,\n\t\t\t\t\t...convertLegacyConversation(convBeforeCheck),\n\t\t\t\t},\n\t\t\t}\n\t\t);\n\n\t\tif (!res.acknowledged) {\n\t\t\terror(500, \"Failed to convert conversation\");\n\t\t}\n\t}\n\n\tconst conv = await collections.conversations.findOne({\n\t\t_id: convId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (!conv) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\t// register the event for ratelimiting\n\tawait collections.messageEvents.insertOne({\n\t\ttype: \"message\",\n\t\tuserId,\n\t\tcreatedAt: new Date(),\n\t\texpiresAt: new Date(Date.now() + 60_000),\n\t\tip: getClientAddress(),\n\t});\n\n\tif (usageLimits?.messagesPerMinute) {\n\t\t// check if the user is rate limited\n\t\tconst nEvents = Math.max(\n\t\t\tawait collections.messageEvents.countDocuments({\n\t\t\t\tuserId,\n\t\t\t\ttype: \"message\",\n\t\t\t\texpiresAt: { $gt: new Date() },\n\t\t\t}),\n\t\t\tawait collections.messageEvents.countDocuments({\n\t\t\t\tip: getClientAddress(),\n\t\t\t\ttype: \"message\",\n\t\t\t\texpiresAt: { $gt: new Date() },\n\t\t\t})\n\t\t);\n\t\tif (nEvents > usageLimits.messagesPerMinute) {\n\t\t\terror(429, ERROR_MESSAGES.rateLimited);\n\t\t}\n\t}\n\n\tif (usageLimits?.messages && conv.messages.length > usageLimits.messages) {\n\t\terror(\n\t\t\t429,\n\t\t\t`This conversation has more than ${usageLimits.messages} messages. Start a new one to continue`\n\t\t);\n\t}\n\n\t// fetch the model\n\tconst model = models.find((m) => m.id === conv.model);\n\n\tif (!model) {\n\t\terror(410, \"Model not available anymore\");\n\t}\n\n\t// finally parse the content of the request\n\tconst form = await request.formData();\n\n\tconst json = form.get(\"data\");\n\n\tif (!json || typeof json !== \"string\") {\n\t\terror(400, \"Invalid request\");\n\t}\n\n\tconst {\n\t\tinputs: newPrompt,\n\t\tid: messageId,\n\t\tis_retry: isRetry,\n\t\tselectedMcpServerNames,\n\t\tselectedMcpServers,\n\t} = z\n\t\t.object({\n\t\t\tid: z.string().uuid().refine(isMessageId).optional(), // parent message id to append to for a normal message, or the message id for a retry/continue\n\t\t\tinputs: z.optional(\n\t\t\t\tz\n\t\t\t\t\t.string()\n\t\t\t\t\t.min(1)\n\t\t\t\t\t.transform((s) => s.replace(/\\r\\n/g, \"\\n\"))\n\t\t\t),\n\t\t\tis_retry: z.optional(z.boolean()),\n\t\t\tselectedMcpServerNames: z.optional(z.array(z.string())),\n\t\t\tselectedMcpServers: z\n\t\t\t\t.optional(\n\t\t\t\t\tz.array(\n\t\t\t\t\t\tz.object({\n\t\t\t\t\t\t\tname: z.string(),\n\t\t\t\t\t\t\turl: z.string(),\n\t\t\t\t\t\t\theaders: z\n\t\t\t\t\t\t\t\t.optional(z.array(z.object({ key: z.string(), value: z.string() })))\n\t\t\t\t\t\t\t\t.default([]),\n\t\t\t\t\t\t})\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t.default([]),\n\t\t\tfiles: z.optional(\n\t\t\t\tz.array(\n\t\t\t\t\tz.object({\n\t\t\t\t\t\ttype: z.literal(\"base64\").or(z.literal(\"hash\")),\n\t\t\t\t\t\tname: z.string(),\n\t\t\t\t\t\tvalue: z.string(),\n\t\t\t\t\t\tmime: z.string(),\n\t\t\t\t\t})\n\t\t\t\t)\n\t\t\t),\n\t\t})\n\t\t.parse(JSON.parse(json));\n\n\t// Attach MCP selection to locals so the text generation pipeline can consume it\n\ttry {\n\t\t(locals as unknown as Record<string, unknown>).mcp = {\n\t\t\tselectedServerNames: selectedMcpServerNames,\n\t\t\tselectedServers: (selectedMcpServers ?? []).map((s) => ({\n\t\t\t\tname: s.name,\n\t\t\t\turl: s.url,\n\t\t\t\theaders:\n\t\t\t\t\ts.headers && s.headers.length > 0\n\t\t\t\t\t\t? Object.fromEntries(s.headers.map((h) => [h.key, h.value]))\n\t\t\t\t\t\t: undefined,\n\t\t\t})),\n\t\t};\n\t} catch {\n\t\t// ignore attachment errors, pipeline will just use env servers\n\t}\n\n\tconst inputFiles = await Promise.all(\n\t\tform\n\t\t\t.getAll(\"files\")\n\t\t\t.filter((entry): entry is File => entry instanceof File && entry.size > 0)\n\t\t\t.map(async (file) => {\n\t\t\t\tconst [type, ...name] = file.name.split(\";\");\n\n\t\t\t\treturn {\n\t\t\t\t\ttype: z.literal(\"base64\").or(z.literal(\"hash\")).parse(type),\n\t\t\t\t\tvalue: await file.text(),\n\t\t\t\t\tmime: file.type,\n\t\t\t\t\tname: name.join(\";\"),\n\t\t\t\t};\n\t\t\t})\n\t);\n\n\tif (usageLimits?.messageLength && (newPrompt?.length ?? 0) > usageLimits.messageLength) {\n\t\terror(400, \"Message too long.\");\n\t}\n\n\t// each file is either:\n\t// base64 string requiring upload to the server\n\t// hash pointing to an existing file\n\tconst hashFiles = inputFiles?.filter((file) => file.type === \"hash\") ?? [];\n\tconst b64Files =\n\t\tinputFiles\n\t\t\t?.filter((file) => file.type !== \"hash\")\n\t\t\t.map((file) => {\n\t\t\t\tconst blob = Buffer.from(file.value, \"base64\");\n\t\t\t\treturn new File([blob], file.name, { type: file.mime });\n\t\t\t}) ?? [];\n\n\t// check sizes\n\t// todo: make configurable\n\tif (b64Files.some((file) => file.size > 10 * 1024 * 1024)) {\n\t\terror(413, \"File too large, should be <10MB\");\n\t}\n\n\tconst uploadedFiles = await Promise.all(b64Files.map((file) => uploadFile(file, conv))).then(\n\t\t(files) => [...files, ...hashFiles]\n\t);\n\n\t// we will append tokens to the content of this message\n\tlet messageToWriteToId: Message[\"id\"] | undefined = undefined;\n\t// used for building the prompt, subtree of the conversation that goes from the latest message to the root\n\tlet messagesForPrompt: Message[] = [];\n\n\tif (isRetry && messageId) {\n\t\t// two cases, if we're retrying a user message with a newPrompt set,\n\t\t// it means we're editing a user message\n\t\t// if we're retrying on an assistant message, newPrompt cannot be set\n\t\t// it means we're retrying the last assistant message for a new answer\n\n\t\tconst messageToRetry = conv.messages.find((message) => message.id === messageId);\n\n\t\tif (!messageToRetry) {\n\t\t\terror(404, \"Message not found\");\n\t\t}\n\n\t\tif (messageToRetry.from === \"user\" && newPrompt) {\n\t\t\t// add a sibling to this message from the user, with the alternative prompt\n\t\t\t// add a children to that sibling, where we can write to\n\t\t\tconst newUserMessageId = addSibling(\n\t\t\t\tconv,\n\t\t\t\t{\n\t\t\t\t\tfrom: \"user\",\n\t\t\t\t\tcontent: newPrompt,\n\t\t\t\t\tfiles: uploadedFiles,\n\t\t\t\t\tcreatedAt: new Date(),\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t},\n\t\t\t\tmessageId\n\t\t\t);\n\t\t\tmessageToWriteToId = addChildren(\n\t\t\t\tconv,\n\t\t\t\t{\n\t\t\t\t\tfrom: \"assistant\",\n\t\t\t\t\tcontent: \"\",\n\t\t\t\t\tcreatedAt: new Date(),\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t},\n\t\t\t\tnewUserMessageId\n\t\t\t);\n\t\t\tmessagesForPrompt = buildSubtree(conv, newUserMessageId);\n\t\t} else if (messageToRetry.from === \"assistant\") {\n\t\t\t// we're retrying an assistant message, to generate a new answer\n\t\t\t// just add a sibling to the assistant answer where we can write to\n\t\t\tmessageToWriteToId = addSibling(\n\t\t\t\tconv,\n\t\t\t\t{ from: \"assistant\", content: \"\", createdAt: new Date(), updatedAt: new Date() },\n\t\t\t\tmessageId\n\t\t\t);\n\t\t\tmessagesForPrompt = buildSubtree(conv, messageId);\n\t\t\tmessagesForPrompt.pop(); // don't need the latest assistant message in the prompt since we're retrying it\n\t\t}\n\t} else {\n\t\t// just a normal linear conversation, so we add the user message\n\t\t// and the blank assistant message back to back\n\t\tconst newUserMessageId = addChildren(\n\t\t\tconv,\n\t\t\t{\n\t\t\t\tfrom: \"user\",\n\t\t\t\tcontent: newPrompt ?? \"\",\n\t\t\t\tfiles: uploadedFiles,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\tmessageId\n\t\t);\n\n\t\tmessageToWriteToId = addChildren(\n\t\t\tconv,\n\t\t\t{\n\t\t\t\tfrom: \"assistant\",\n\t\t\t\tcontent: \"\",\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\tnewUserMessageId\n\t\t);\n\t\t// build the prompt from the user message\n\t\tmessagesForPrompt = buildSubtree(conv, newUserMessageId);\n\t}\n\n\tconst messageToWriteTo = conv.messages.find((message) => message.id === messageToWriteToId);\n\tif (!messageToWriteTo) {\n\t\terror(500, \"Failed to create message\");\n\t}\n\tif (messagesForPrompt.length === 0) {\n\t\terror(500, \"Failed to create prompt\");\n\t}\n\n\t// update the conversation with the new messages\n\tawait collections.conversations.updateOne(\n\t\t{ _id: convId },\n\t\t{ $set: { messages: conv.messages, title: conv.title, updatedAt: new Date() } }\n\t);\n\n\tlet doneStreaming = false;\n\tlet clientDetached = false;\n\n\tlet lastTokenTimestamp: undefined | Date = undefined;\n\tlet firstTokenObserved = false;\n\tconst metricsEnabled = MetricsServer.isEnabled();\n\tconst metrics = metricsEnabled ? MetricsServer.getMetrics() : undefined;\n\tconst metricsModelId = model.id ?? model.name ?? conv.model;\n\tconst metricsLabels = { model: metricsModelId };\n\n\tconst persistConversation = async () => {\n\t\tconst messagesForSave = conv.messages.map((msg) => {\n\t\t\tconst filteredUpdates =\n\t\t\t\tmsg.updates\n\t\t\t\t\t?.filter(\n\t\t\t\t\t\t(u) =>\n\t\t\t\t\t\t\t!(u.type === MessageUpdateType.Status && u.status === MessageUpdateStatus.KeepAlive)\n\t\t\t\t\t)\n\t\t\t\t\t.map((u) => {\n\t\t\t\t\t\tif (u.type !== MessageUpdateType.Stream) return u;\n\t\t\t\t\t\t// Preserve existing len if already compressed, otherwise compute from token\n\t\t\t\t\t\tconst len = u.len ?? (u.token ?? \"\").length;\n\t\t\t\t\t\t// store a lightweight marker to preserve ordering without duplicating content\n\t\t\t\t\t\treturn { type: MessageUpdateType.Stream, token: \"\", len } satisfies MessageStreamUpdate;\n\t\t\t\t\t}) ?? [];\n\n\t\t\treturn { ...msg, updates: filteredUpdates };\n\t\t});\n\n\t\tawait collections.conversations.updateOne(\n\t\t\t{ _id: convId },\n\t\t\t{ $set: { messages: messagesForSave, title: conv.title, updatedAt: new Date() } }\n\t\t);\n\t};\n\n\tconst abortRegistry = AbortRegistry.getInstance();\n\n\t// we now build the stream\n\tconst stream = new ReadableStream({\n\t\tasync start(controller) {\n\t\t\tconst conversationKey = convId.toString();\n\t\t\tconst ctrl = new AbortController();\n\t\t\tabortRegistry.register(conversationKey, ctrl);\n\n\t\t\tlet finalAnswerReceived = false;\n\t\t\tlet abortedByUser = false;\n\t\t\tlet finishedStatusSent = false;\n\n\t\t\tmessageToWriteTo.updates ??= [];\n\t\t\tasync function update(event: MessageUpdate) {\n\t\t\t\tif (!messageToWriteTo || !conv) {\n\t\t\t\t\tthrow Error(\"No message or conversation to write events to\");\n\t\t\t\t}\n\n\t\t\t\tif (\n\t\t\t\t\tevent.type === MessageUpdateType.Status &&\n\t\t\t\t\tevent.status === MessageUpdateStatus.Finished\n\t\t\t\t) {\n\t\t\t\t\tfinishedStatusSent = true;\n\t\t\t\t}\n\n\t\t\t\t// Add token to content or skip if empty\n\t\t\t\tif (event.type === MessageUpdateType.Stream) {\n\t\t\t\t\tif (event.token === \"\") return;\n\t\t\t\t\tmessageToWriteTo.content += event.token;\n\n\t\t\t\t\tif (metricsEnabled && metrics) {\n\t\t\t\t\t\tconst now = Date.now();\n\t\t\t\t\t\tmetrics.model.tokenCountTotal.inc(metricsLabels);\n\n\t\t\t\t\t\tif (!firstTokenObserved) {\n\t\t\t\t\t\t\tmetrics.model.timeToFirstToken.observe(metricsLabels, now - promptedAt.getTime());\n\t\t\t\t\t\t\tfirstTokenObserved = true;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tconst previousTimestamp = lastTokenTimestamp\n\t\t\t\t\t\t\t? lastTokenTimestamp.getTime()\n\t\t\t\t\t\t\t: promptedAt.getTime();\n\t\t\t\t\t\tmetrics.model.timePerOutputToken.observe(metricsLabels, now - previousTimestamp);\n\t\t\t\t\t}\n\n\t\t\t\t\tlastTokenTimestamp = new Date();\n\t\t\t\t}\n\n\t\t\t\t// Append reasoning stream tokens to message.reasoning (server-side)\n\t\t\t\telse if (\n\t\t\t\t\tevent.type === MessageUpdateType.Reasoning &&\n\t\t\t\t\tevent.subtype === MessageReasoningUpdateType.Stream &&\n\t\t\t\t\t\"token\" in event\n\t\t\t\t) {\n\t\t\t\t\tmessageToWriteTo.reasoning ??= \"\";\n\t\t\t\t\tmessageToWriteTo.reasoning += event.token;\n\t\t\t\t}\n\n\t\t\t\t// Set the title\n\t\t\t\telse if (event.type === MessageUpdateType.Title) {\n\t\t\t\t\t// Always strip <think> markers from titles when saving\n\t\t\t\t\tconst sanitizedTitle = event.title.replace(/<\\/?think>/gi, \"\").trim();\n\t\t\t\t\tconv.title = sanitizedTitle;\n\t\t\t\t\tawait collections.conversations.updateOne(\n\t\t\t\t\t\t{ _id: convId },\n\t\t\t\t\t\t{ $set: { title: conv?.title, updatedAt: new Date() } }\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Set the final text and the interrupted flag\n\t\t\t\telse if (event.type === MessageUpdateType.FinalAnswer) {\n\t\t\t\t\tmessageToWriteTo.interrupted = event.interrupted;\n\t\t\t\t\t// Default behavior: replace the streamed text with the provider's final text.\n\t\t\t\t\t// However, when tools (MCP/function calls) were used, providers often stream\n\t\t\t\t\t// some content (e.g., a story) before triggering tools, then return a\n\t\t\t\t\t// different follow‑up message afterwards (e.g., an image caption). Our\n\t\t\t\t\t// previous logic overwrote the pre‑tool content. Preserve it by merging in\n\t\t\t\t\t// the pre‑tool stream when tool updates occurred and the final text does\n\t\t\t\t\t// not already include the streamed prefix.\n\t\t\t\t\tconst hadTools = (messageToWriteTo.updates ?? []).some(\n\t\t\t\t\t\t(u) => u.type === MessageUpdateType.Tool\n\t\t\t\t\t);\n\n\t\t\t\t\tif (hadTools) {\n\t\t\t\t\t\tconst existing = messageToWriteTo.content.slice(initialMessageContent.length);\n\t\t\t\t\t\tif (existing && existing.length > 0) {\n\t\t\t\t\t\t\t// A. If we already streamed the same final text, keep as-is.\n\t\t\t\t\t\t\tif (event.text && existing.endsWith(event.text)) {\n\t\t\t\t\t\t\t\tmessageToWriteTo.content = initialMessageContent + existing;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// B. If the final text already includes the streamed prefix, use it verbatim.\n\t\t\t\t\t\t\telse if (event.text && event.text.startsWith(existing)) {\n\t\t\t\t\t\t\t\tmessageToWriteTo.content = initialMessageContent + event.text;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// C. Otherwise, merge with a paragraph break for readability.\n\t\t\t\t\t\t\telse {\n\t\t\t\t\t\t\t\tconst needsGap = !/\\n\\n$/.test(existing) && !/^\\n/.test(event.text ?? \"\");\n\t\t\t\t\t\t\t\tmessageToWriteTo.content =\n\t\t\t\t\t\t\t\t\tinitialMessageContent + existing + (needsGap ? \"\\n\\n\" : \"\") + (event.text ?? \"\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tmessageToWriteTo.content = initialMessageContent + (event.text ?? \"\");\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tmessageToWriteTo.content = initialMessageContent + event.text;\n\t\t\t\t\t}\n\t\t\t\t\tfinalAnswerReceived = true;\n\n\t\t\t\t\tif (metricsEnabled && metrics) {\n\t\t\t\t\t\tmetrics.model.latency.observe(metricsLabels, Date.now() - promptedAt.getTime());\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Add file\n\t\t\t\telse if (event.type === MessageUpdateType.File) {\n\t\t\t\t\tmessageToWriteTo.files = [\n\t\t\t\t\t\t...(messageToWriteTo.files ?? []),\n\t\t\t\t\t\t{ type: \"hash\", name: event.name, value: event.sha, mime: event.mime },\n\t\t\t\t\t];\n\t\t\t\t}\n\n\t\t\t\t// Store router metadata (for router models) or provider info (for all models)\n\t\t\t\telse if (event.type === MessageUpdateType.RouterMetadata) {\n\t\t\t\t\t// Merge metadata updates to preserve existing fields (router may send route/model first, then provider comes later)\n\t\t\t\t\tif (model?.isRouter) {\n\t\t\t\t\t\tmessageToWriteTo.routerMetadata = {\n\t\t\t\t\t\t\troute: event.route || messageToWriteTo.routerMetadata?.route || \"\",\n\t\t\t\t\t\t\tmodel: event.model || messageToWriteTo.routerMetadata?.model || \"\",\n\t\t\t\t\t\t\tprovider: event.provider || messageToWriteTo.routerMetadata?.provider,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t\t// Store provider-only metadata for non-router models if available\n\t\t\t\t\telse if (event.provider) {\n\t\t\t\t\t\tmessageToWriteTo.routerMetadata = {\n\t\t\t\t\t\t\troute: messageToWriteTo.routerMetadata?.route || \"\",\n\t\t\t\t\t\t\tmodel: messageToWriteTo.routerMetadata?.model || \"\",\n\t\t\t\t\t\t\tprovider: event.provider,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Append updates for audit/replay (streams too, to preserve ordering)\n\t\t\t\tif (\n\t\t\t\t\t!(\n\t\t\t\t\t\tevent.type === MessageUpdateType.Status &&\n\t\t\t\t\t\tevent.status === MessageUpdateStatus.KeepAlive\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tmessageToWriteTo?.updates?.push(\n\t\t\t\t\t\tevent.type === MessageUpdateType.Stream ? { ...event } : event\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Avoid remote keylogging attack executed by watching packet lengths\n\t\t\t\t// by padding the text with null chars to a fixed length\n\t\t\t\t// https://cdn.arstechnica.net/wp-content/uploads/2024/03/LLM-Side-Channel.pdf\n\t\t\t\tif (event.type === MessageUpdateType.Stream) {\n\t\t\t\t\tevent = { ...event, token: event.token.padEnd(16, \"\\0\") };\n\t\t\t\t}\n\n\t\t\t\tmessageToWriteTo.updatedAt = new Date();\n\n\t\t\t\tconst enqueueUpdate = async () => {\n\t\t\t\t\tif (clientDetached) return;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tcontroller.enqueue(JSON.stringify(event) + \"\\n\");\n\t\t\t\t\t\tif (event.type === MessageUpdateType.FinalAnswer) {\n\t\t\t\t\t\t\tcontroller.enqueue(\" \".repeat(4096));\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tclientDetached = true;\n\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t{ conversationId: convId.toString() },\n\t\t\t\t\t\t\t\"Client detached during message streaming\"\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t};\n\n\t\t\t\tawait enqueueUpdate();\n\n\t\t\t\tif (clientDetached) {\n\t\t\t\t\tawait persistConversation();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet hasError = false;\n\t\t\tconst initialMessageContent = messageToWriteTo.content;\n\n\t\t\ttry {\n\t\t\t\t// Fetch user settings once for all overrides and billing org\n\t\t\t\tconst userSettings = await collections.settings.findOne(authCondition(locals));\n\n\t\t\t\t// Add billing organization to locals for the endpoint to use\n\t\t\t\tlocals.billingOrganization = userSettings?.billingOrganization;\n\n\t\t\t\tconst ctx: TextGenerationContext = {\n\t\t\t\t\tmodel,\n\t\t\t\t\tendpoint: await model.getEndpoint(),\n\t\t\t\t\tconv,\n\t\t\t\t\tmessages: messagesForPrompt,\n\t\t\t\t\tassistant: undefined,\n\t\t\t\t\tpromptedAt,\n\t\t\t\t\tip: getClientAddress(),\n\t\t\t\t\tusername: locals.user?.username,\n\t\t\t\t\t// Force-enable multimodal if user settings say so for this model\n\t\t\t\t\tforceMultimodal: Boolean(userSettings?.multimodalOverrides?.[model.id]),\n\t\t\t\t\t// Force-enable tools if user settings say so for this model\n\t\t\t\t\tforceTools: Boolean(userSettings?.toolsOverrides?.[model.id]),\n\t\t\t\t\t// Inference provider preference (HuggingChat only, skip for router models)\n\t\t\t\t\tprovider:\n\t\t\t\t\t\tconfig.isHuggingChat && !model.isRouter\n\t\t\t\t\t\t\t? userSettings?.providerOverrides?.[model.id]\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\tlocals,\n\t\t\t\t\tabortController: ctrl,\n\t\t\t\t};\n\t\t\t\t// run the text generation and send updates to the client\n\t\t\t\tfor await (const event of textGeneration(ctx)) await update(event);\n\t\t\t\tif (ctrl.signal.aborted) {\n\t\t\t\t\tabortedByUser = true;\n\t\t\t\t}\n\t\t\t\tif (abortedByUser && !finalAnswerReceived) {\n\t\t\t\t\tconst partialText = messageToWriteTo.content.slice(initialMessageContent.length);\n\t\t\t\t\tawait update({\n\t\t\t\t\t\ttype: MessageUpdateType.FinalAnswer,\n\t\t\t\t\t\ttext: partialText,\n\t\t\t\t\t\tinterrupted: true,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tconst err = e as Error;\n\t\t\t\tconst isAbortError =\n\t\t\t\t\terr?.name === \"AbortError\" ||\n\t\t\t\t\terr?.name === \"APIUserAbortError\" ||\n\t\t\t\t\terr?.message === \"Request was aborted.\";\n\t\t\t\tif (isAbortError || ctrl.signal.aborted) {\n\t\t\t\t\tabortedByUser = true;\n\t\t\t\t\tlogger.info({ conversationId: conversationKey }, \"Generation aborted by user\");\n\t\t\t\t\tif (!finalAnswerReceived) {\n\t\t\t\t\t\tconst partialText = messageToWriteTo.content.slice(initialMessageContent.length);\n\t\t\t\t\t\tawait update({\n\t\t\t\t\t\t\ttype: MessageUpdateType.FinalAnswer,\n\t\t\t\t\t\t\ttext: partialText,\n\t\t\t\t\t\t\tinterrupted: true,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\thasError = true;\n\t\t\t\t\t// Extract status code if available from HTTPError or APIError\n\t\t\t\t\tconst errObj = err as unknown as Record<string, unknown>;\n\t\t\t\t\tconst statusCode =\n\t\t\t\t\t\t(typeof errObj.statusCode === \"number\" ? errObj.statusCode : undefined) ||\n\t\t\t\t\t\t(typeof errObj.status === \"number\" ? errObj.status : undefined);\n\t\t\t\t\tawait update({\n\t\t\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\t\t\tstatus: MessageUpdateStatus.Error,\n\t\t\t\t\t\tmessage: err.message,\n\t\t\t\t\t\t...(statusCode && { statusCode }),\n\t\t\t\t\t});\n\t\t\t\t\tlogger.error(err, \"Error in conversation stream\");\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\t// check if no output was generated\n\t\t\t\tif (!hasError && !abortedByUser && messageToWriteTo.content === initialMessageContent) {\n\t\t\t\t\thasError = true;\n\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tconversationId: conversationKey,\n\t\t\t\t\t\t\tupdatesCount: messageToWriteTo.updates?.length ?? 0,\n\t\t\t\t\t\t\tfilesCount: messageToWriteTo.files?.length ?? 0,\n\t\t\t\t\t\t\treasoningLen: messageToWriteTo.reasoning?.length ?? 0,\n\t\t\t\t\t\t\tinitialLen: initialMessageContent.length,\n\t\t\t\t\t\t\tfinalLen: messageToWriteTo.content.length,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"No output generated after streaming; emitting error status\"\n\t\t\t\t\t);\n\t\t\t\t\tawait update({\n\t\t\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\t\t\tstatus: MessageUpdateStatus.Error,\n\t\t\t\t\t\tmessage: \"No output was generated. Something went wrong.\",\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!hasError && !finishedStatusSent) {\n\t\t\t\tawait update({\n\t\t\t\t\ttype: MessageUpdateType.Status,\n\t\t\t\t\tstatus: MessageUpdateStatus.Finished,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tawait persistConversation();\n\t\t\tabortRegistry.unregister(conversationKey, ctrl);\n\n\t\t\t// used to detect if cancel() is called bc of interrupt or just because the connection closes\n\t\t\tdoneStreaming = true;\n\t\t\tif (!clientDetached) {\n\t\t\t\tcontroller.close();\n\t\t\t}\n\t\t},\n\t\tasync cancel() {\n\t\t\tif (doneStreaming) return;\n\t\t\tclientDetached = true;\n\t\t\tawait persistConversation();\n\t\t},\n\t});\n\n\tif (metricsEnabled && metrics) {\n\t\tmetrics.model.messagesTotal.inc(metricsLabels);\n\t}\n\n\t// Todo: maybe we should wait for the message to be saved before ending the response - in case of errors\n\treturn new Response(stream, {\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/jsonl\",\n\t\t},\n\t});\n}\n\nexport async function DELETE({ locals, params }) {\n\tconst convId = new ObjectId(params.id);\n\n\tconst conv = await collections.conversations.findOne({\n\t\t_id: convId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (!conv) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\tawait collections.conversations.deleteOne({ _id: conv._id });\n\n\treturn new Response();\n}\n\nexport async function PATCH({ request, locals, params }) {\n\tconst values = z\n\t\t.object({\n\t\t\ttitle: z.string().trim().min(1).max(100).optional(),\n\t\t\tmodel: validModelIdSchema.optional(),\n\t\t})\n\t\t.parse(await request.json());\n\n\tconst convId = new ObjectId(params.id);\n\n\tconst conv = await collections.conversations.findOne({\n\t\t_id: convId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (!conv) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\t// Only include defined values in the update, with title sanitized\n\tconst updateValues = {\n\t\t...(values.title !== undefined && {\n\t\t\ttitle: values.title.replace(/<\\/?think>/gi, \"\").trim(),\n\t\t}),\n\t\t...(values.model !== undefined && { model: values.model }),\n\t};\n\n\tawait collections.conversations.updateOne(\n\t\t{\n\t\t\t_id: convId,\n\t\t},\n\t\t{\n\t\t\t$set: updateValues,\n\t\t}\n\t);\n\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/conversation/[id]/message/[messageId]/prompt/+server.ts",
    "content": "import { buildPrompt } from \"$lib/buildPrompt\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { models } from \"$lib/server/models\";\nimport { buildSubtree } from \"$lib/utils/tree/buildSubtree\";\nimport { isMessageId } from \"$lib/utils/tree/isMessageId\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\n\nexport async function GET({ params, locals }) {\n\tconst conv =\n\t\tparams.id.length === 7\n\t\t\t? await collections.sharedConversations.findOne({\n\t\t\t\t\t_id: params.id,\n\t\t\t\t})\n\t\t\t: await collections.conversations.findOne({\n\t\t\t\t\t_id: new ObjectId(params.id),\n\t\t\t\t\t...authCondition(locals),\n\t\t\t\t});\n\n\tif (conv === null) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\tconst messageId = params.messageId;\n\n\tconst messageIndex = conv.messages.findIndex((msg) => msg.id === messageId);\n\n\tif (!isMessageId(messageId) || messageIndex === -1) {\n\t\terror(404, \"Message not found\");\n\t}\n\n\tconst model = models.find((m) => m.id === conv.model);\n\n\tif (!model) {\n\t\terror(404, \"Conversation model not found\");\n\t}\n\n\tconst messagesUpTo = buildSubtree(conv, messageId);\n\n\tconst prompt = await buildPrompt({\n\t\tpreprompt: conv.preprompt,\n\t\tmessages: messagesUpTo,\n\t\tmodel,\n\t}).catch((err) => {\n\t\tconsole.error(err);\n\t\treturn \"Prompt generation failed\";\n\t});\n\n\treturn Response.json({\n\t\tprompt,\n\t\tmodel: model.name,\n\t\tparameters: {\n\t\t\t...model.parameters,\n\t\t\treturn_full_text: false,\n\t\t},\n\t\tmessages: messagesUpTo.map((msg) => ({\n\t\t\trole: msg.from,\n\t\t\tcontent: msg.content,\n\t\t\tcreatedAt: msg.createdAt,\n\t\t\tupdatedAt: msg.updatedAt,\n\t\t\tupdates: msg.updates?.filter((u) => u.type === \"title\"),\n\t\t\tfiles: msg.files,\n\t\t})),\n\t});\n}\n"
  },
  {
    "path": "src/routes/conversation/[id]/output/[sha256]/+server.ts",
    "content": "import { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\nimport { z } from \"zod\";\nimport type { RequestHandler } from \"./$types\";\nimport { downloadFile } from \"$lib/server/files/downloadFile\";\nimport mimeTypes from \"mime-types\";\n\nexport const GET: RequestHandler = async ({ locals, params }) => {\n\tconst sha256 = z.string().parse(params.sha256);\n\n\tconst userId = locals.user?._id ?? locals.sessionId;\n\n\t// check user\n\tif (!userId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\n\tif (params.id.length !== 7) {\n\t\tconst convId = new ObjectId(z.string().parse(params.id));\n\n\t\t// check if the user has access to the conversation\n\t\tconst conv = await collections.conversations.findOne({\n\t\t\t_id: convId,\n\t\t\t...authCondition(locals),\n\t\t});\n\n\t\tif (!conv) {\n\t\t\terror(404, \"Conversation not found\");\n\t\t}\n\t} else {\n\t\t// look for the conversation in shared conversations\n\t\tconst conv = await collections.sharedConversations.findOne({\n\t\t\t_id: params.id,\n\t\t});\n\n\t\tif (!conv) {\n\t\t\terror(404, \"Conversation not found\");\n\t\t}\n\t}\n\n\tconst { value, mime } = await downloadFile(sha256, params.id);\n\n\tconst b64Value = Buffer.from(value, \"base64\");\n\treturn new Response(b64Value, {\n\t\theaders: {\n\t\t\t\"Content-Type\": mime ?? \"application/octet-stream\",\n\t\t\t\"Content-Security-Policy\":\n\t\t\t\t\"default-src 'none'; script-src 'none'; style-src 'none'; sandbox;\",\n\t\t\t\"Content-Disposition\": `attachment; filename=\"${sha256.slice(0, 8)}.${\n\t\t\t\tmime ? mimeTypes.extension(mime) || \"bin\" : \"bin\"\n\t\t\t}\"`,\n\t\t\t\"Content-Length\": b64Value.length.toString(),\n\t\t\t\"Accept-Range\": \"bytes\",\n\t\t},\n\t});\n};\n"
  },
  {
    "path": "src/routes/conversation/[id]/share/+server.ts",
    "content": "import { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport type { SharedConversation } from \"$lib/types/SharedConversation\";\nimport { hashConv } from \"$lib/utils/hashConv\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\nimport { nanoid } from \"nanoid\";\n\nexport async function POST({ params, locals }) {\n\tconst conversation = await collections.conversations.findOne({\n\t\t_id: new ObjectId(params.id),\n\t\t...authCondition(locals),\n\t});\n\n\tif (!conversation) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\tconst hash = await hashConv(conversation);\n\n\tconst existingShare = await collections.sharedConversations.findOne({ hash });\n\n\tif (existingShare) {\n\t\treturn new Response(\n\t\t\tJSON.stringify({\n\t\t\t\tshareId: existingShare._id,\n\t\t\t}),\n\t\t\t{ headers: { \"Content-Type\": \"application/json\" } }\n\t\t);\n\t}\n\n\tconst shared: SharedConversation = {\n\t\t_id: nanoid(7),\n\t\thash,\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\trootMessageId: conversation.rootMessageId,\n\t\tmessages: conversation.messages,\n\t\ttitle: conversation.title,\n\t\tmodel: conversation.model,\n\t\tpreprompt: conversation.preprompt,\n\t};\n\n\tawait collections.sharedConversations.insertOne(shared);\n\n\t// copy files from `${conversation._id}-` to `${shared._id}-`\n\tconst files = await collections.bucket\n\t\t.find({ filename: { $regex: `^${conversation._id}-` } })\n\t\t.toArray();\n\n\tawait Promise.all(\n\t\tfiles.map(async (file) => {\n\t\t\tconst newFilename = file.filename.replace(`${conversation._id}-`, `${shared._id}-`);\n\t\t\t// copy files from `${conversation._id}-` to `${shared._id}-` by downloading and reuploaidng\n\t\t\tconst downloadStream = collections.bucket.openDownloadStream(file._id);\n\t\t\tconst uploadStream = collections.bucket.openUploadStream(newFilename, {\n\t\t\t\tmetadata: { ...file.metadata, conversation: shared._id.toString() },\n\t\t\t});\n\t\t\tdownloadStream.pipe(uploadStream);\n\t\t})\n\t);\n\n\treturn new Response(\n\t\tJSON.stringify({\n\t\t\tshareId: shared._id,\n\t\t}),\n\t\t{ headers: { \"Content-Type\": \"application/json\" } }\n\t);\n}\n"
  },
  {
    "path": "src/routes/conversation/[id]/stop-generating/+server.ts",
    "content": "import { authCondition } from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { AbortRegistry } from \"$lib/server/abortRegistry\";\nimport { error } from \"@sveltejs/kit\";\nimport { ObjectId } from \"mongodb\";\n\n/**\n * Ideally, we'd be able to detect the client-side abort, see https://github.com/huggingface/chat-ui/pull/88#issuecomment-1523173850\n */\nexport async function POST({ params, locals }) {\n\tif (!locals.user && !locals.sessionId) {\n\t\terror(401, \"Unauthorized\");\n\t}\n\n\tconst conversationId = new ObjectId(params.id);\n\n\tconst conversation = await collections.conversations.findOne({\n\t\t_id: conversationId,\n\t\t...authCondition(locals),\n\t});\n\n\tif (!conversation) {\n\t\terror(404, \"Conversation not found\");\n\t}\n\n\tAbortRegistry.getInstance().abort(conversationId.toString());\n\n\tawait collections.abortedGenerations.updateOne(\n\t\t{ conversationId },\n\t\t{ $set: { updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } },\n\t\t{ upsert: true }\n\t);\n\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/healthcheck/+server.ts",
    "content": "export async function GET() {\n\treturn new Response(\"OK\", { status: 200 });\n}\n"
  },
  {
    "path": "src/routes/login/+server.ts",
    "content": "import { triggerOauthFlow } from \"$lib/server/auth\";\n\nexport async function GET(event) {\n\treturn await triggerOauthFlow(event);\n}\n"
  },
  {
    "path": "src/routes/login/callback/+server.ts",
    "content": "import { error, redirect } from \"@sveltejs/kit\";\nimport { getOIDCUserData, validateAndParseCsrfToken } from \"$lib/server/auth\";\nimport { z } from \"zod\";\nimport { base } from \"$app/paths\";\nimport { config } from \"$lib/server/config\";\nimport JSON5 from \"json5\";\nimport { updateUser } from \"./updateUser.js\";\n\nconst sanitizeJSONEnv = (val: string, fallback: string) => {\n\tconst raw = (val ?? \"\").trim();\n\tconst unquoted = raw.startsWith(\"`\") && raw.endsWith(\"`\") ? raw.slice(1, -1) : raw;\n\treturn unquoted || fallback;\n};\n\nconst allowedUserEmails = z\n\t.array(z.string().email())\n\t.optional()\n\t.default([])\n\t.parse(JSON5.parse(sanitizeJSONEnv(config.ALLOWED_USER_EMAILS, \"[]\")));\n\nconst allowedUserDomains = z\n\t.array(z.string().regex(/\\.\\w+$/)) // Contains at least a dot\n\t.optional()\n\t.default([])\n\t.parse(JSON5.parse(sanitizeJSONEnv(config.ALLOWED_USER_DOMAINS, \"[]\")));\n\nexport async function GET({ url, locals, cookies, request, getClientAddress }) {\n\tconst { error: errorName, error_description: errorDescription } = z\n\t\t.object({\n\t\t\terror: z.string().optional(),\n\t\t\terror_description: z.string().optional(),\n\t\t})\n\t\t.parse(Object.fromEntries(url.searchParams.entries()));\n\n\tif (errorName) {\n\t\tthrow error(400, errorName + (errorDescription ? \": \" + errorDescription : \"\"));\n\t}\n\n\tconst { code, state, iss } = z\n\t\t.object({\n\t\t\tcode: z.string(),\n\t\t\tstate: z.string(),\n\t\t\tiss: z.string().optional(),\n\t\t})\n\t\t.parse(Object.fromEntries(url.searchParams.entries()));\n\n\tconst csrfToken = Buffer.from(state, \"base64\").toString(\"utf-8\");\n\n\tconst validatedToken = await validateAndParseCsrfToken(csrfToken, locals.sessionId);\n\n\tif (!validatedToken) {\n\t\tthrow error(403, \"Invalid or expired CSRF token\");\n\t}\n\n\tconst codeVerifier = cookies.get(\"hfChat-codeVerifier\");\n\tif (!codeVerifier) {\n\t\tthrow error(403, \"Code verifier cookie not found\");\n\t}\n\n\tconst { userData, token } = await getOIDCUserData(\n\t\t{ redirectURI: validatedToken.redirectUrl },\n\t\tcode,\n\t\tcodeVerifier,\n\t\tiss,\n\t\turl\n\t);\n\n\t// Filter by allowed user emails or domains\n\tif (allowedUserEmails.length > 0 || allowedUserDomains.length > 0) {\n\t\tif (!userData.email) {\n\t\t\tthrow error(403, \"User not allowed: email not returned\");\n\t\t}\n\t\tconst emailVerified = userData.email_verified ?? true;\n\t\tif (!emailVerified) {\n\t\t\tthrow error(403, \"User not allowed: email not verified\");\n\t\t}\n\n\t\tconst emailDomain = userData.email.split(\"@\")[1];\n\t\tconst isEmailAllowed = allowedUserEmails.includes(userData.email);\n\t\tconst isDomainAllowed = allowedUserDomains.includes(emailDomain);\n\n\t\tif (!isEmailAllowed && !isDomainAllowed) {\n\t\t\tthrow error(403, \"User not allowed\");\n\t\t}\n\t}\n\n\tawait updateUser({\n\t\tuserData,\n\t\ttoken,\n\t\tlocals,\n\t\tcookies,\n\t\tuserAgent: request.headers.get(\"user-agent\") ?? undefined,\n\t\tip: getClientAddress(),\n\t});\n\n\t// Prefer returning the user to their original in-app path when provided.\n\t// `validatedToken.next` is sanitized server-side to avoid protocol-relative redirects.\n\tconst next = validatedToken.next;\n\tif (next) {\n\t\treturn redirect(302, next);\n\t}\n\treturn redirect(302, `${base}/`);\n}\n"
  },
  {
    "path": "src/routes/login/callback/updateUser.spec.ts",
    "content": "import { assert, it, describe, afterEach, vi, expect } from \"vitest\";\nimport type { Cookies } from \"@sveltejs/kit\";\nimport { collections } from \"$lib/server/database\";\nimport { updateUser } from \"./updateUser\";\nimport { ObjectId } from \"mongodb\";\nimport { DEFAULT_SETTINGS } from \"$lib/types/Settings\";\nimport { defaultModel } from \"$lib/server/models\";\nimport { findUser } from \"$lib/server/auth\";\nimport type { TokenSet } from \"openid-client\";\n\nconst userData = {\n\tpreferred_username: \"new-username\",\n\tname: \"name\",\n\tpicture: \"https://example.com/avatar.png\",\n\tsub: \"1234567890\",\n};\nObject.freeze(userData);\n\nconst locals = {\n\tuserId: \"1234567890\",\n\tsessionId: \"1234567890\",\n\tisAdmin: false,\n};\n\nconst token = {\n\taccess_token: \"access_token\",\n\trefresh_token: \"refresh_token\",\n\texpires_at: Math.floor(Date.now() / 1000) + 3600, // Expires 1 hour from now\n\texpires_in: 3600,\n} as TokenSet;\n\n// @ts-expect-error SvelteKit cookies dumb mock\nconst cookiesMock: Cookies = {\n\tset: vi.fn(),\n};\n\nconst insertRandomUser = async () => {\n\tconst res = await collections.users.insertOne({\n\t\t_id: new ObjectId(),\n\t\tcreatedAt: new Date(),\n\t\tupdatedAt: new Date(),\n\t\tusername: \"base-username\",\n\t\tname: userData.name,\n\t\tavatarUrl: userData.picture,\n\t\thfUserId: userData.sub,\n\t});\n\n\treturn res.insertedId;\n};\n\nconst insertRandomConversations = async (count: number) => {\n\tconst res = await collections.conversations.insertMany(\n\t\tnew Array(count).fill(0).map(() => ({\n\t\t\t_id: new ObjectId(),\n\t\t\ttitle: \"random title\",\n\t\t\tmessages: [],\n\t\t\tmodel: defaultModel.id,\n\t\t\t// embedding model removed in this build\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tsessionId: locals.sessionId,\n\t\t}))\n\t);\n\n\treturn res.insertedIds;\n};\n\ndescribe(\"login\", () => {\n\tit(\"should update user if existing\", async () => {\n\t\tawait insertRandomUser();\n\n\t\tawait updateUser({ userData, locals, cookies: cookiesMock, token });\n\n\t\tconst existingUser = await collections.users.findOne({ hfUserId: userData.sub });\n\n\t\tassert.equal(existingUser?.name, userData.name);\n\n\t\texpect(cookiesMock.set).toBeCalledTimes(1);\n\t}, 30000);\n\n\tit(\"should migrate pre-existing conversations for new user\", async () => {\n\t\tconst insertedId = await insertRandomUser();\n\n\t\tawait insertRandomConversations(2);\n\n\t\tawait updateUser({ userData, locals, cookies: cookiesMock, token });\n\n\t\tconst conversationCount = await collections.conversations.countDocuments({\n\t\t\tuserId: insertedId,\n\t\t\tsessionId: { $exists: false },\n\t\t});\n\n\t\tassert.equal(conversationCount, 2);\n\n\t\tawait collections.conversations.deleteMany({ userId: insertedId });\n\t});\n\n\tit(\"should create default settings for new user\", async () => {\n\t\tawait updateUser({ userData, locals, cookies: cookiesMock, token });\n\n\t\t// updateUser creates a new sessionId, so we need to use the updated value\n\t\tconst user = (await findUser(locals.sessionId, undefined, new URL(\"http://localhost\"))).user;\n\n\t\tassert.exists(user);\n\n\t\tconst settings = await collections.settings.findOne({ userId: user?._id });\n\n\t\texpect(settings).toMatchObject({\n\t\t\tuserId: user?._id,\n\t\t\tupdatedAt: expect.any(Date),\n\t\t\tcreatedAt: expect.any(Date),\n\t\t\t...DEFAULT_SETTINGS,\n\t\t});\n\n\t\tawait collections.settings.deleteOne({ userId: user?._id });\n\t});\n\n\tit(\"should migrate pre-existing settings for pre-existing user\", async () => {\n\t\tconst { insertedId } = await collections.settings.insertOne({\n\t\t\tsessionId: locals.sessionId,\n\t\t\tupdatedAt: new Date(),\n\t\t\tcreatedAt: new Date(),\n\t\t\t...DEFAULT_SETTINGS,\n\t\t\tshareConversationsWithModelAuthors: false,\n\t\t});\n\n\t\tawait updateUser({ userData, locals, cookies: cookiesMock, token });\n\n\t\tconst settings = await collections.settings.findOne({\n\t\t\t_id: insertedId,\n\t\t\tsessionId: { $exists: false },\n\t\t});\n\n\t\tassert.exists(settings);\n\n\t\tconst user = await collections.users.findOne({ hfUserId: userData.sub });\n\n\t\texpect(settings).toMatchObject({\n\t\t\tuserId: user?._id,\n\t\t\tupdatedAt: expect.any(Date),\n\t\t\tcreatedAt: expect.any(Date),\n\t\t\t...DEFAULT_SETTINGS,\n\t\t\tshareConversationsWithModelAuthors: false,\n\t\t});\n\n\t\tawait collections.settings.deleteOne({ userId: user?._id });\n\t});\n});\n\nafterEach(async () => {\n\tawait collections.users.deleteMany({ hfUserId: userData.sub });\n\tawait collections.sessions.deleteMany({});\n\n\tlocals.userId = \"1234567890\";\n\tlocals.sessionId = \"1234567890\";\n\tvi.clearAllMocks();\n});\n"
  },
  {
    "path": "src/routes/login/callback/updateUser.ts",
    "content": "import {\n\tgetCoupledCookieHash,\n\trefreshSessionCookie,\n\ttokenSetToSessionOauth,\n} from \"$lib/server/auth\";\nimport { collections } from \"$lib/server/database\";\nimport { ObjectId } from \"mongodb\";\nimport { DEFAULT_SETTINGS } from \"$lib/types/Settings\";\nimport { z } from \"zod\";\nimport type { UserinfoResponse, TokenSet } from \"openid-client\";\nimport { error, type Cookies } from \"@sveltejs/kit\";\nimport crypto from \"crypto\";\nimport { sha256 } from \"$lib/utils/sha256\";\nimport { addWeeks } from \"date-fns\";\nimport { OIDConfig } from \"$lib/server/auth\";\nimport { config } from \"$lib/server/config\";\nimport { logger } from \"$lib/server/logger\";\n\nexport async function updateUser(params: {\n\tuserData: UserinfoResponse;\n\ttoken: TokenSet;\n\tlocals: App.Locals;\n\tcookies: Cookies;\n\tuserAgent?: string;\n\tip?: string;\n}) {\n\tconst { userData, token, locals, cookies, userAgent, ip } = params;\n\n\t// Microsoft Entra v1 tokens do not provide preferred_username, instead the username is provided in the upn\n\t// claim. See https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference\n\tif (!userData.preferred_username && userData.upn) {\n\t\tuserData.preferred_username = userData.upn as string;\n\t}\n\n\tconst {\n\t\tpreferred_username: username,\n\t\tname,\n\t\temail,\n\t\tpicture: avatarUrl,\n\t\tsub: hfUserId,\n\t\torgs,\n\t} = z\n\t\t.object({\n\t\t\tpreferred_username: z.string().optional(),\n\t\t\tname: z.string(),\n\t\t\tpicture: z.string().optional(),\n\t\t\tsub: z.string(),\n\t\t\temail: z.string().email().optional(),\n\t\t\torgs: z\n\t\t\t\t.array(\n\t\t\t\t\tz.object({\n\t\t\t\t\t\tsub: z.string(),\n\t\t\t\t\t\tname: z.string(),\n\t\t\t\t\t\tpicture: z.string(),\n\t\t\t\t\t\tpreferred_username: z.string(),\n\t\t\t\t\t\tplan: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t)\n\t\t\t\t.optional(),\n\t\t})\n\t\t.setKey(OIDConfig.NAME_CLAIM, z.string())\n\t\t.refine((data) => data.preferred_username || data.email, {\n\t\t\tmessage: \"Either preferred_username or email must be provided by the provider.\",\n\t\t})\n\t\t.transform((data) => ({\n\t\t\t...data,\n\t\t\tname: data[OIDConfig.NAME_CLAIM],\n\t\t}))\n\t\t.parse(userData) as {\n\t\tpreferred_username?: string;\n\t\temail?: string;\n\t\tpicture?: string;\n\t\tsub: string;\n\t\tname: string;\n\t\torgs?: Array<{\n\t\t\tsub: string;\n\t\t\tname: string;\n\t\t\tpicture: string;\n\t\t\tpreferred_username: string;\n\t\t\tplan?: string;\n\t\t}>;\n\t} & Record<string, string>;\n\n\t// Dynamically access user data based on NAME_CLAIM from environment\n\t// This approach allows us to adapt to different OIDC providers flexibly.\n\n\tlogger.info(\n\t\t{\n\t\t\tlogin_username: username,\n\t\t\tlogin_name: name,\n\t\t\tlogin_email: email,\n\t\t\tlogin_orgs: orgs?.map((el) => el.sub),\n\t\t},\n\t\t\"user login\"\n\t);\n\t// if using huggingface as auth provider, check orgs for earl access and amin rights\n\tconst isAdmin =\n\t\t(config.HF_ORG_ADMIN && orgs?.some((org) => org.sub === config.HF_ORG_ADMIN)) || false;\n\tconst isEarlyAccess =\n\t\t(config.HF_ORG_EARLY_ACCESS && orgs?.some((org) => org.sub === config.HF_ORG_EARLY_ACCESS)) ||\n\t\tfalse;\n\n\tlogger.debug(\n\t\t{\n\t\t\tisAdmin,\n\t\t\tisEarlyAccess,\n\t\t\thfUserId,\n\t\t},\n\t\t`Updating user ${hfUserId}`\n\t);\n\n\t// check if user already exists\n\tconst existingUser = await collections.users.findOne({ hfUserId });\n\tlet userId = existingUser?._id;\n\n\t// update session cookie on login\n\tconst previousSessionId = locals.sessionId;\n\tconst secretSessionId = crypto.randomUUID();\n\tconst sessionId = await sha256(secretSessionId);\n\n\tif (await collections.sessions.findOne({ sessionId })) {\n\t\terror(500, \"Session ID collision\");\n\t}\n\n\tlocals.sessionId = sessionId;\n\n\t// Get cookie hash if coupling is enabled\n\tconst coupledCookieHash = await getCoupledCookieHash(cookies);\n\n\t// Prepare OAuth token data for session storage\n\tconst oauthData = tokenSetToSessionOauth(token);\n\n\tif (existingUser) {\n\t\t// update existing user if any\n\t\tawait collections.users.updateOne(\n\t\t\t{ _id: existingUser._id },\n\t\t\t{ $set: { username, name, avatarUrl, isAdmin, isEarlyAccess } }\n\t\t);\n\n\t\t// remove previous session if it exists and add new one\n\t\tawait collections.sessions.deleteOne({ sessionId: previousSessionId });\n\t\tawait collections.sessions.insertOne({\n\t\t\t_id: new ObjectId(),\n\t\t\tsessionId: locals.sessionId,\n\t\t\tuserId: existingUser._id,\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tuserAgent,\n\t\t\tip,\n\t\t\texpiresAt: addWeeks(new Date(), 2),\n\t\t\t...(coupledCookieHash ? { coupledCookieHash } : {}),\n\t\t\t...(oauthData ? { oauth: oauthData } : {}),\n\t\t});\n\t} else {\n\t\t// user doesn't exist yet, create a new one\n\t\tconst { insertedId } = await collections.users.insertOne({\n\t\t\t_id: new ObjectId(),\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tusername,\n\t\t\tname,\n\t\t\temail,\n\t\t\tavatarUrl,\n\t\t\thfUserId,\n\t\t\tisAdmin,\n\t\t\tisEarlyAccess,\n\t\t});\n\n\t\tuserId = insertedId;\n\n\t\tawait collections.sessions.insertOne({\n\t\t\t_id: new ObjectId(),\n\t\t\tsessionId: locals.sessionId,\n\t\t\tuserId,\n\t\t\tcreatedAt: new Date(),\n\t\t\tupdatedAt: new Date(),\n\t\t\tuserAgent,\n\t\t\tip,\n\t\t\texpiresAt: addWeeks(new Date(), 2),\n\t\t\t...(coupledCookieHash ? { coupledCookieHash } : {}),\n\t\t\t...(oauthData ? { oauth: oauthData } : {}),\n\t\t});\n\n\t\t// move pre-existing settings to new user\n\t\tconst { matchedCount } = await collections.settings.updateOne(\n\t\t\t{ sessionId: previousSessionId },\n\t\t\t{\n\t\t\t\t$set: { userId, updatedAt: new Date() },\n\t\t\t\t$unset: { sessionId: \"\" },\n\t\t\t}\n\t\t);\n\n\t\tif (!matchedCount) {\n\t\t\t// if no settings found for user, create default settings\n\t\t\tawait collections.settings.insertOne({\n\t\t\t\tuserId,\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\t...DEFAULT_SETTINGS,\n\t\t\t});\n\t\t}\n\t}\n\n\t// refresh session cookie\n\trefreshSessionCookie(cookies, secretSessionId);\n\n\t// migrate pre-existing conversations\n\tawait collections.conversations.updateMany(\n\t\t{ sessionId: previousSessionId },\n\t\t{\n\t\t\t$set: { userId },\n\t\t\t$unset: { sessionId: \"\" },\n\t\t}\n\t);\n}\n"
  },
  {
    "path": "src/routes/logout/+server.ts",
    "content": "import { dev } from \"$app/environment\";\nimport { base } from \"$app/paths\";\nimport { collections } from \"$lib/server/database\";\nimport { redirect } from \"@sveltejs/kit\";\nimport { config } from \"$lib/server/config\";\n\nexport async function POST({ locals, cookies }) {\n\tawait collections.sessions.deleteOne({ sessionId: locals.sessionId });\n\n\tcookies.delete(config.COOKIE_NAME, {\n\t\tpath: \"/\",\n\t\t// So that it works inside the space's iframe\n\t\tsameSite: dev || config.ALLOW_INSECURE_COOKIES === \"true\" ? \"lax\" : \"none\",\n\t\tsecure: !dev && !(config.ALLOW_INSECURE_COOKIES === \"true\"),\n\t\thttpOnly: true,\n\t});\n\treturn redirect(302, `${base}/`);\n}\n"
  },
  {
    "path": "src/routes/metrics/+server.ts",
    "content": "import { config } from \"$lib/server/config\";\nimport { MetricsServer } from \"$lib/server/metrics\";\n\nexport async function GET() {\n\tif (config.METRICS_ENABLED !== \"true\") {\n\t\treturn new Response(\"Not Found\", { status: 404 });\n\t}\n\n\tconst payload = await MetricsServer.getInstance().render();\n\n\treturn new Response(payload, {\n\t\tstatus: 200,\n\t\theaders: {\n\t\t\t\"Content-Type\": \"text/plain; version=0.0.4\",\n\t\t\t\"Cache-Control\": \"no-store\",\n\t\t},\n\t});\n}\n"
  },
  {
    "path": "src/routes/models/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport type { PageData } from \"./$types\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\n\timport CarbonHelpFilled from \"~icons/carbon/help-filled\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\timport LucideImage from \"~icons/lucide/image\";\n\timport LucideSettings from \"~icons/lucide/settings\";\n\timport IconFast from \"$lib/components/icons/IconFast.svelte\";\n\timport IconCheap from \"$lib/components/icons/IconCheap.svelte\";\n\timport { PROVIDERS_HUB_ORGS } from \"@huggingface/inference\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport { goto } from \"$app/navigation\";\n\tinterface Props {\n\t\tdata: PageData;\n\t}\n\n\tlet { data }: Props = $props();\n\n\tconst settings = useSettingsStore();\n\n\tconst publicConfig = usePublicConfig();\n\n\t// Local filter state for model search (hyphen/space insensitive)\n\tlet modelFilter = $state(\"\");\n\tconst normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, \" \");\n\tlet queryTokens = $derived(normalize(modelFilter).trim().split(/\\s+/).filter(Boolean));\n\n\t// Filtered models list\n\tlet filteredModels = $derived(\n\t\tdata.models\n\t\t\t.filter((el) => !el.unlisted)\n\t\t\t.filter((el) => {\n\t\t\t\tconst haystack = normalize(`${el.id} ${el.name ?? \"\"} ${el.displayName ?? \"\"}`);\n\t\t\t\treturn queryTokens.every((q) => haystack.includes(q));\n\t\t\t})\n\t);\n</script>\n\n<svelte:head>\n\t{#if publicConfig.isHuggingChat}\n\t\t<title>{publicConfig.PUBLIC_APP_NAME} - Models</title>\n\t\t<meta property=\"og:title\" content=\"{publicConfig.PUBLIC_APP_NAME} - Models\" />\n\t\t<meta property=\"og:type\" content=\"website\" />\n\t\t<meta\n\t\t\tproperty=\"og:description\"\n\t\t\tcontent=\"Browse {publicConfig.PUBLIC_APP_NAME} available models\"\n\t\t/>\n\t\t<meta property=\"og:url\" content={page.url.href} />\n\t\t<meta property=\"og:image\" content=\"{publicConfig.assetPath}/thumbnail.png\" />\n\t\t<meta property=\"og:image:alt\" content=\"{publicConfig.PUBLIC_APP_NAME} preview\" />\n\t\t<meta name=\"twitter:card\" content=\"summary_large_image\" />\n\t\t<meta name=\"twitter:title\" content=\"{publicConfig.PUBLIC_APP_NAME} - Models\" />\n\t\t<meta\n\t\t\tname=\"twitter:description\"\n\t\t\tcontent=\"Browse {publicConfig.PUBLIC_APP_NAME} available models\"\n\t\t/>\n\t\t<meta name=\"twitter:image\" content=\"{publicConfig.assetPath}/thumbnail.png\" />\n\t\t<meta name=\"twitter:image:alt\" content=\"{publicConfig.PUBLIC_APP_NAME} preview\" />\n\t{/if}\n</svelte:head>\n\n<div class=\"scrollbar-custom h-full overflow-y-auto py-12 max-sm:pt-8 md:py-24\">\n\t<div class=\"pt-42 mx-auto flex flex-col px-5 xl:w-[60rem] 2xl:w-[64rem]\">\n\t\t<div class=\"flex items-center\">\n\t\t\t<h1 class=\"text-xl font-bold sm:text-2xl\">Models</h1>\n\t\t\t{#if publicConfig.isHuggingChat}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/docs/inference-providers\"\n\t\t\t\t\tclass=\"ml-auto text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\taria-label=\"Hub discussion about models\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonHelpFilled />\n\t\t\t\t</a>\n\t\t\t{/if}\n\t\t</div>\n\t\t<h2 class=\"text-gray-500\">\n\t\t\tAll models available{#if publicConfig.isHuggingChat}&nbsp;via <a\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\thref=\"https://huggingface.co/inference/models\"\n\t\t\t\t\tclass=\"underline decoration-gray-300 hover:decoration-gray-500 dark:decoration-gray-600 dark:hover:decoration-gray-500\"\n\t\t\t\t\t>Inference Providers</a\n\t\t\t\t>{/if}\n\t\t</h2>\n\n\t\t<!-- Filter input -->\n\t\t<input\n\t\t\ttype=\"search\"\n\t\t\tbind:value={modelFilter}\n\t\t\tplaceholder=\"Search by name\"\n\t\t\taria-label=\"Search models by name or id\"\n\t\t\tclass=\"mt-4 w-full rounded-3xl border border-gray-300 bg-white px-5 py-2 text-[15px]\n\t\t\t\tplaceholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300\n\t\t\t\tdark:border-gray-700 dark:bg-gray-900 dark:focus:ring-gray-700\"\n\t\t/>\n\n\t\t<div class=\"mt-6 min-h-[50vh]\">\n\t\t\t<div\n\t\t\t\tclass=\"overflow-hidden rounded-2xl border border-gray-200/60 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900\"\n\t\t\t>\n\t\t\t\t{#each filteredModels as model, index (model.id)}\n\t\t\t\t\t{@const isActive = model.id === $settings.activeModel}\n\t\t\t\t\t{@const isLast = index === filteredModels.length - 1}\n\t\t\t\t\t<a\n\t\t\t\t\t\thref=\"{base}/models/{model.id}\"\n\t\t\t\t\t\taria-label=\"Model card for {model.displayName}\"\n\t\t\t\t\t\tclass=\"group flex cursor-pointer items-center gap-2 p-3 sm:gap-4 sm:p-4\n\t\t\t\t\t\t\t{isActive\n\t\t\t\t\t\t\t? 'bg-gray-50 dark:bg-gray-800'\n\t\t\t\t\t\t\t: 'bg-white hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800'}\n\t\t\t\t\t\t\t{isLast ? '' : 'border-b border-gray-100 dark:border-gray-800'}\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<!-- Avatar -->\n\t\t\t\t\t\t<div class=\"flex-shrink-0\">\n\t\t\t\t\t\t\t{#if model.logoUrl}\n\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\talt={model.displayName}\n\t\t\t\t\t\t\t\t\tclass=\"size-8 rounded-lg border border-gray-100 bg-gray-50 object-cover dark:border-gray-700 dark:bg-gray-100 sm:size-10\"\n\t\t\t\t\t\t\t\t\tsrc={model.logoUrl}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclass=\"h-10 w-10 rounded-lg border border-gray-100 bg-gray-200 dark:border-gray-700 dark:bg-gray-700\"\n\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t></div>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<!-- Content -->\n\t\t\t\t\t\t<div class=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t<div class=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t\t<h3\n\t\t\t\t\t\t\t\t\tclass=\"truncate font-medium text-gray-900 dark:text-gray-200 max-sm:text-xs\"\n\t\t\t\t\t\t\t\t\tclass:font-bold={isActive}\n\t\t\t\t\t\t\t\t\tclass:dark:text-white={isActive}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{model.displayName}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t{#if index === 0 && model.isRouter && !isActive}\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"rounded border border-gray-200 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\tDefault\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"truncate pr-4 text-xs text-gray-500 dark:text-gray-400 sm:text-[13px]\">\n\t\t\t\t\t\t\t\t{model.isRouter\n\t\t\t\t\t\t\t\t\t? \"Routes your messages to the best model for your request.\"\n\t\t\t\t\t\t\t\t\t: model.description || \"-\"}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t<!-- Icons and badges -->\n\t\t\t\t\t\t<div class=\"flex flex-shrink-0 items-center gap-1.5\">\n\t\t\t\t\t\t\t{#if publicConfig.isHuggingChat && !model.isRouter && $settings.providerOverrides?.[model.id] && $settings.providerOverrides[model.id] !== \"auto\"}\n\t\t\t\t\t\t\t\t{@const providerOverride = $settings.providerOverrides[model.id]}\n\t\t\t\t\t\t\t\t{@const hubOrg =\n\t\t\t\t\t\t\t\t\tPROVIDERS_HUB_ORGS[providerOverride as keyof typeof PROVIDERS_HUB_ORGS]}\n\t\t\t\t\t\t\t\t{#if providerOverride === \"fastest\"}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\ttitle=\"Provider: Fastest\"\n\t\t\t\t\t\t\t\t\t\tclass=\"rounded-md bg-green-50 p-1.5 text-green-600 dark:bg-green-900/20 dark:text-green-400\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<IconFast classNames=\"size-3 sm:size-3.5\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{:else if providerOverride === \"cheapest\"}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\ttitle=\"Provider: Cheapest\"\n\t\t\t\t\t\t\t\t\t\tclass=\"rounded-md bg-blue-50 p-1.5 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<IconCheap classNames=\"size-3 sm:size-3.5\" />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{:else if hubOrg}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\ttitle=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\t\t\tclass=\"flex size-[26px] items-center justify-center rounded-md bg-gray-100 p-1 dark:bg-gray-800 sm:size-[30px]\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\t\t\talt={providerOverride}\n\t\t\t\t\t\t\t\t\t\t\tclass=\"size-full rounded\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{#if $settings.toolsOverrides?.[model.id] ?? (model as { supportsTools?: boolean }).supportsTools}\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\ttitle=\"This model supports tool calling (functions).\"\n\t\t\t\t\t\t\t\t\tclass=\"rounded-md bg-purple-50 p-1.5 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<LucideHammer class=\"size-3 sm:size-3.5\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{#if $settings.multimodalOverrides?.[model.id] ?? model.multimodal}\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\ttitle=\"This model is multimodal and supports image inputs natively.\"\n\t\t\t\t\t\t\t\t\tclass=\"rounded-md bg-blue-50 p-1.5 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<LucideImage class=\"size-3 sm:size-3.5\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\ttitle=\"Model settings\"\n\t\t\t\t\t\t\t\taria-label=\"Model settings for {model.displayName}\"\n\t\t\t\t\t\t\t\tclass=\"rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700\"\n\t\t\t\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\tgoto(`${base}/settings/${model.id}`);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<LucideSettings class=\"size-3 sm:size-3.5\" />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{#if isActive}\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclass=\"rounded-full bg-black px-2.5 py-1 text-xs font-bold text-white shadow-md dark:bg-white dark:text-black\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tActive\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</a>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/models/[...model]/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from \"$app/state\";\n\timport { base } from \"$app/paths\";\n\timport { goto, replaceState } from \"$app/navigation\";\n\timport { onMount, tick } from \"svelte\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\timport ChatWindow from \"$lib/components/chat/ChatWindow.svelte\";\n\timport { findCurrentModel } from \"$lib/utils/models\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport { ERROR_MESSAGES, error } from \"$lib/stores/errors\";\n\timport { pendingMessage } from \"$lib/stores/pendingMessage\";\n\timport { sanitizeUrlParam } from \"$lib/utils/urlParams\";\n\timport { loadAttachmentsFromUrls } from \"$lib/utils/loadAttachmentsFromUrls\";\n\timport { requireAuthUser } from \"$lib/utils/auth\";\n\n\tlet { data } = $props();\n\n\tlet loading = $state(false);\n\tlet files: File[] = $state([]);\n\tlet draft = $state(\"\");\n\n\tconst settings = useSettingsStore();\n\tlet modelId = $derived(page.params.model ?? \"\");\n\tconst publicConfig = usePublicConfig();\n\tlet modelPath = $derived(\n\t\tmodelId\n\t\t\t.split(\"/\")\n\t\t\t.map((segment) => encodeURIComponent(segment))\n\t\t\t.join(\"/\")\n\t);\n\n\tasync function createConversation(message: string) {\n\t\ttry {\n\t\t\tloading = true;\n\n\t\t\tconst res = await fetch(`${base}/conversation`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\tmodel: modelId,\n\t\t\t\t\tpreprompt: $settings.customPrompts[modelId],\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tif (!res.ok) {\n\t\t\t\terror.set(\"Error while creating conversation, try again.\");\n\t\t\t\tconsole.error(\"Error while creating conversation: \" + (await res.text()));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst { conversationId } = await res.json();\n\n\t\t\t// Ugly hack to use a store as temp storage, feel free to improve ^^\n\t\t\tpendingMessage.set({\n\t\t\t\tcontent: message,\n\t\t\t\tfiles,\n\t\t\t});\n\n\t\t\t// invalidateAll to update list of conversations\n\t\t\tawait goto(`${base}/conversation/${conversationId}`, { invalidateAll: true });\n\t\t} catch (err) {\n\t\t\terror.set(ERROR_MESSAGES.default);\n\t\t\tconsole.error(err);\n\t\t} finally {\n\t\t\tloading = false;\n\t\t}\n\t}\n\n\tonMount(async () => {\n\t\ttry {\n\t\t\t// Check if auth is required before processing any query params\n\t\t\tconst hasQ = page.url.searchParams.has(\"q\");\n\t\t\tconst hasPrompt = page.url.searchParams.has(\"prompt\");\n\t\t\tconst hasAttachments = page.url.searchParams.has(\"attachments\");\n\n\t\t\tif ((hasQ || hasPrompt || hasAttachments) && requireAuthUser()) {\n\t\t\t\treturn; // Redirecting to login, will return to this URL after\n\t\t\t}\n\n\t\t\t// Handle attachments parameter first\n\t\t\tif (hasAttachments) {\n\t\t\t\tconst result = await loadAttachmentsFromUrls(page.url.searchParams);\n\t\t\t\tfiles = result.files;\n\n\t\t\t\t// Show errors if any\n\t\t\t\tif (result.errors.length > 0) {\n\t\t\t\t\tconsole.error(\"Failed to load some attachments:\", result.errors);\n\t\t\t\t\terror.set(\n\t\t\t\t\t\t`Failed to load ${result.errors.length} attachment(s). Check console for details.`\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Clean up URL\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"attachments\");\n\t\t\t\thistory.replaceState({}, \"\", url);\n\t\t\t}\n\n\t\t\tconst query = sanitizeUrlParam(page.url.searchParams.get(\"q\"));\n\t\t\tif (query) {\n\t\t\t\tvoid createConversation(query);\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"q\");\n\t\t\t\ttick().then(() => {\n\t\t\t\t\treplaceState(url, page.state);\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst promptQuery = sanitizeUrlParam(page.url.searchParams.get(\"prompt\"));\n\t\t\tif (promptQuery && !draft) {\n\t\t\t\tdraft = promptQuery;\n\t\t\t\tconst url = new URL(page.url);\n\t\t\t\turl.searchParams.delete(\"prompt\");\n\t\t\t\ttick().then(() => {\n\t\t\t\t\treplaceState(url, page.state);\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconsole.error(\"Failed to process URL parameters:\", err);\n\t\t}\n\n\t\tsettings.instantSet({ activeModel: modelId });\n\t});\n</script>\n\n<svelte:head>\n\t<title>{modelId} - {publicConfig.PUBLIC_APP_NAME}</title>\n\t<meta property=\"og:title\" content=\"{modelId} - {publicConfig.PUBLIC_APP_NAME}\" />\n\t<meta property=\"og:type\" content=\"website\" />\n\t<meta property=\"og:description\" content=\"Use {modelId} with {publicConfig.PUBLIC_APP_NAME}\" />\n\t<meta\n\t\tproperty=\"og:image\"\n\t\tcontent=\"{publicConfig.PUBLIC_ORIGIN || page.url.origin}{base}/models/{modelPath}/thumbnail.png\"\n\t/>\n\t<meta property=\"og:image:alt\" content=\"{modelId} - {publicConfig.PUBLIC_APP_NAME}\" />\n\t<meta property=\"og:image:width\" content=\"1200\" />\n\t<meta property=\"og:image:height\" content=\"648\" />\n\t<meta property=\"og:url\" content={page.url.href} />\n\t<meta property=\"og:site_name\" content={publicConfig.PUBLIC_APP_NAME} />\n\t<meta name=\"twitter:card\" content=\"summary_large_image\" />\n\t<meta name=\"twitter:title\" content=\"{modelId} - {publicConfig.PUBLIC_APP_NAME}\" />\n\t<meta name=\"twitter:description\" content=\"Use {modelId} with {publicConfig.PUBLIC_APP_NAME}\" />\n\t<meta\n\t\tname=\"twitter:image\"\n\t\tcontent=\"{publicConfig.PUBLIC_ORIGIN || page.url.origin}{base}/models/{modelPath}/thumbnail.png\"\n\t/>\n\t<meta name=\"twitter:image:alt\" content=\"{modelId} - {publicConfig.PUBLIC_APP_NAME}\" />\n</svelte:head>\n\n<ChatWindow\n\tonmessage={(message) => createConversation(message)}\n\t{loading}\n\tcurrentModel={findCurrentModel(data.models, data.oldModels, modelId)}\n\tmodels={data.models}\n\tbind:files\n\tbind:draft\n/>\n"
  },
  {
    "path": "src/routes/models/[...model]/+page.ts",
    "content": "import { base } from \"$app/paths\";\n\nexport async function load({ params, parent, fetch }) {\n\tawait fetch(`${base}/api/v2/models/${params.model}/subscribe`, {\n\t\tmethod: \"POST\",\n\t});\n\n\treturn {\n\t\tsettings: await parent().then((data) => ({\n\t\t\t...data.settings,\n\t\t\tactiveModel: params.model,\n\t\t})),\n\t};\n}\n"
  },
  {
    "path": "src/routes/privacy/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { marked } from \"marked\";\n\timport privacy from \"../../../PRIVACY.md?raw\";\n</script>\n\n<div class=\"overflow-auto p-6\">\n\t<div class=\"prose mx-auto px-4 pb-24 pt-6 dark:prose-invert md:pt-12\">\n\t\t<!-- eslint-disable-next-line svelte/no-at-html-tags -->\n\t\t{@html marked(privacy, { gfm: true })}\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/r/[id]/+page.ts",
    "content": "import { redirect } from \"@sveltejs/kit\";\nimport { useAPIClient, handleResponse } from \"$lib/APIClient\";\nimport { base } from \"$app/paths\";\nimport type { PageLoad } from \"./$types\";\n\nexport const load: PageLoad = async ({ params, url, fetch, parent }) => {\n\tconst leafId = url.searchParams.get(\"leafId\");\n\tconst parentData = await parent();\n\n\t// If logged in, import the share and redirect to the new conversation\n\tif (parentData.loginEnabled && parentData.user && params.id) {\n\t\tconst client = useAPIClient({ fetch, origin: url.origin });\n\n\t\tlet importedConversationId: string | undefined;\n\t\ttry {\n\t\t\tconst result = await client.conversations[\"import-share\"]\n\t\t\t\t.post({ shareId: params.id })\n\t\t\t\t.then(handleResponse);\n\t\t\timportedConversationId = result.conversationId;\n\t\t} catch {\n\t\t\t// Fall through to view-only mode on error\n\t\t}\n\n\t\tif (importedConversationId) {\n\t\t\tredirect(\n\t\t\t\t302,\n\t\t\t\t`${base}/conversation/${importedConversationId}?leafId=${leafId ?? \"\"}&fromShare=${params.id}`\n\t\t\t);\n\t\t}\n\t}\n\n\t// Not logged in or import failed: redirect to view-only mode\n\tredirect(302, `${base}/conversation/${params.id}${leafId ? `?leafId=${leafId}` : \"\"}`);\n};\n"
  },
  {
    "path": "src/routes/settings/(nav)/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount, tick } from \"svelte\";\n\timport { base } from \"$app/paths\";\n\timport { afterNavigate, goto } from \"$app/navigation\";\n\timport { page } from \"$app/state\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport IconOmni from \"$lib/components/icons/IconOmni.svelte\";\n\timport IconBurger from \"$lib/components/icons/IconBurger.svelte\";\n\timport IconFast from \"$lib/components/icons/IconFast.svelte\";\n\timport IconCheap from \"$lib/components/icons/IconCheap.svelte\";\n\timport CarbonClose from \"~icons/carbon/close\";\n\timport CarbonTextLongParagraph from \"~icons/carbon/text-long-paragraph\";\n\timport CarbonChevronLeft from \"~icons/carbon/chevron-left\";\n\timport LucideImage from \"~icons/lucide/image\";\n\timport LucideHammer from \"~icons/lucide/hammer\";\n\timport IconGear from \"~icons/bi/gear-fill\";\n\timport { PROVIDERS_HUB_ORGS } from \"@huggingface/inference\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\n\timport type { LayoutData } from \"../$types\";\n\timport { browser } from \"$app/environment\";\n\timport { isDesktop } from \"$lib/utils/isDesktop\";\n\timport { debounce } from \"$lib/utils/debounce\";\n\n\tinterface Props {\n\t\tdata: LayoutData;\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { data, children }: Props = $props();\n\n\tlet previousPage: string = $state(base || \"/\");\n\tlet showContent: boolean = $state(false);\n\n\tlet navContainer: HTMLDivElement | undefined = $state();\n\n\tasync function scrollSelectedModelIntoView() {\n\t\tawait tick();\n\t\tconst container = navContainer;\n\t\tif (!container) return;\n\t\tconst currentModelId = page.params.model as string | undefined;\n\t\tif (!currentModelId) return;\n\t\tconst buttons = container.querySelectorAll<HTMLButtonElement>(\"button[data-model-id]\");\n\t\tlet target: HTMLElement | null = null;\n\t\tfor (const btn of buttons) {\n\t\t\tif (btn.dataset.modelId === currentModelId) {\n\t\t\t\ttarget = btn;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!target) return;\n\t\t// Use minimal movement; keep within view if needed\n\t\ttarget.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n\t}\n\n\tfunction checkDesktopRedirect() {\n\t\tif (\n\t\t\tbrowser &&\n\t\t\tisDesktop(window) &&\n\t\t\tpage.url.pathname === `${base}/settings` &&\n\t\t\t!page.url.pathname.endsWith(\"/application\")\n\t\t) {\n\t\t\tgoto(`${base}/settings/application`);\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\t// Show content when not on the root settings page\n\t\tshowContent = page.url.pathname !== `${base}/settings`;\n\t\t// Initial desktop redirect check\n\t\tcheckDesktopRedirect();\n\n\t\t// Ensure the selected model (if any) is visible in the nav\n\t\tvoid scrollSelectedModelIntoView();\n\n\t\t// Add resize listener for desktop redirect\n\t\tif (browser) {\n\t\t\tconst debouncedCheck = debounce(checkDesktopRedirect, 100);\n\t\t\twindow.addEventListener(\"resize\", debouncedCheck);\n\t\t\treturn () => window.removeEventListener(\"resize\", debouncedCheck);\n\t\t}\n\t});\n\n\tafterNavigate(({ from }) => {\n\t\tif (from?.url && !from.url.pathname.includes(\"settings\")) {\n\t\t\tpreviousPage = from.url.toString() || previousPage || base || \"/\";\n\t\t}\n\t\t// Show content when not on the root settings page\n\t\tshowContent = page.url.pathname !== `${base}/settings`;\n\t\t// Check desktop redirect after navigation\n\t\tcheckDesktopRedirect();\n\t\t// After navigation, keep the selected model in view\n\t\tvoid scrollSelectedModelIntoView();\n\t});\n\n\tconst settings = useSettingsStore();\n\n\t// Local filter for model list (hyphen/space insensitive)\n\tlet modelFilter = $state(\"\");\n\tconst normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, \" \");\n\tlet queryTokens = $derived(normalize(modelFilter).trim().split(/\\s+/).filter(Boolean));\n</script>\n\n<div\n\tclass=\"mx-auto grid h-full w-full max-w-[1400px] grid-cols-1 grid-rows-[auto,1fr] content-start gap-x-6 overflow-hidden p-4 text-gray-800 dark:text-gray-300 md:grid-cols-3 md:grid-rows-[auto,1fr] md:p-4\"\n>\n\t<div class=\"col-span-1 mb-3 flex items-center justify-between md:col-span-3 md:mb-4\">\n\t\t{#if showContent && browser}\n\t\t\t<button\n\t\t\t\tclass=\"btn rounded-lg md:hidden\"\n\t\t\t\taria-label=\"Back to menu\"\n\t\t\t\tonclick={() => {\n\t\t\t\t\tshowContent = false;\n\t\t\t\t\tgoto(`${base}/settings`);\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<IconBurger\n\t\t\t\t\tclassNames=\"text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white sm:hidden\"\n\t\t\t\t/>\n\t\t\t\t<CarbonChevronLeft\n\t\t\t\t\tclass=\"text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white max-sm:hidden\"\n\t\t\t\t/>\n\t\t\t</button>\n\t\t{/if}\n\t\t<h2 class=\" left-0 right-0 mx-auto w-fit text-center text-xl font-bold md:hidden\">Settings</h2>\n\t\t<button\n\t\t\tclass=\"btn rounded-lg\"\n\t\t\taria-label=\"Close settings\"\n\t\t\tonclick={() => {\n\t\t\t\tgoto(previousPage);\n\t\t\t}}\n\t\t>\n\t\t\t<CarbonClose\n\t\t\t\tclass=\"text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white\"\n\t\t\t/>\n\t\t</button>\n\t</div>\n\t{#if !(showContent && browser && !isDesktop(window))}\n\t\t<div\n\t\t\tclass=\"scrollbar-custom col-span-1 flex flex-col overflow-y-auto whitespace-nowrap rounded-r-xl bg-gradient-to-l from-gray-50 to-10% dark:from-gray-700/40 max-md:-mx-4 max-md:h-full md:pr-6\"\n\t\t\tclass:max-md:hidden={showContent && browser}\n\t\t\tbind:this={navContainer}\n\t\t>\n\t\t\t<!-- Section Headers -->\n\t\t\t<h3\n\t\t\t\tclass=\"px-3 pb-1 pt-2 text-xs font-semibold tracking-wide text-gray-600 dark:text-gray-400 md:text-left\"\n\t\t\t>\n\t\t\t\tModels\n\t\t\t</h3>\n\n\t\t\t<!-- Filter input -->\n\t\t\t<div class=\"px-2 py-2\">\n\t\t\t\t<input\n\t\t\t\t\tbind:value={modelFilter}\n\t\t\t\t\ttype=\"search\"\n\t\t\t\t\tplaceholder=\"Search by name\"\n\t\t\t\t\taria-label=\"Search models by name or id\"\n\t\t\t\t\tclass=\"w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700\"\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t{#each data.models\n\t\t\t\t.filter((el) => !el.unlisted)\n\t\t\t\t.filter((el) => {\n\t\t\t\t\tconst haystack = normalize(`${el.id} ${el.name ?? \"\"} ${el.displayName ?? \"\"}`);\n\t\t\t\t\treturn queryTokens.every((q) => haystack.includes(q));\n\t\t\t\t}) as model}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonclick={() => goto(`${base}/settings/${model.id}`)}\n\t\t\t\t\tclass=\"group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {model.id ===\n\t\t\t\t\tpage.params.model\n\t\t\t\t\t\t? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'\n\t\t\t\t\t\t: ''}\"\n\t\t\t\t\tdata-model-id={model.id}\n\t\t\t\t\taria-label=\"Configure {model.displayName}\"\n\t\t\t\t>\n\t\t\t\t\t<div class=\"mr-auto flex items-center gap-1 truncate\">\n\t\t\t\t\t\t<span class=\"truncate\">{model.displayName}</span>\n\t\t\t\t\t\t{#if model.isRouter}\n\t\t\t\t\t\t\t<IconOmni />\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{#if publicConfig.isHuggingChat && !model.isRouter && $settings.providerOverrides?.[model.id] && $settings.providerOverrides[model.id] !== \"auto\"}\n\t\t\t\t\t\t{@const providerOverride = $settings.providerOverrides[model.id]}\n\t\t\t\t\t\t{@const hubOrg =\n\t\t\t\t\t\t\tPROVIDERS_HUB_ORGS[providerOverride as keyof typeof PROVIDERS_HUB_ORGS]}\n\t\t\t\t\t\t{#if providerOverride === \"fastest\"}\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\ttitle=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\tclass=\"grid size-[21px] flex-none place-items-center rounded-md bg-green-500/10 text-green-600 dark:text-green-500\"\n\t\t\t\t\t\t\t\taria-label=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IconFast classNames=\"size-3\" />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{:else if providerOverride === \"cheapest\"}\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\ttitle=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\tclass=\"grid size-[21px] flex-none place-items-center rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-500\"\n\t\t\t\t\t\t\t\taria-label=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IconCheap classNames=\"size-3\" />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{:else if hubOrg}\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\ttitle=\"Provider: {providerOverride}\"\n\t\t\t\t\t\t\t\tclass=\"flex size-[21px] flex-none items-center justify-center rounded-md bg-gray-500/10 p-[0.225rem]\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\talt={providerOverride}\n\t\t\t\t\t\t\t\t\tclass=\"size-full rounded\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t{/if}\n\n\t\t\t\t\t{#if $settings.toolsOverrides?.[model.id] ?? (model as { supportsTools?: boolean }).supportsTools}\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\ttitle=\"Tool calling supported\"\n\t\t\t\t\t\t\tclass=\"grid size-[21px] flex-none place-items-center rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-500\"\n\t\t\t\t\t\t\taria-label=\"Model supports tools\"\n\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<LucideHammer class=\"size-3\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t{/if}\n\n\t\t\t\t\t{#if $settings.multimodalOverrides?.[model.id] ?? model.multimodal}\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\ttitle=\"Multimodal support (image inputs)\"\n\t\t\t\t\t\t\tclass=\"grid size-[21px] flex-none place-items-center rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-500\"\n\t\t\t\t\t\t\taria-label=\"Model is multimodal\"\n\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<LucideImage class=\"size-3\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t{/if}\n\n\t\t\t\t\t{#if $settings.customPrompts?.[model.id]}\n\t\t\t\t\t\t<CarbonTextLongParagraph\n\t\t\t\t\t\t\tclass=\"size-6 rounded-md border border-gray-300 p-1 text-gray-800 dark:border-gray-600 dark:text-gray-200\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t{/if}\n\t\t\t\t\t{#if model.id === $settings.activeModel}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclass=\"flex h-[21px] items-center rounded-md bg-black/90 px-2 text-[11px] font-semibold leading-none text-white dark:bg-white dark:text-black\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tActive\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t</button>\n\t\t\t{/each}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonclick={() => goto(`${base}/settings/application`)}\n\t\t\t\tclass=\"group sticky bottom-0 mt-1 flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 dark:text-gray-300 max-md:order-first md:rounded-xl md:px-3 {page\n\t\t\t\t\t.url.pathname === `${base}/settings/application`\n\t\t\t\t\t? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'\n\t\t\t\t\t: 'bg-white dark:bg-gray-800'}\"\n\t\t\t\taria-label=\"Configure application settings\"\n\t\t\t>\n\t\t\t\t<IconGear class=\"mr-0.5 text-xxs\" />\n\t\t\t\tApplication Settings\n\t\t\t</button>\n\t\t</div>\n\t{/if}\n\t{#if showContent}\n\t\t<div\n\t\t\tclass=\"scrollbar-custom col-span-1 w-full overflow-y-auto overflow-x-clip px-1 md:col-span-2 md:row-span-2\"\n\t\t\tclass:max-md:hidden={!showContent && browser}\n\t\t>\n\t\t\t{@render children?.()}\n\t\t</div>\n\t{/if}\n</div>\n"
  },
  {
    "path": "src/routes/settings/(nav)/+layout.ts",
    "content": "export const ssr = false;\n"
  },
  {
    "path": "src/routes/settings/(nav)/+page.svelte",
    "content": ""
  },
  {
    "path": "src/routes/settings/(nav)/+server.ts",
    "content": "import { collections } from \"$lib/server/database\";\nimport { z } from \"zod\";\nimport { authCondition } from \"$lib/server/auth\";\nimport { DEFAULT_SETTINGS, type SettingsEditable } from \"$lib/types/Settings\";\nimport { resolveStreamingMode } from \"$lib/utils/messageUpdates\";\n\nconst settingsSchema = z.object({\n\tshareConversationsWithModelAuthors: z\n\t\t.boolean()\n\t\t.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),\n\twelcomeModalSeen: z.boolean().optional(),\n\tactiveModel: z.string().default(DEFAULT_SETTINGS.activeModel),\n\tcustomPrompts: z.record(z.string()).default({}),\n\tmultimodalOverrides: z.record(z.boolean()).default({}),\n\ttoolsOverrides: z.record(z.boolean()).default({}),\n\tproviderOverrides: z.record(z.string()).default({}),\n\tstreamingMode: z.enum([\"raw\", \"smooth\"]).optional(),\n\tdirectPaste: z.boolean().default(false),\n\thapticsEnabled: z.boolean().default(true),\n\thidePromptExamples: z.record(z.boolean()).default({}),\n\tbillingOrganization: z.string().optional(),\n});\n\nexport async function POST({ request, locals }) {\n\tconst body = await request.json();\n\n\tconst { welcomeModalSeen, ...parsedSettings } = settingsSchema.parse(body);\n\tconst streamingMode = resolveStreamingMode(parsedSettings);\n\tconst settings = {\n\t\t...parsedSettings,\n\t\tstreamingMode,\n\t} satisfies SettingsEditable;\n\n\tawait collections.settings.updateOne(\n\t\tauthCondition(locals),\n\t\t{\n\t\t\t$set: {\n\t\t\t\t...settings,\n\t\t\t\t...(welcomeModalSeen && { welcomeModalSeenAt: new Date() }),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t},\n\t\t\t$setOnInsert: {\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tupsert: true,\n\t\t}\n\t);\n\t// return ok response\n\treturn new Response();\n}\n"
  },
  {
    "path": "src/routes/settings/(nav)/[...model]/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { page } from \"$app/state\";\n\timport { base } from \"$app/paths\";\n\n\timport type { BackendModel } from \"$lib/server/models\";\n\timport IconOmni from \"$lib/components/icons/IconOmni.svelte\";\n\timport IconFast from \"$lib/components/icons/IconFast.svelte\";\n\timport IconCheap from \"$lib/components/icons/IconCheap.svelte\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport CopyToClipBoardBtn from \"$lib/components/CopyToClipBoardBtn.svelte\";\n\timport CarbonArrowUpRight from \"~icons/carbon/arrow-up-right\";\n\timport CarbonCopy from \"~icons/carbon/copy\";\n\timport CarbonChat from \"~icons/carbon/chat\";\n\timport CarbonCode from \"~icons/carbon/code\";\n\timport CarbonChevronDown from \"~icons/carbon/chevron-down\";\n\timport LucideCheck from \"~icons/lucide/check\";\n\timport CarbonMagicWandFilled from \"~icons/carbon/magic-wand-filled\";\n\timport { PROVIDERS_HUB_ORGS } from \"@huggingface/inference\";\n\timport { Select } from \"bits-ui\";\n\n\timport { goto } from \"$app/navigation\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\timport Switch from \"$lib/components/Switch.svelte\";\n\n\tconst publicConfig = usePublicConfig();\n\tconst settings = useSettingsStore();\n\tconst modelId = $derived(page.params.model ?? \"\");\n\n\t// Functional bindings for nested settings (Svelte 5):\n\t// Avoid binding directly to $settings.*[modelId]; write via store update\n\tfunction getToolsOverride() {\n\t\treturn (\n\t\t\t$settings.toolsOverrides?.[modelId] ??\n\t\t\tBoolean((model as unknown as { supportsTools?: boolean }).supportsTools)\n\t\t);\n\t}\n\tfunction setToolsOverride(v: boolean) {\n\t\tsettings.update((s) => ({\n\t\t\t...s,\n\t\t\ttoolsOverrides: { ...s.toolsOverrides, [modelId]: v },\n\t\t}));\n\t}\n\tfunction getMultimodalOverride() {\n\t\treturn $settings.multimodalOverrides?.[modelId] ?? Boolean(model?.multimodal);\n\t}\n\tfunction setMultimodalOverride(v: boolean) {\n\t\tsettings.update((s) => ({\n\t\t\t...s,\n\t\t\tmultimodalOverrides: { ...s.multimodalOverrides, [modelId]: v },\n\t\t}));\n\t}\n\tfunction getHidePromptExamples() {\n\t\treturn $settings.hidePromptExamples?.[modelId] ?? false;\n\t}\n\tfunction setHidePromptExamples(v: boolean) {\n\t\tsettings.update((s) => ({\n\t\t\t...s,\n\t\t\thidePromptExamples: { ...s.hidePromptExamples, [modelId]: v },\n\t\t}));\n\t}\n\n\tfunction getProviderOverride() {\n\t\treturn $settings.providerOverrides?.[modelId] ?? \"auto\";\n\t}\n\tfunction setProviderOverride(v: string) {\n\t\tsettings.update((s) => ({\n\t\t\t...s,\n\t\t\tproviderOverrides: { ...s.providerOverrides, [modelId]: v },\n\t\t}));\n\t}\n\n\tfunction getCustomPrompt() {\n\t\treturn $settings.customPrompts?.[modelId] ?? \"\";\n\t}\n\tfunction setCustomPrompt(v: string) {\n\t\tsettings.update((s) => ({\n\t\t\t...s,\n\t\t\tcustomPrompts: { ...s.customPrompts, [modelId]: v },\n\t\t}));\n\t}\n\n\ttype RouterProvider = { provider: string } & Record<string, unknown>;\n\n\t$effect(() => {\n\t\tconst defaultPreprompt =\n\t\t\tpage.data.models.find((el: BackendModel) => el.id === modelId)?.preprompt || \"\";\n\t\tsettings.initValue(\"customPrompts\", modelId, defaultPreprompt);\n\t});\n\n\tlet hasCustomPreprompt = $derived(\n\t\t$settings.customPrompts[modelId] !==\n\t\t\tpage.data.models.find((el: BackendModel) => el.id === modelId)?.preprompt\n\t);\n\n\tlet model = $derived(page.data.models.find((el: BackendModel) => el.id === modelId));\n\tlet providerList: RouterProvider[] = $derived((model?.providers ?? []) as RouterProvider[]);\n\n\t// Initialize multimodal override for this model if not set yet\n\t$effect(() => {\n\t\tif (model) {\n\t\t\t// Default to the model's advertised capability\n\t\t\tsettings.initValue(\"multimodalOverrides\", modelId, !!model.multimodal);\n\t\t}\n\t});\n\n\t// Initialize tools override for this model if not set yet\n\t$effect(() => {\n\t\tif (model) {\n\t\t\tsettings.initValue(\n\t\t\t\t\"toolsOverrides\",\n\t\t\t\tmodelId,\n\t\t\t\tBoolean((model as unknown as { supportsTools?: boolean }).supportsTools)\n\t\t\t);\n\t\t}\n\t});\n\n\t// Ensure hidePromptExamples has an entry for this model so the switch can bind safely\n\t$effect(() => {\n\t\tsettings.initValue(\"hidePromptExamples\", modelId, false);\n\t});\n\n\t// Initialize provider override for this model (default to \"auto\")\n\t$effect(() => {\n\t\tsettings.initValue(\"providerOverrides\", modelId, \"auto\");\n\t});\n\n\t// Provider selection policies for the dropdown\n\tconst PROVIDER_POLICIES = [\n\t\t{ value: \"auto\", label: \"Auto (your HF preference order)\" },\n\t\t{ value: \"fastest\", label: \"Fastest (highest throughput)\" },\n\t\t{ value: \"cheapest\", label: \"Cheapest (lowest cost)\" },\n\t] as const;\n</script>\n\n<div class=\"flex flex-col items-start\">\n\t<div class=\"mb-4 flex flex-col gap-0.5\">\n\t\t<h2 class=\"text-base font-semibold md:text-lg\">\n\t\t\t{model.displayName}\n\t\t</h2>\n\n\t\t{#if model.description}\n\t\t\t<p class=\"line-clamp-2 whitespace-pre-wrap text-sm text-gray-600 dark:text-gray-400\">\n\t\t\t\t{model.description}\n\t\t\t</p>\n\t\t{/if}\n\t</div>\n\n\t<!-- Actions -->\n\t<div class=\"mb-4 flex flex-wrap items-center gap-1.5\">\n\t\t<button\n\t\t\tclass=\"flex w-fit items-center rounded-full bg-black px-3 py-1.5 text-sm !text-white shadow-sm hover:bg-black/90 dark:bg-white/80 dark:!text-gray-900 dark:hover:bg-white/90\"\n\t\t\tname=\"Activate model\"\n\t\t\tonclick={(e) => {\n\t\t\t\te.stopPropagation();\n\t\t\t\tsettings.instantSet({\n\t\t\t\t\tactiveModel: modelId,\n\t\t\t\t});\n\t\t\t\tgoto(`${base}/`);\n\t\t\t}}\n\t\t>\n\t\t\t<CarbonChat class=\"mr-1.5 text-sm\" />\n\t\t\tNew chat\n\t\t</button>\n\n\t\t{#if model.modelUrl}\n\t\t\t<a\n\t\t\t\thref={model.modelUrl || \"https://huggingface.co/\" + model.name}\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\trel=\"noreferrer\"\n\t\t\t\tclass=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t>\n\t\t\t\t<CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs \" />\n\t\t\t\tModel page\n\t\t\t</a>\n\t\t{/if}\n\n\t\t{#if model.datasetName || model.datasetUrl}\n\t\t\t<a\n\t\t\t\thref={model.datasetUrl || \"https://huggingface.co/datasets/\" + model.datasetName}\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\trel=\"noreferrer\"\n\t\t\t\tclass=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t>\n\t\t\t\t<CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs \" />\n\t\t\t\tDataset page\n\t\t\t</a>\n\t\t{/if}\n\n\t\t{#if model.websiteUrl}\n\t\t\t<a\n\t\t\t\thref={model.websiteUrl}\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t\trel=\"noreferrer\"\n\t\t\t>\n\t\t\t\t<CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs \" />\n\t\t\t\tModel website\n\t\t\t</a>\n\t\t{/if}\n\n\t\t{#if publicConfig.isHuggingChat}\n\t\t\t{#if !model?.isRouter}\n\t\t\t\t<a\n\t\t\t\t\thref={\"https://huggingface.co/\" + model.name + \"?inference_api=true\"}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonCode class=\"mr-1.5 shrink-0 text-xs\" />\n\t\t\t\t\tUse via API\n\t\t\t\t</a>\n\t\t\t\t<a\n\t\t\t\t\thref={\"https://huggingface.co/\" + model.name}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\tclass=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t\t>\n\t\t\t\t\t<CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-xs\" />\n\t\t\t\t\tView model card\n\t\t\t\t</a>\n\t\t\t{/if}\n\t\t\t<CopyToClipBoardBtn\n\t\t\t\tvalue=\"{publicConfig.PUBLIC_ORIGIN || page.url.origin}{base}/models/{model.id}\"\n\t\t\t\tclassNames=\"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n\t\t\t>\n\t\t\t\t<div class=\"flex items-center gap-1.5\">\n\t\t\t\t\t<CarbonCopy class=\"shrink-0 text-xs\" />Copy direct link\n\t\t\t\t</div>\n\t\t\t</CopyToClipBoardBtn>\n\t\t{/if}\n\t</div>\n\n\t<div class=\"relative flex w-full flex-col gap-2\">\n\t\t{#if model?.isRouter}\n\t\t\t<p class=\"mb-3 mt-2 rounded-lg bg-gray-100 px-3 py-2 text-sm dark:bg-white/5\">\n\t\t\t\t<IconOmni classNames=\"-translate-y-px\" /> Omni routes your messages to the best underlying model\n\t\t\t\tdepending on your request.\n\t\t\t</p>\n\t\t{/if}\n\t\t<div class=\"flex w-full flex-row content-between\">\n\t\t\t<h3 class=\"mb-1 text-[15px] font-semibold text-gray-800 dark:text-gray-200\">System Prompt</h3>\n\t\t\t{#if hasCustomPreprompt}\n\t\t\t\t<button\n\t\t\t\t\tclass=\"ml-auto text-xs underline decoration-gray-300 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400\"\n\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\tsettings.update((s) => ({\n\t\t\t\t\t\t\t...s,\n\t\t\t\t\t\t\tcustomPrompts: { ...s.customPrompts, [modelId]: model.preprompt },\n\t\t\t\t\t\t}));\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\tReset\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<textarea\n\t\t\taria-label=\"Custom system prompt\"\n\t\t\trows=\"8\"\n\t\t\tclass=\"w-full resize-none rounded-md border border-gray-200 bg-gray-50 p-2 text-[13px] dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200\"\n\t\t\tbind:value={getCustomPrompt, setCustomPrompt}\n\t\t></textarea>\n\t\t<!-- Capabilities -->\n\t\t<div\n\t\t\tclass=\"mt-3 rounded-xl border border-gray-200 bg-white px-3 shadow-sm dark:border-gray-700 dark:bg-gray-800\"\n\t\t>\n\t\t\t<div class=\"divide-y divide-gray-200 dark:divide-gray-700\">\n\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\tTool calling (functions)\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\tEnable tools and allow the model to call them in chat.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Switch name=\"forceTools\" bind:checked={getToolsOverride, setToolsOverride} />\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\tMultimodal support (image inputs)\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\tEnable image uploads and send images to this model.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tname=\"forceMultimodal\"\n\t\t\t\t\t\tbind:checked={getMultimodalOverride, setMultimodalOverride}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{#if model?.isRouter}\n\t\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\t\tHide prompt examples\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tHide the prompt suggestions above the chat input.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\tname=\"hidePromptExamples\"\n\t\t\t\t\t\t\tbind:checked={getHidePromptExamples, setHidePromptExamples}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t</div>\n\n\t\t{#if publicConfig.isHuggingChat && model.providers?.length && !model?.isRouter}\n\t\t\t<div\n\t\t\t\tclass=\"mt-3 flex flex-col items-start gap-2.5 rounded-xl border border-gray-200 bg-white px-3 py-3 shadow-sm dark:border-gray-700 dark:bg-gray-800\"\n\t\t\t>\n\t\t\t\t<div>\n\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\tInference Providers\n\t\t\t\t\t</div>\n\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\tChoose which Inference Provider to use with this model. You can also manage provider\n\t\t\t\t\t\tpreferences in <a\n\t\t\t\t\t\t\tclass=\"underline decoration-gray-400 hover:decoration-gray-700 dark:decoration-gray-500 dark:hover:decoration-gray-300\"\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\thref=\"https://huggingface.co/settings/inference-providers/settings\"\n\t\t\t\t\t\t\t>your HF settings</a\n\t\t\t\t\t\t>.\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t\t<Select.Root\n\t\t\t\t\ttype=\"single\"\n\t\t\t\t\tvalue={getProviderOverride()}\n\t\t\t\t\tonValueChange={(v) => v && setProviderOverride(v)}\n\t\t\t\t>\n\t\t\t\t\t<Select.Trigger\n\t\t\t\t\t\taria-label=\"Select inference provider\"\n\t\t\t\t\t\tclass=\"inline-flex w-auto items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-2 py-2 text-sm text-gray-800 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{@const currentValue = getProviderOverride()}\n\t\t\t\t\t\t{@const currentPolicy = PROVIDER_POLICIES.find((p) => p.value === currentValue)}\n\t\t\t\t\t\t{@const currentProvider = providerList.find((p) => p.provider === currentValue)}\n\t\t\t\t\t\t<span class=\"flex items-center gap-2\">\n\t\t\t\t\t\t\t{#if currentValue === \"auto\"}\n\t\t\t\t\t\t\t\t<span class=\"grid size-5 flex-none place-items-center rounded-md bg-gray-500/10\">\n\t\t\t\t\t\t\t\t\t<CarbonMagicWandFilled class=\"size-3 text-gray-700 dark:text-gray-300\" />\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{:else if currentValue === \"fastest\"}\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclass=\"grid size-5 flex-none place-items-center rounded-md bg-green-500/10 text-green-600 dark:text-green-500\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<IconFast classNames=\"size-3\" />\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{:else if currentValue === \"cheapest\"}\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclass=\"grid size-5 flex-none place-items-center rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-500\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<IconCheap classNames=\"size-3\" />\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{:else if currentProvider}\n\t\t\t\t\t\t\t\t{@const hubOrg =\n\t\t\t\t\t\t\t\t\tPROVIDERS_HUB_ORGS[currentValue as keyof typeof PROVIDERS_HUB_ORGS]}\n\t\t\t\t\t\t\t\t{#if hubOrg}\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"flex size-5 flex-none items-center justify-center rounded-md bg-gray-500/10 p-0.5\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\t\t\t\t\tclass=\"size-full rounded\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t{currentPolicy?.label ?? currentProvider?.provider ?? currentValue}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<CarbonChevronDown class=\"size-4 text-gray-500\" />\n\t\t\t\t\t</Select.Trigger>\n\t\t\t\t\t<Select.Portal>\n\t\t\t\t\t\t<Select.Content\n\t\t\t\t\t\t\tclass=\"scrollbar-custom z-50 max-h-60 overflow-y-auto rounded-xl border border-gray-200 bg-white/95 p-1 shadow-lg backdrop-blur dark:border-gray-700 dark:bg-gray-800/95\"\n\t\t\t\t\t\t\tsideOffset={4}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Select.Group>\n\t\t\t\t\t\t\t\t<Select.GroupHeading\n\t\t\t\t\t\t\t\t\tclass=\"px-2 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tSelection mode\n\t\t\t\t\t\t\t\t</Select.GroupHeading>\n\t\t\t\t\t\t\t\t{#each PROVIDER_POLICIES as opt (opt.value)}\n\t\t\t\t\t\t\t\t\t<Select.Item\n\t\t\t\t\t\t\t\t\t\tvalue={opt.value}\n\t\t\t\t\t\t\t\t\t\tclass=\"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-700 outline-none data-[highlighted]:bg-gray-100 dark:text-gray-200 dark:data-[highlighted]:bg-white/10\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{#if opt.value === \"auto\"}\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"grid size-5 flex-none place-items-center rounded-md bg-gray-500/10\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<CarbonMagicWandFilled class=\"size-3 text-gray-700 dark:text-gray-300\" />\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{:else if opt.value === \"fastest\"}\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"grid size-5 flex-none place-items-center rounded-md bg-green-500/10 text-green-600 dark:text-green-500\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<IconFast classNames=\"size-3\" />\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{:else if opt.value === \"cheapest\"}\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"grid size-5 flex-none place-items-center rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-500\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<IconCheap classNames=\"size-3\" />\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t\t<span class=\"flex-1\">{opt.label}</span>\n\t\t\t\t\t\t\t\t\t\t{#if getProviderOverride() === opt.value}\n\t\t\t\t\t\t\t\t\t\t\t<LucideCheck class=\"size-4 text-gray-500\" />\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t</Select.Item>\n\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t</Select.Group>\n\t\t\t\t\t\t\t<div class=\"my-1 h-px bg-gray-200 dark:bg-gray-700\"></div>\n\t\t\t\t\t\t\t<Select.Group>\n\t\t\t\t\t\t\t\t<Select.GroupHeading\n\t\t\t\t\t\t\t\t\tclass=\"px-2 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tSpecific provider\n\t\t\t\t\t\t\t\t</Select.GroupHeading>\n\t\t\t\t\t\t\t\t{#each providerList as prov (prov.provider)}\n\t\t\t\t\t\t\t\t\t{@const hubOrg =\n\t\t\t\t\t\t\t\t\t\tPROVIDERS_HUB_ORGS[prov.provider as keyof typeof PROVIDERS_HUB_ORGS]}\n\t\t\t\t\t\t\t\t\t<Select.Item\n\t\t\t\t\t\t\t\t\t\tvalue={prov.provider}\n\t\t\t\t\t\t\t\t\t\tclass=\"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-700 outline-none data-[highlighted]:bg-gray-100 dark:text-gray-200 dark:data-[highlighted]:bg-white/10\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{#if hubOrg}\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"flex size-5 flex-none items-center justify-center rounded-md bg-gray-500/10 p-0.5\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\t\t\t\t\t\tsrc=\"https://huggingface.co/api/avatars/{hubOrg}\"\n\t\t\t\t\t\t\t\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"size-full rounded\"\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"size-5\"></span>\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t\t<span class=\"flex-1\">{prov.provider}</span>\n\t\t\t\t\t\t\t\t\t\t{#if getProviderOverride() === prov.provider}\n\t\t\t\t\t\t\t\t\t\t\t<LucideCheck class=\"size-4 text-gray-500\" />\n\t\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t\t</Select.Item>\n\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t</Select.Group>\n\t\t\t\t\t\t</Select.Content>\n\t\t\t\t\t</Select.Portal>\n\t\t\t\t</Select.Root>\n\t\t\t</div>\n\t\t{/if}\n\t\t<!-- Tokenizer-based token counting disabled in this build -->\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/settings/(nav)/[...model]/+page.ts",
    "content": "import { base } from \"$app/paths\";\nimport { redirect } from \"@sveltejs/kit\";\n\nexport async function load({ parent, params }) {\n\tconst data = await parent();\n\n\tconst model = data.models.find((m: { id: string }) => m.id === params.model);\n\n\tif (!model || model.unlisted) {\n\t\tredirect(302, `${base}/settings`);\n\t}\n\n\treturn data;\n}\n"
  },
  {
    "path": "src/routes/settings/(nav)/application/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport CarbonTrashCan from \"~icons/carbon/trash-can\";\n\timport CarbonArrowUpRight from \"~icons/carbon/arrow-up-right\";\n\timport CarbonLogoGithub from \"~icons/carbon/logo-github\";\n\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport type { StreamingMode } from \"$lib/types/Settings\";\n\timport Switch from \"$lib/components/Switch.svelte\";\n\n\timport { goto } from \"$app/navigation\";\n\timport { error } from \"$lib/stores/errors\";\n\timport { base } from \"$app/paths\";\n\timport { page } from \"$app/state\";\n\timport { usePublicConfig } from \"$lib/utils/PublicConfig.svelte\";\n\timport { useAPIClient, handleResponse } from \"$lib/APIClient\";\n\timport { onMount } from \"svelte\";\n\timport { browser } from \"$app/environment\";\n\timport { getThemePreference, setTheme, type ThemePreference } from \"$lib/switchTheme\";\n\timport { supportsHaptics } from \"$lib/utils/haptics\";\n\n\tconst publicConfig = usePublicConfig();\n\tlet settings = useSettingsStore();\n\n\t// Functional bindings for store fields (Svelte 5): avoid mutating $settings directly\n\tfunction getShareWithAuthors() {\n\t\treturn $settings.shareConversationsWithModelAuthors;\n\t}\n\tfunction setShareWithAuthors(v: boolean) {\n\t\tsettings.update((s) => ({ ...s, shareConversationsWithModelAuthors: v }));\n\t}\n\tfunction getStreamingMode() {\n\t\treturn $settings.streamingMode;\n\t}\n\tfunction setStreamingMode(v: StreamingMode) {\n\t\tsettings.update((s) => ({ ...s, streamingMode: v }));\n\t}\n\tfunction getDirectPaste() {\n\t\treturn $settings.directPaste;\n\t}\n\tfunction setDirectPaste(v: boolean) {\n\t\tsettings.update((s) => ({ ...s, directPaste: v }));\n\t}\n\tfunction getHapticsEnabled() {\n\t\treturn $settings.hapticsEnabled;\n\t}\n\tfunction setHapticsEnabled(v: boolean) {\n\t\tsettings.update((s) => ({ ...s, hapticsEnabled: v }));\n\t}\n\n\tconst client = useAPIClient();\n\n\tlet OPENAI_BASE_URL = $state<string | null>(null);\n\n\t// Billing organization state\n\ttype BillingOrg = { sub: string; name: string; preferred_username: string };\n\tlet billingOrgs = $state<BillingOrg[]>([]);\n\tlet billingOrgsLoading = $state(false);\n\tlet billingOrgsError = $state<string | null>(null);\n\n\tfunction getBillingOrganization() {\n\t\treturn $settings.billingOrganization ?? \"\";\n\t}\n\tfunction setBillingOrganization(v: string) {\n\t\tsettings.update((s) => ({ ...s, billingOrganization: v }));\n\t}\n\n\tonMount(async () => {\n\t\t// Fetch debug config\n\t\ttry {\n\t\t\tconst cfg = await client.debug.config.get().then(handleResponse);\n\t\t\tOPENAI_BASE_URL = (cfg as { OPENAI_BASE_URL?: string }).OPENAI_BASE_URL || null;\n\t\t} catch (e) {\n\t\t\t// ignore if debug endpoint is unavailable\n\t\t}\n\n\t\t// Fetch billing organizations (only for HuggingChat + logged in users)\n\t\tif (publicConfig.isHuggingChat && page.data.user) {\n\t\t\tbillingOrgsLoading = true;\n\t\t\ttry {\n\t\t\t\tconst data = (await client.user[\"billing-orgs\"].get().then(handleResponse)) as {\n\t\t\t\t\tuserCanPay: boolean;\n\t\t\t\t\torganizations: BillingOrg[];\n\t\t\t\t\tcurrentBillingOrg?: string;\n\t\t\t\t};\n\t\t\t\tbillingOrgs = data.organizations ?? [];\n\t\t\t\t// Update settings if current billing org was cleared by server\n\t\t\t\tif (data.currentBillingOrg !== getBillingOrganization()) {\n\t\t\t\t\tsetBillingOrganization(data.currentBillingOrg ?? \"\");\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tbillingOrgsError = \"Failed to load billing options\";\n\t\t\t} finally {\n\t\t\t\tbillingOrgsLoading = false;\n\t\t\t}\n\t\t}\n\t});\n\n\tlet themePref = $state<ThemePreference>(browser ? getThemePreference() : \"system\");\n\n\t// Admin: model refresh UI state\n\tlet refreshing = $state(false);\n\tlet refreshMessage = $state<string | null>(null);\n</script>\n\n<div class=\"flex w-full flex-col gap-4\">\n\t<h2 class=\"text-center text-lg font-semibold text-gray-800 dark:text-gray-200 md:text-left\">\n\t\tApplication Settings\n\t</h2>\n\n\t{#if OPENAI_BASE_URL !== null}\n\t\t<div\n\t\t\tclass=\"mt-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-[12px] text-gray-700 dark:border-gray-700 dark:bg-gray-700/80 dark:text-gray-300\"\n\t\t>\n\t\t\t<span class=\"font-medium\">API Base URL:</span>\n\t\t\t<code class=\"ml-1 break-all font-mono text-[12px] text-gray-800 dark:text-gray-100\"\n\t\t\t\t>{OPENAI_BASE_URL}</code\n\t\t\t>\n\t\t</div>\n\t{/if}\n\t{#if !!publicConfig.PUBLIC_COMMIT_SHA}\n\t\t<div\n\t\t\tclass=\"flex flex-col items-start justify-between text-xl font-semibold text-gray-800 dark:text-gray-200\"\n\t\t>\n\t\t\t<a\n\t\t\t\thref={`https://github.com/huggingface/chat-ui/commit/${publicConfig.PUBLIC_COMMIT_SHA}`}\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\trel=\"noreferrer\"\n\t\t\t\tclass=\"text-sm font-light text-gray-500 dark:text-gray-400\"\n\t\t\t>\n\t\t\t\tLatest deployment <span class=\"gap-2 font-mono\"\n\t\t\t\t\t>{publicConfig.PUBLIC_COMMIT_SHA.slice(0, 7)}</span\n\t\t\t\t>\n\t\t\t</a>\n\t\t</div>\n\t{/if}\n\t{#if page.data.isAdmin}\n\t\t<div class=\"flex items-center gap-2\">\n\t\t\t<p\n\t\t\t\tclass=\"rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-500/10 dark:text-red-300\"\n\t\t\t>\n\t\t\t\tAdmin mode\n\t\t\t</p>\n\t\t\t<button\n\t\t\t\tclass=\"btn rounded-md text-xs\"\n\t\t\t\tclass:underline={!refreshing}\n\t\t\t\ttype=\"button\"\n\t\t\t\tonclick={async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\trefreshing = true;\n\t\t\t\t\t\trefreshMessage = null;\n\t\t\t\t\t\tconst res = await client.models.refresh.post().then(handleResponse);\n\t\t\t\t\t\tconst delta = `+${res.added.length} −${res.removed.length} ~${res.changed.length}`;\n\t\t\t\t\t\trefreshMessage = `Refreshed in ${res.durationMs} ms • ${delta} • total ${res.total}`;\n\t\t\t\t\t\tawait goto(page.url.pathname, { invalidateAll: true });\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tconsole.error(e);\n\t\t\t\t\t\t$error = \"Model refresh failed\";\n\t\t\t\t\t} finally {\n\t\t\t\t\t\trefreshing = false;\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tdisabled={refreshing}\n\t\t\t>\n\t\t\t\t{refreshing ? \"Refreshing…\" : \"Refresh models\"}\n\t\t\t</button>\n\t\t\t{#if refreshMessage}\n\t\t\t\t<span class=\"text-xs text-gray-600 dark:text-gray-400\">{refreshMessage}</span>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n\t<div class=\"flex h-full flex-col gap-4 max-sm:pt-0\">\n\t\t<div\n\t\t\tclass=\"rounded-xl border border-gray-200 bg-white px-3 shadow-sm dark:border-gray-700 dark:bg-gray-800\"\n\t\t>\n\t\t\t<div class=\"divide-y divide-gray-200 dark:divide-gray-700\">\n\t\t\t\t{#if publicConfig.PUBLIC_APP_DATA_SHARING === \"1\"}\n\t\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\t\tShare with model authors\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tSharing your data helps improve open models over time.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\tname=\"shareConversationsWithModelAuthors\"\n\t\t\t\t\t\t\tbind:checked={getShareWithAuthors, setShareWithAuthors}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\tStreaming mode\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\tChoose how assistant text appears while generating.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<select\n\t\t\t\t\t\tclass=\"rounded-md border border-gray-300 bg-white px-1 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200\"\n\t\t\t\t\t\tvalue={getStreamingMode()}\n\t\t\t\t\t\tonchange={(e) => setStreamingMode(e.currentTarget.value as StreamingMode)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<option value=\"smooth\">Smooth stream</option>\n\t\t\t\t\t\t<option value=\"raw\">Raw stream</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\tPaste text directly\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\tPaste long text directly into chat instead of a file.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Switch name=\"directPaste\" bind:checked={getDirectPaste, setDirectPaste} />\n\t\t\t\t</div>\n\n\t\t\t\t{#if supportsHaptics()}\n\t\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\t\tHaptic feedback\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tVibrate on taps and actions on supported devices.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<Switch name=\"hapticsEnabled\" bind:checked={getHapticsEnabled, setHapticsEnabled} />\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\n\t\t\t\t<!-- Theme selector -->\n\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">Theme</div>\n\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\tChoose light, dark, or follow system.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<select\n\t\t\t\t\t\tclass=\"rounded-md border border-gray-300 bg-white px-1 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200\"\n\t\t\t\t\t\tvalue={themePref}\n\t\t\t\t\t\tonchange={(e) => {\n\t\t\t\t\t\t\tconst v = e.currentTarget.value as ThemePreference;\n\t\t\t\t\t\t\tsetTheme(v);\n\t\t\t\t\t\t\tthemePref = v;\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<option value=\"system\">System</option>\n\t\t\t\t\t\t<option value=\"light\">Light</option>\n\t\t\t\t\t\t<option value=\"dark\">Dark</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Billing section (HuggingChat only) -->\n\t\t{#if publicConfig.isHuggingChat && page.data.user}\n\t\t\t<div\n\t\t\t\tclass=\"rounded-xl border border-gray-200 bg-white px-3 shadow-sm dark:border-gray-700 dark:bg-gray-800\"\n\t\t\t>\n\t\t\t\t<div class=\"divide-y divide-gray-200 dark:divide-gray-700\">\n\t\t\t\t\t<!-- Bill usage to -->\n\t\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">Billing</div>\n\t\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tSelect between personal or organization billing (for eligible organizations).\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"flex items-center\">\n\t\t\t\t\t\t\t{#if billingOrgsLoading}\n\t\t\t\t\t\t\t\t<span class=\"text-xs text-gray-500 dark:text-gray-400\">Loading...</span>\n\t\t\t\t\t\t\t{:else if billingOrgsError}\n\t\t\t\t\t\t\t\t<span class=\"text-xs text-red-500\">{billingOrgsError}</span>\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\tclass=\"rounded-md border border-gray-300 bg-white px-1 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200\"\n\t\t\t\t\t\t\t\t\tvalue={getBillingOrganization()}\n\t\t\t\t\t\t\t\t\tonchange={(e) => setBillingOrganization(e.currentTarget.value)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<option value=\"\">Personal</option>\n\t\t\t\t\t\t\t\t\t{#each billingOrgs as org}\n\t\t\t\t\t\t\t\t\t\t<option value={org.preferred_username}>{org.name}</option>\n\t\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<!-- Providers Usage -->\n\t\t\t\t\t<div class=\"flex items-start justify-between py-3\">\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div class=\"text-[13px] font-medium text-gray-800 dark:text-gray-200\">\n\t\t\t\t\t\t\t\tProviders Usage\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-[12px] text-gray-500 dark:text-gray-400\">\n\t\t\t\t\t\t\t\tSee which providers you use and choose your preferred ones.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref={getBillingOrganization()\n\t\t\t\t\t\t\t\t? `https://huggingface.co/organizations/${getBillingOrganization()}/settings/inference-providers/overview`\n\t\t\t\t\t\t\t\t: \"https://huggingface.co/settings/inference-providers/overview\"}\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\tclass=\"whitespace-nowrap rounded-md border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tView Usage\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<div class=\"mt-6 flex flex-col gap-2 self-start text-[13px]\">\n\t\t\t{#if publicConfig.isHuggingChat}\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://github.com/huggingface/chat-ui\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\tclass=\"flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400\"\n\t\t\t\t\t><CarbonLogoGithub class=\"mr-1.5 shrink-0 text-sm \" /> Github repository</a\n\t\t\t\t>\n\t\t\t\t<a\n\t\t\t\t\thref=\"https://huggingface.co/spaces/huggingchat/chat-ui/discussions/764\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\tclass=\"flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400\"\n\t\t\t\t\t><CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-sm \" /> Share your feedback on HuggingChat</a\n\t\t\t\t>\n\t\t\t\t<a\n\t\t\t\t\thref=\"{base}/privacy\"\n\t\t\t\t\tclass=\"flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400\"\n\t\t\t\t\t><CarbonArrowUpRight class=\"mr-1.5 shrink-0 text-sm \" /> About & Privacy</a\n\t\t\t\t>\n\t\t\t{/if}\n\t\t\t<button\n\t\t\t\tonclick={async (e) => {\n\t\t\t\t\te.preventDefault();\n\n\t\t\t\t\tconfirm(\"Are you sure you want to delete all conversations?\") &&\n\t\t\t\t\t\tclient.conversations\n\t\t\t\t\t\t\t.delete()\n\t\t\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\t\t\tawait goto(`${base}/`, { invalidateAll: true });\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.catch((err) => {\n\t\t\t\t\t\t\t\tconsole.error(err);\n\t\t\t\t\t\t\t\t$error = err.message;\n\t\t\t\t\t\t\t});\n\t\t\t\t}}\n\t\t\t\ttype=\"submit\"\n\t\t\t\tclass=\"flex items-center underline decoration-red-200 underline-offset-2 hover:decoration-red-500 dark:decoration-red-900 dark:hover:decoration-red-700\"\n\t\t\t\t><CarbonTrashCan class=\"mr-2 inline text-sm text-red-500\" />Delete all conversations</button\n\t\t\t>\n\t\t</div>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/routes/settings/+layout.svelte",
    "content": "<script lang=\"ts\">\n\timport { base } from \"$app/paths\";\n\timport { afterNavigate, goto } from \"$app/navigation\";\n\timport { useSettingsStore } from \"$lib/stores/settings\";\n\timport CarbonCheckmark from \"~icons/carbon/checkmark\";\n\n\timport Modal from \"$lib/components/Modal.svelte\";\n\n\tinterface Props {\n\t\tchildren?: import(\"svelte\").Snippet;\n\t}\n\n\tlet { children }: Props = $props();\n\n\tlet previousPage: string = $state(base || \"/\");\n\n\tafterNavigate(({ from }) => {\n\t\tif (from?.url && !from.url.pathname.includes(\"settings\")) {\n\t\t\tpreviousPage = from.url.toString() || previousPage || base || \"/\";\n\t\t}\n\t});\n\n\tconst settings = useSettingsStore();\n</script>\n\n<Modal\n\tonclose={() => goto(previousPage)}\n\tdisableFly={true}\n\twidth=\"border dark:border-gray-700 h-[95dvh] w-[90dvw] pb-0 overflow-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200 sm:h-[95dvh] xl:w-[1200px] xl:h-[85dvh] 2xl:h-[75dvh]\"\n>\n\t{@render children?.()}\n\t{#if $settings.recentlySaved}\n\t\t<div\n\t\t\tclass=\"absolute bottom-4 right-4 m-2 flex items-center gap-1.5 rounded-full border bg-black px-3 py-1 text-white dark:border-white/10 dark:bg-gray-700 dark:text-gray-100\"\n\t\t>\n\t\t\t<CarbonCheckmark class=\"text-white\" />\n\t\t\tSaved\n\t\t</div>\n\t{/if}\n</Modal>\n"
  },
  {
    "path": "src/styles/highlight-js.css",
    "content": "/* Atom One Light (v9.16.2) */\n/*\n\nAtom One Light by Daniel Gamage\nOriginal One Light Syntax theme from https://github.com/atom/one-light-syntax\n\nbase:    #fafafa\nmono-1:  #383a42\nmono-2:  #686b77\nmono-3:  #a0a1a7\nhue-1:   #0184bb\nhue-2:   #4078f2\nhue-3:   #a626a4\nhue-4:   #50a14f\nhue-5:   #e45649\nhue-5-2: #c91243\nhue-6:   #986801\nhue-6-2: #c18401\n\n*/\n\n.hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n\tpadding: 0.5em;\n\tcolor: #383a42;\n\tbackground: #fafafa;\n}\n\n.hljs-comment,\n.hljs-quote {\n\tcolor: #a0a1a7;\n\tfont-style: italic;\n}\n\n.hljs-doctag,\n.hljs-keyword,\n.hljs-formula {\n\tcolor: #a626a4;\n}\n\n.hljs-section,\n.hljs-name,\n.hljs-selector-tag,\n.hljs-deletion,\n.hljs-subst {\n\tcolor: #e45649;\n}\n\n.hljs-literal {\n\tcolor: #0184bb;\n}\n\n.hljs-string,\n.hljs-regexp,\n.hljs-addition,\n.hljs-attribute,\n.hljs-meta-string {\n\tcolor: #50a14f;\n}\n\n.hljs-built_in,\n.hljs-class .hljs-title {\n\tcolor: #c18401;\n}\n\n.hljs-attr,\n.hljs-variable,\n.hljs-template-variable,\n.hljs-type,\n.hljs-selector-class,\n.hljs-selector-attr,\n.hljs-selector-pseudo,\n.hljs-number {\n\tcolor: #986801;\n}\n\n.hljs-symbol,\n.hljs-bullet,\n.hljs-link,\n.hljs-meta,\n.hljs-selector-id,\n.hljs-title {\n\tcolor: #4078f2;\n}\n\n.hljs-emphasis {\n\tfont-style: italic;\n}\n\n.hljs-strong {\n\tfont-weight: bold;\n}\n\n.hljs-link {\n\ttext-decoration: underline;\n}\n\n/* Atom One Dark (v9.16.2) scoped to .dark */\n/*\n\nAtom One Dark by Daniel Gamage\nOriginal One Dark Syntax theme from https://github.com/atom/one-dark-syntax\n\nbase:    #282c34\nmono-1:  #abb2bf\nmono-2:  #818896\nmono-3:  #5c6370\nhue-1:   #56b6c2\nhue-2:   #61aeee\nhue-3:   #c678dd\nhue-4:   #98c379\nhue-5:   #e06c75\nhue-5-2: #be5046\nhue-6:   #d19a66\nhue-6-2: #e6c07b\n\n*/\n\n.dark .hljs {\n\tdisplay: block;\n\toverflow-x: auto;\n\tpadding: 0.5em;\n\tcolor: #abb2bf;\n\tbackground: #282c34;\n}\n\n.dark .hljs-comment,\n.dark .hljs-quote {\n\tcolor: #5c6370;\n\tfont-style: italic;\n}\n\n.dark .hljs-doctag,\n.dark .hljs-keyword,\n.dark .hljs-formula {\n\tcolor: #c678dd;\n}\n\n.dark .hljs-section,\n.dark .hljs-name,\n.dark .hljs-selector-tag,\n.dark .hljs-deletion,\n.dark .hljs-subst {\n\tcolor: #e06c75;\n}\n\n.dark .hljs-literal {\n\tcolor: #56b6c2;\n}\n\n.dark .hljs-string,\n.dark .hljs-regexp,\n.dark .hljs-addition,\n.dark .hljs-attribute,\n.dark .hljs-meta-string {\n\tcolor: #98c379;\n}\n\n.dark .hljs-built_in,\n.dark .hljs-class .hljs-title {\n\tcolor: #e6c07b;\n}\n\n.dark .hljs-attr,\n.dark .hljs-variable,\n.dark .hljs-template-variable,\n.dark .hljs-type,\n.dark .hljs-selector-class,\n.dark .hljs-selector-attr,\n.dark .hljs-selector-pseudo,\n.dark .hljs-number {\n\tcolor: #d19a66;\n}\n\n.dark .hljs-symbol,\n.dark .hljs-bullet,\n.dark .hljs-link,\n.dark .hljs-meta,\n.dark .hljs-selector-id,\n.dark .hljs-title {\n\tcolor: #61aeee;\n}\n\n.dark .hljs-emphasis {\n\tfont-style: italic;\n}\n\n.dark .hljs-strong {\n\tfont-weight: bold;\n}\n\n.dark .hljs-link {\n\ttext-decoration: underline;\n}\n"
  },
  {
    "path": "src/styles/main.css",
    "content": "@import \"./highlight-js.css\";\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml,\nbody {\n\toverscroll-behavior: none;\n\ttouch-action: pan-x pan-y;\n}\n\n@layer components {\n\t.btn {\n\t\t@apply inline-flex flex-shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap outline-none transition-all focus:ring disabled:cursor-default;\n\t}\n\n\t.active-model {\n\t\t/* Ensure active border wins over defaults/utilities in both themes */\n\t\t@apply !border-black dark:!border-white/60;\n\t}\n\n\t.file-hoverable {\n\t\t@apply hover:bg-gray-500/10;\n\t}\n\n\t.base-tool {\n\t\t@apply flex h-[1.6rem] items-center gap-[.2rem] whitespace-nowrap border border-transparent text-xs outline-none transition-all focus:outline-none active:outline-none dark:hover:text-gray-300 sm:hover:text-purple-600;\n\t}\n\n\t.active-tool {\n\t\t@apply rounded-full !border-purple-200 bg-purple-100 pl-1 pr-2 text-purple-600 hover:text-purple-600 dark:!border-purple-700 dark:bg-purple-600/40 dark:text-purple-200;\n\t}\n}\n\n@layer utilities {\n\t/* your existing utilities */\n\t.scrollbar-custom {\n\t\t@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-black/10 scrollbar-thumb-rounded-full scrollbar-w-1 hover:scrollbar-thumb-black/20 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20;\n\t}\n\n\t.scrollbar-custom::-webkit-scrollbar {\n\t\tbackground-color: transparent;\n\t\twidth: 8px;\n\t\theight: 8px;\n\t}\n\n\t.scrollbar-custom::-webkit-scrollbar-thumb {\n\t\tbackground-color: rgba(0, 0, 0, 0.1);\n\t\tborder-radius: 9999px;\n\t}\n\n\t.dark .scrollbar-custom::-webkit-scrollbar {\n\t\tbackground-color: rgba(17, 17, 17, 0.85);\n\t}\n\n\t.dark .scrollbar-custom::-webkit-scrollbar-thumb {\n\t\tbackground-color: rgba(255, 255, 255, 0.1);\n\t}\n\n\t/* Rounded top/bottom caps for vertical scrollbars (Chrome/Edge/Safari) */\n\t.scrollbar-custom::-webkit-scrollbar-track {\n\t\t@apply rounded-full bg-clip-padding; /* clip bg to padding so caps look round */\n\t\t/* space for the end caps — tweak with Tailwind spacing */\n\t\tborder-top: theme(\"spacing.2\") solid transparent; /* 0.5rem */\n\t\tborder-bottom: theme(\"spacing.2\") solid transparent; /* 0.5rem */\n\t}\n\n\t/* Rounded left/right caps for horizontal scrollbars */\n\t.scrollbar-custom::-webkit-scrollbar-track:horizontal {\n\t\t@apply rounded-full bg-clip-padding;\n\t\tborder-left: theme(\"spacing.2\") solid transparent;\n\t\tborder-right: theme(\"spacing.2\") solid transparent;\n\t\tborder-top-width: 0;\n\t\tborder-bottom-width: 0;\n\t}\n\n\t.no-scrollbar {\n\t\t@apply [-ms-overflow-style:none] [scrollbar-width:none] [&::-ms-scrollbar]:hidden [&::-webkit-scrollbar]:hidden;\n\t}\n\n\t.prose table {\n\t\t@apply block max-w-full overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-black/10 scrollbar-thumb-rounded-full scrollbar-w-1 hover:scrollbar-thumb-black/20 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20;\n\t}\n\n\t/* .scrollbar-custom {\n\t\t@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-black/10 scrollbar-thumb-rounded-full scrollbar-w-1 hover:scrollbar-thumb-black/20 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20;\n\t} */\n\t.prose hr {\n\t\t@apply my-4;\n\t}\n\n\t.prose strong {\n\t\t@apply font-medium;\n\t}\n\n\t.prose pre {\n\t\t@apply border-[0.5px] bg-white text-gray-600 dark:border-gray-700 dark:!bg-gray-900 dark:bg-inherit dark:text-inherit;\n\t}\n\n\t.prose code:not(pre code) {\n\t\t@apply rounded-md bg-gray-200/60 px-[0.4em] py-[0.2em] text-[85%] dark:bg-gray-700;\n\t}\n\n\t.prose code:not(pre code)::before,\n\t.prose code:not(pre code)::after {\n\t\tcontent: none;\n\t}\n\n\t/* Override prose-sm title sizes - 75% of original */\n\t.prose-sm :where(h1):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 1.6em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n\n\t.prose-sm :where(h2):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 1.07em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n\n\t.prose-sm :where(h3):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 0.96em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n\n\t.prose-sm :where(h4):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 0.8em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n\n\t.prose-sm :where(h5):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 0.75em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n\n\t.prose-sm :where(h6):not(:where([class~=\"not-prose\"], [class~=\"not-prose\"] *)) {\n\t\tfont-size: 0.7em; /* 75% */\n\t\t@apply font-semibold;\n\t}\n}\n\n.katex-display {\n\toverflow: auto hidden;\n}\n"
  },
  {
    "path": "static/chatui/manifest.json",
    "content": "{\n\t\"background_color\": \"#ffffff\",\n\t\"name\": \"ChatUI\",\n\t\"short_name\": \"ChatUI\",\n\t\"display\": \"standalone\",\n\t\"start_url\": \"/chat\",\n\t\"icons\": [\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-36x36.png\",\n\t\t\t\"sizes\": \"36x36\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-48x48.png\",\n\t\t\t\"sizes\": \"48x48\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-72x72.png\",\n\t\t\t\"sizes\": \"72x72\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-96x96.png\",\n\t\t\t\"sizes\": \"96x96\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-128x128.png\",\n\t\t\t\"sizes\": \"128x128\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-144x144.png\",\n\t\t\t\"sizes\": \"144x144\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-192x192.png\",\n\t\t\t\"sizes\": \"192x192\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-256x256.png\",\n\t\t\t\"sizes\": \"256x256\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/chatui/icon-512x512.png\",\n\t\t\t\"sizes\": \"512x512\",\n\t\t\t\"type\": \"image/png\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "static/huggingchat/manifest.json",
    "content": "{\n\t\"background_color\": \"#ffffff\",\n\t\"name\": \"HuggingChat\",\n\t\"short_name\": \"HuggingChat\",\n\t\"display\": \"standalone\",\n\t\"start_url\": \"/chat\",\n\t\"icons\": [\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-36x36.png\",\n\t\t\t\"sizes\": \"36x36\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-48x48.png\",\n\t\t\t\"sizes\": \"48x48\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-72x72.png\",\n\t\t\t\"sizes\": \"72x72\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-96x96.png\",\n\t\t\t\"sizes\": \"96x96\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-128x128.png\",\n\t\t\t\"sizes\": \"128x128\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-144x144.png\",\n\t\t\t\"sizes\": \"144x144\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-192x192.png\",\n\t\t\t\"sizes\": \"192x192\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-256x256.png\",\n\t\t\t\"sizes\": \"256x256\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/chat/huggingchat/icon-512x512.png\",\n\t\t\t\"sizes\": \"512x512\",\n\t\t\t\"type\": \"image/png\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "static/huggingchat/routes.chat.json",
    "content": "[\n\t{\n\t\t\"name\": \"job_app_docs\",\n\t\t\"description\": \"Create ATS‑ready resumes and cover letters aligned to a job posting.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\n\t\t\t\"deepseek-ai/DeepSeek-V3.1\",\n\t\t\t\"moonshotai/Kimi-K2-Instruct-0905\",\n\t\t\t\"zai-org/GLM-4.6\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"email_writing\",\n\t\t\"description\": \"Draft or revise emails with clear tone and a specific CTA.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"google/gemma-3-27b-it\"]\n\t},\n\t{\n\t\t\"name\": \"social_media_copy\",\n\t\t\"description\": \"Write platform‑specific social captions and short posts for engagement.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-V3.1\",\n\t\t\"fallback_models\": [\"moonshotai/Kimi-K2-Instruct-0905\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"editing_rewrite\",\n\t\t\"description\": \"Lightly proofread and rephrase text for tone, length, and clarity.\",\n\t\t\"primary_model\": \"moonshotai/Kimi-K2-Instruct-0905\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"google/gemma-3-27b-it\", \"zai-org/GLM-4.6\"]\n\t},\n\t{\n\t\t\"name\": \"qa_explanations\",\n\t\t\"description\": \"Provide concise answers and plain‑language explanations.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"meta-llama/Llama-3.3-70B-Instruct\"]\n\t},\n\t{\n\t\t\"name\": \"technical_explanation\",\n\t\t\"description\": \"Explain complex technical topics step‑by‑step with worked examples.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-R1-0528\",\n\t\t\"fallback_models\": [\"Qwen/QwQ-32B\", \"moonshotai/Kimi-K2-Instruct-0905\"]\n\t},\n\t{\n\t\t\"name\": \"essay_writing\",\n\t\t\"description\": \"Plan and write essays from outline to draft; citations on request.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Thinking-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-R1-0528\", \"deepseek-ai/DeepSeek-V3.1\"]\n\t},\n\t{\n\t\t\"name\": \"summarization\",\n\t\t\"description\": \"Condense documents into an abstract, key points, and action items.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\n\t\t\t\"deepseek-ai/DeepSeek-V3.1\",\n\t\t\t\"meta-llama/Llama-4-Maverick-17B-128E-Instruct\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"translation\",\n\t\t\"description\": \"Translate between languages with register and terminology control.\",\n\t\t\"primary_model\": \"CohereLabs/command-a-translate-08-2025\",\n\t\t\"fallback_models\": [\"CohereLabs/aya-expanse-32b\", \"google/gemma-3-27b-it\"]\n\t},\n\t{\n\t\t\"name\": \"language_tutoring\",\n\t\t\"description\": \"Interactive language practice with conversation, grammar, vocab, and feedback.\",\n\t\t\"primary_model\": \"CohereLabs/aya-expanse-32b\",\n\t\t\"fallback_models\": [\n\t\t\t\"CohereLabs/aya-expanse-8b\",\n\t\t\t\"google/gemma-3-27b-it\",\n\t\t\t\"meta-llama/Llama-3.3-70B-Instruct\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"formal_proof\",\n\t\t\"description\": \"Produce Lean 4 proofs with tactic scripts and subgoals.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-Prover-V2-671B\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-R1-0528\", \"Qwen/QwQ-32B\"]\n\t},\n\t{\n\t\t\"name\": \"software_architecture_design\",\n\t\t\"description\": \"Design architectures: views, APIs, data models, and scalability trade‑offs.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"meta-llama/Llama-3.1-405B-Instruct\"]\n\t},\n\t{\n\t\t\"name\": \"agentic_orchestration\",\n\t\t\"description\": \"Plan and execute tool/API calls with schemas, retries, and recovery.\",\n\t\t\"primary_model\": \"openai/gpt-oss-120b\",\n\t\t\"fallback_models\": [\"zai-org/GLM-4.6\", \"deepseek-ai/DeepSeek-V3.1\"]\n\t},\n\t{\n\t\t\"name\": \"code_generation\",\n\t\t\"description\": \"Generate new code, tests, and scaffolds from specs.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"Qwen/Qwen3-Coder-30B-A3B-Instruct\"]\n\t},\n\t{\n\t\t\"name\": \"frontend_ui\",\n\t\t\"description\": \"Build accessible, responsive UI components and pages.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-R1-0528\",\n\t\t\"fallback_models\": [\"Qwen/Qwen3-Coder-480B-A35B-Instruct\", \"zai-org/GLM-4.6\"]\n\t},\n\t{\n\t\t\"name\": \"code_maintenance\",\n\t\t\"description\": \"Fix bugs and refactor code; add tests.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-Coder-480B-A35B-Instruct\",\n\t\t\"fallback_models\": [\n\t\t\t\"deepseek-ai/DeepSeek-V3.1\",\n\t\t\t\"meta-llama/Llama-4-Maverick-17B-128E-Instruct\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"code_review_docs\",\n\t\t\"description\": \"Explain code and write docs, READMEs, and examples.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-V3.1\",\n\t\t\"fallback_models\": [\"meta-llama/Llama-3.3-70B-Instruct\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"terminal_cli\",\n\t\t\"description\": \"Solve Linux shell tasks with safe, idempotent commands.\",\n\t\t\"primary_model\": \"zai-org/GLM-4.6\",\n\t\t\"fallback_models\": [\"meta-llama/Llama-4-Maverick-17B-128E-Instruct\", \"Qwen/Qwen3-32B\"]\n\t},\n\t{\n\t\t\"name\": \"travel_planning\",\n\t\t\"description\": \"Research trips and craft day‑by‑day itineraries with logistics.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\n\t\t\t\"deepseek-ai/DeepSeek-V3.1\",\n\t\t\t\"meta-llama/Llama-4-Maverick-17B-128E-Instruct\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"shopping_recommendations\",\n\t\t\"description\": \"Compare products and recommend ranked picks with rationale.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"zai-org/GLM-4.6\", \"deepseek-ai/DeepSeek-V3.1\"]\n\t},\n\t{\n\t\t\"name\": \"meal_planning\",\n\t\t\"description\": \"Create meal plans and recipes by diet, budget, and time.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"google/gemma-3-27b-it\"]\n\t},\n\t{\n\t\t\"name\": \"decision_support\",\n\t\t\"description\": \"Score options against criteria and recommend a choice.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-R1-0528\",\n\t\t\"fallback_models\": [\"Qwen/Qwen3-235B-A22B-Thinking-2507\", \"deepseek-ai/DeepSeek-V3.1\"]\n\t},\n\t{\n\t\t\"name\": \"career_coaching\",\n\t\t\"description\": \"Guide job search, skill gaps, interviews, and negotiation.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"meta-llama/Llama-3.3-70B-Instruct\", \"deepseek-ai/DeepSeek-V3.1\"]\n\t},\n\t{\n\t\t\"name\": \"personal_finance\",\n\t\t\"description\": \"Build budgets, savings plans, and simple tracking schemas.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"Qwen/Qwen3-235B-A22B-Thinking-2507\"]\n\t},\n\t{\n\t\t\"name\": \"health_wellness_info\",\n\t\t\"description\": \"Provide general health, fitness, sleep, and nutrition information.\",\n\t\t\"primary_model\": \"aaditya/Llama3-OpenBioLLM-70B\",\n\t\t\"fallback_models\": [\"Qwen/Qwen3-235B-A22B-Instruct-2507\", \"google/gemma-3-27b-it\"]\n\t},\n\t{\n\t\t\"name\": \"brainstorming_ideas\",\n\t\t\"description\": \"Generate many creative ideas, then help narrow choices.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-V3.1\",\n\t\t\"fallback_models\": [\"NousResearch/Hermes-4-70B\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"creative_writing\",\n\t\t\"description\": \"Write fiction, poems, jokes, or scripts with style control.\",\n\t\t\"primary_model\": \"moonshotai/Kimi-K2-Instruct-0905\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"meta-llama/Llama-3.3-70B-Instruct\"]\n\t},\n\t{\n\t\t\"name\": \"interactive_roleplay\",\n\t\t\"description\": \"Run in‑character text adventures and persistent role‑play.\",\n\t\t\"primary_model\": \"NousResearch/Hermes-4-70B\",\n\t\t\"fallback_models\": [\"moonshotai/Kimi-K2-Instruct-0905\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"character_impersonation\",\n\t\t\"description\": \"Act and imitate fictional character voices or invented personas consistently.\",\n\t\t\"primary_model\": \"NousResearch/Hermes-4-70B\",\n\t\t\"fallback_models\": [\"moonshotai/Kimi-K2-Instruct-0905\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"casual_conversation\",\n\t\t\"description\": \"Engage in friendly and open‑ended casual chat.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\"moonshotai/Kimi-K2-Instruct-0905\", \"google/gemma-3-27b-it\"]\n\t},\n\t{\n\t\t\"name\": \"emotional_support\",\n\t\t\"description\": \"Provide compassionate listening and gentle guidance for emotional well-being.\",\n\t\t\"primary_model\": \"Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\"fallback_models\": [\n\t\t\t\"meta-llama/Llama-4-Maverick-17B-128E-Instruct\",\n\t\t\t\"deepseek-ai/DeepSeek-V3.1\"\n\t\t]\n\t},\n\t{\n\t\t\"name\": \"learning_tutor\",\n\t\t\"description\": \"Teach concepts with step-by-step explanations, examples, and practice.\",\n\t\t\"primary_model\": \"deepseek-ai/DeepSeek-V3.1\",\n\t\t\"fallback_models\": [\"Qwen/Qwen3-235B-A22B-Thinking-2507\", \"deepseek-ai/DeepSeek-R1-0528\"]\n\t},\n\t{\n\t\t\"name\": \"structured_data\",\n\t\t\"description\": \"Extract structured JSON from text.\",\n\t\t\"primary_model\": \"zai-org/GLM-4.6\",\n\t\t\"fallback_models\": [\"deepseek-ai/DeepSeek-V3.1\", \"Qwen/Qwen3-235B-A22B-Instruct-2507\"]\n\t},\n\t{\n\t\t\"name\": \"spell_checker\",\n\t\t\"description\": \"Fix spelling, capitalization, punctuation, and obvious grammar errors.\",\n\t\t\"primary_model\": \"CohereLabs/aya-expanse-32b\",\n\t\t\"fallback_models\": [\"moonshotai/Kimi-K2-Instruct-0905\", \"google/gemma-3-27b-it\"]\n\t}\n]\n"
  },
  {
    "path": "static/robots.txt",
    "content": "User-agent: *\nAllow: /\nAllow: /r/\nDisallow: /conversation/\nDisallow: /api/\nDisallow: /login\nDisallow: /logout\n\n# Sitemap\n# Sitemap: https://huggingface.co/chat/sitemap.xml\n"
  },
  {
    "path": "stub/@reflink/reflink/index.js",
    "content": ""
  },
  {
    "path": "stub/@reflink/reflink/package.json",
    "content": "{\n\t\"name\": \"@reflink/reflink\",\n\t\"version\": \"0.0.0\",\n\t\"main\": \"index.js\"\n}\n"
  },
  {
    "path": "svelte.config.js",
    "content": "import adapterNode from \"@sveltejs/adapter-node\";\nimport adapterStatic from \"@sveltejs/adapter-static\";\nimport { vitePreprocess } from \"@sveltejs/vite-plugin-svelte\";\nimport dotenv from \"dotenv\";\nimport { execSync } from \"child_process\";\n\ndotenv.config({ path: \"./.env.local\", override: true });\ndotenv.config({ path: \"./.env\" });\n\nconst useStatic = process.env.ADAPTER === \"static\";\n\nfunction getCurrentCommitSHA() {\n\ttry {\n\t\treturn execSync(\"git rev-parse HEAD\").toString();\n\t} catch (error) {\n\t\tconsole.error(\"Error getting current commit SHA:\", error);\n\t\treturn \"unknown\";\n\t}\n}\n\nprocess.env.PUBLIC_VERSION ??= process.env.npm_package_version;\nprocess.env.PUBLIC_COMMIT_SHA ??= getCurrentCommitSHA();\nprocess.env.PUBLIC_APP_ASSETS ??= \"chatui\";\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n\t// Consult https://kit.svelte.dev/docs/integrations#preprocessors\n\t// for more information about preprocessors\n\tpreprocess: vitePreprocess(),\n\n\tkit: {\n\t\tadapter: useStatic ? adapterStatic({ fallback: \"index.html\", strict: false }) : adapterNode(),\n\n\t\tpaths: {\n\t\t\tbase: process.env.APP_BASE || \"\",\n\t\t\trelative: false,\n\t\t},\n\t\tcsrf: {\n\t\t\t// handled in hooks.server.ts, because we can have multiple valid origins\n\t\t\ttrustedOrigins: [\"*\"],\n\t\t},\n\t\tcsp: {\n\t\t\tdirectives: {\n\t\t\t\t...(process.env.ALLOW_IFRAME === \"true\"\n\t\t\t\t\t? {}\n\t\t\t\t\t: { \"frame-ancestors\": [\"https://huggingface.co\"] }),\n\t\t\t},\n\t\t},\n\t\talias: {},\n\t},\n};\n\nexport default config;\n"
  },
  {
    "path": "tailwind.config.cjs",
    "content": "const defaultTheme = require(\"tailwindcss/defaultTheme\");\nconst colors = require(\"tailwindcss/colors\");\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n\tdarkMode: \"class\",\n\tmode: \"jit\",\n\tcontent: [\"./src/**/*.{html,js,svelte,ts}\"],\n\ttheme: {\n\t\textend: {\n\t\t\tcolors: {\n\t\t\t\tgray: {\n\t\t\t\t\t600: \"#323843\",\n\t\t\t\t\t700: \"#252a33\",\n\t\t\t\t\t800: \"#1b1f27\",\n\t\t\t\t\t900: \"#12151c\",\n\t\t\t\t\t950: \"#07090d\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tfontSize: {\n\t\t\t\txxs: \"0.625rem\",\n\t\t\t\tsmd: \"0.94rem\",\n\t\t\t},\n\t\t},\n\t},\n\tplugins: [\n\t\trequire(\"tailwind-scrollbar\")({ nocompatible: true }),\n\t\trequire(\"@tailwindcss/typography\"),\n\t],\n};\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"extends\": \"./.svelte-kit/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"allowJs\": true,\n\t\t\"checkJs\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"resolveJsonModule\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"sourceMap\": true,\n\t\t\"strict\": true,\n\t\t\"target\": \"ES2018\"\n\t},\n\t\"exclude\": [\"vite.config.ts\"]\n\t// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias\n\t//\n\t// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes\n\t// from the referenced tsconfig.json - TypeScript does not merge them in\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { sveltekit } from \"@sveltejs/kit/vite\";\nimport Icons from \"unplugin-icons/vite\";\nimport { promises } from \"fs\";\nimport { defineConfig } from \"vitest/config\";\nimport { config } from \"dotenv\";\n\nconfig({ path: \"./.env.local\" });\n\n// used to load fonts server side for thumbnail generation\nfunction loadTTFAsArrayBuffer() {\n\treturn {\n\t\tname: \"load-ttf-as-array-buffer\",\n\t\tasync transform(_src, id) {\n\t\t\tif (id.endsWith(\".ttf\")) {\n\t\t\t\treturn `export default new Uint8Array([\n\t\t\t${new Uint8Array(await promises.readFile(id))}\n\t\t  ]).buffer`;\n\t\t\t}\n\t\t},\n\t};\n}\nexport default defineConfig({\n\tplugins: [\n\t\tsveltekit(),\n\t\tIcons({\n\t\t\tcompiler: \"svelte\",\n\t\t}),\n\t\tloadTTFAsArrayBuffer(),\n\t],\n\t// Allow external access via ngrok tunnel host\n\tserver: {\n\t\tport: process.env.PORT ? parseInt(process.env.PORT) : 5173,\n\t\t// Allow any ngrok-free.app subdomain (dynamic tunnels)\n\t\t// See Vite server.allowedHosts: string[] | true\n\t\t// Using leading dot matches subdomains per Vite's host check logic\n\t\tallowedHosts: [\"huggingface.ngrok.io\"],\n\t},\n\toptimizeDeps: {\n\t\tinclude: [\"uuid\", \"sharp\", \"clsx\"],\n\t},\n\ttest: {\n\t\tworkspace: [\n\t\t\t...(process.env.VITEST_BROWSER === \"true\"\n\t\t\t\t? [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// Client-side tests (Svelte components), opt-in due flaky browser harness in CI/local\n\t\t\t\t\t\t\textends: \"./vite.config.ts\",\n\t\t\t\t\t\t\ttest: {\n\t\t\t\t\t\t\t\tname: \"client\",\n\t\t\t\t\t\t\t\tenvironment: \"browser\",\n\t\t\t\t\t\t\t\tbrowser: {\n\t\t\t\t\t\t\t\t\tenabled: true,\n\t\t\t\t\t\t\t\t\tprovider: \"playwright\",\n\t\t\t\t\t\t\t\t\tinstances: [{ browser: \"chromium\", headless: true }],\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tinclude: [\"src/**/*.svelte.{test,spec}.{js,ts}\"],\n\t\t\t\t\t\t\t\texclude: [\"src/lib/server/**\", \"src/**/*.ssr.{test,spec}.{js,ts}\"],\n\t\t\t\t\t\t\t\tsetupFiles: [\"./scripts/setups/vitest-setup-client.ts\"],\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t: []),\n\t\t\t{\n\t\t\t\t// SSR tests (Server-side rendering)\n\t\t\t\textends: \"./vite.config.ts\",\n\t\t\t\ttest: {\n\t\t\t\t\tname: \"ssr\",\n\t\t\t\t\tenvironment: \"node\",\n\t\t\t\t\tinclude: [\"src/**/*.ssr.{test,spec}.{js,ts}\"],\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\t// Server-side tests (Node.js utilities)\n\t\t\t\textends: \"./vite.config.ts\",\n\t\t\t\ttest: {\n\t\t\t\t\tname: \"server\",\n\t\t\t\t\tenvironment: \"node\",\n\t\t\t\t\tinclude: [\"src/**/*.{test,spec}.{js,ts}\"],\n\t\t\t\t\texclude: [\"src/**/*.svelte.{test,spec}.{js,ts}\", \"src/**/*.ssr.{test,spec}.{js,ts}\"],\n\t\t\t\t\tsetupFiles: [\"./scripts/setups/vitest-setup-server.ts\"],\n\t\t\t\t\ttestTimeout: 30000,\n\t\t\t\t\thookTimeout: 30000,\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n});\n"
  }
]