diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6eb8615828..0a36356f3e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,38 +1,56 @@ -## Pull Request Checklist +# Pull Request Checklist -- [ ] **Target branch:** Pull requests should target the `dev` branch. -- [ ] **Description:** Briefly describe the changes in this pull request. +### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) and describe your changes before submitting a pull request. + +**Before submitting, make sure you've checked the following:** + +- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch. +- [ ] **Description:** Provide a concise description of the changes made in this pull request. - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. - [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? -- [ ] **Testing:** Have you written and run sufficient tests for the changes? -- [ ] **Code Review:** Have you self-reviewed your code and addressed any coding standard issues? +- [ ] **Testing:** Have you written and run sufficient tests for validating the changes? +- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? +- [ ] **Label:** To cleary categorize this pull request, assign a relevant label to the pull request title, using one of the following: + - **BREAKING CHANGE**: Significant changes that may affect compatibility + - **build**: Changes that affect the build system or external dependencies + - **ci**: Changes to our continuous integration processes or workflows + - **chore**: Refactor, cleanup, or other non-functional code changes + - **docs**: Documentation update or addition + - **feat**: Introduces a new feature or enhancement to the codebase + - **fix**: Bug fix or error correction + - **i18n**: Internationalization or localization changes + - **perf**: Performance improvement + - **refactor**: Code restructuring for better maintainability, readability, or scalability + - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.) + - **test**: Adding missing tests or correcting existing tests + - **WIP**: Work in progress, a temporary label for incomplete or ongoing work ---- +# Changelog Entry -## Description +### Description -[Insert a brief description of the changes made in this pull request, including any relevant motivation and impact.] - ---- - -### Changelog Entry +- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)] ### Added - [List any new features, functionalities, or additions] -### Fixed - -- [List any fixes, corrections, or bug fixes] - ### Changed - [List any changes, updates, refactorings, or optimizations] +### Deprecated + +- [List any deprecated functionality or features that have been removed] + ### Removed -- [List any removed features, files, or deprecated functionalities] +- [List any removed features, files, or functionalities] + +### Fixed + +- [List any fixes, corrections, or bug fixes] ### Security @@ -40,12 +58,15 @@ ### Breaking Changes -- [List any breaking changes affecting compatibility or functionality] +- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality] --- ### Additional Information - [Insert any additional context, notes, or explanations for the changes] + - [Reference any related issues, commits, or other relevant information] -- [Reference any related issues, commits, or other relevant information] +### Screenshots or Videos + +- [Attach any relevant screenshots or videos demonstrating the changes] diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index eec1305e4a..6f89f14a96 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -37,3 +37,21 @@ jobs: - name: Build Frontend run: npm run build + + test-frontend: + name: 'Frontend Unit Tests' + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Dependencies + run: npm ci + + - name: Run vitest + run: npm run test:frontend diff --git a/.gitignore b/.gitignore index 55209604ac..a54fef5954 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ __pycache__/ # C extensions *.so +# Pyodide distribution +static/pyodide/* +!static/pyodide/pyodide-lock.json + # Distribution / packaging .Python build/ diff --git a/.prettierignore b/.prettierignore index b722147c43..82c4912572 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,11 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock + +kubernetes/ + +# Copy of .gitignore .DS_Store node_modules /build @@ -6,11 +14,303 @@ node_modules .env .env.* !.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock +# C extensions +*.so -# Ignore kubernetes files -kubernetes \ No newline at end of file +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# cypress artifacts +cypress/videos +cypress/screenshots + + + +/static/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2047cc3fb7..98656a309e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.125] - 2024-05-19 + +### Added + +- **🔄 Updated UI**: Chat interface revamped with chat bubbles. Easily switch back to the old style via settings > interface > chat bubble UI. +- **📂 Enhanced Sidebar UI**: Model files, documents, prompts, and playground merged into Workspace for streamlined access. +- **🚀 Improved Many Model Interaction**: All responses now displayed simultaneously for a smoother experience. +- **🐍 Python Code Execution**: Execute Python code locally in the browser with libraries like 'requests', 'beautifulsoup4', 'numpy', 'pandas', 'seaborn', 'matplotlib', 'scikit-learn', 'scipy', 'regex'. +- **🧠 Experimental Memory Feature**: Manually input personal information you want LLMs to remember via settings > personalization > memory. +- **💾 Persistent Settings**: Settings now saved as config.json for convenience. +- **🩺 Health Check Endpoint**: Added for Docker deployment. +- **↕️ RTL Support**: Toggle chat direction via settings > interface > chat direction. +- **🖥️ PowerPoint Support**: RAG pipeline now supports PowerPoint documents. +- **🌐 Language Updates**: Ukrainian, Turkish, Arabic, Chinese, Serbian, Vietnamese updated; Punjabi added. + +### Changed + +- **👤 Shared Chat Update**: Shared chat now includes creator user information. + ## [0.1.124] - 2024-05-08 ### Added diff --git a/Dockerfile b/Dockerfile index faee1ac321..dee049fb4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ ARG USE_CUDA_VER=cu121 # IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 ARG USE_RERANKING_MODEL="" +# Override at your own risk - non-root configurations are untested +ARG UID=0 +ARG GID=0 ######## WebUI frontend ######## FROM --platform=$BUILDPLATFORM node:21-alpine3.19 as build @@ -32,6 +35,8 @@ ARG USE_OLLAMA ARG USE_CUDA_VER ARG USE_EMBEDDING_MODEL ARG USE_RERANKING_MODEL +ARG UID +ARG GID ## Basis ## ENV ENV=prod \ @@ -76,46 +81,57 @@ ENV HF_HOME="/app/backend/data/cache/embedding/models" WORKDIR /app/backend ENV HOME /root +# Create user and group if not root +RUN if [ $UID -ne 0 ]; then \ + if [ $GID -ne 0 ]; then \ + addgroup --gid $GID app; \ + fi; \ + adduser --uid $UID --gid $GID --home $HOME --disabled-password --no-create-home app; \ + fi + RUN mkdir -p $HOME/.cache/chroma RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id +# Make sure the user has access to the app and root directory +RUN chown -R $UID:$GID /app $HOME + RUN if [ "$USE_OLLAMA" = "true" ]; then \ - apt-get update && \ - # Install pandoc and netcat - apt-get install -y --no-install-recommends pandoc netcat-openbsd && \ - # for RAG OCR - apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ - # install helper tools - apt-get install -y --no-install-recommends curl && \ - # install ollama - curl -fsSL https://ollama.com/install.sh | sh && \ - # cleanup - rm -rf /var/lib/apt/lists/*; \ + apt-get update && \ + # Install pandoc and netcat + apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \ + # for RAG OCR + apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ + # install helper tools + apt-get install -y --no-install-recommends curl jq && \ + # install ollama + curl -fsSL https://ollama.com/install.sh | sh && \ + # cleanup + rm -rf /var/lib/apt/lists/*; \ else \ - apt-get update && \ - # Install pandoc and netcat - apt-get install -y --no-install-recommends pandoc netcat-openbsd && \ - # for RAG OCR - apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ - # cleanup - rm -rf /var/lib/apt/lists/*; \ + apt-get update && \ + # Install pandoc and netcat + apt-get install -y --no-install-recommends pandoc netcat-openbsd curl jq && \ + # for RAG OCR + apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ + # cleanup + rm -rf /var/lib/apt/lists/*; \ fi # install python dependencies -COPY ./backend/requirements.txt ./requirements.txt +COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt RUN pip3 install uv && \ if [ "$USE_CUDA" = "true" ]; then \ - # If you use CUDA the whisper and embedding model will be downloaded on first use - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ - uv pip install --system -r requirements.txt --no-cache-dir && \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ - python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + # If you use CUDA the whisper and embedding model will be downloaded on first use + pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ + uv pip install --system -r requirements.txt --no-cache-dir && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ else \ - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ - uv pip install --system -r requirements.txt --no-cache-dir && \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ - python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ + uv pip install --system -r requirements.txt --no-cache-dir && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ fi @@ -125,13 +141,17 @@ RUN pip3 install uv && \ # COPY --from=build /app/onnx /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx # copy built frontend files -COPY --from=build /app/build /app/build -COPY --from=build /app/CHANGELOG.md /app/CHANGELOG.md -COPY --from=build /app/package.json /app/package.json +COPY --chown=$UID:$GID --from=build /app/build /app/build +COPY --chown=$UID:$GID --from=build /app/CHANGELOG.md /app/CHANGELOG.md +COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json # copy backend files -COPY ./backend . +COPY --chown=$UID:$GID ./backend . EXPOSE 8080 +HEALTHCHECK CMD curl --silent --fail http://localhost:8080/health | jq -e '.status == true' || exit 1 + +USER $UID:$GID + CMD [ "bash", "start.sh"] diff --git a/backend/apps/audio/main.py b/backend/apps/audio/main.py index 87732d7bc6..0f65a551e2 100644 --- a/backend/apps/audio/main.py +++ b/backend/apps/audio/main.py @@ -45,6 +45,7 @@ from config import ( AUDIO_OPENAI_API_KEY, AUDIO_OPENAI_API_MODEL, AUDIO_OPENAI_API_VOICE, + AppConfig, ) log = logging.getLogger(__name__) @@ -59,11 +60,11 @@ app.add_middleware( allow_headers=["*"], ) - -app.state.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL -app.state.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY -app.state.OPENAI_API_MODEL = AUDIO_OPENAI_API_MODEL -app.state.OPENAI_API_VOICE = AUDIO_OPENAI_API_VOICE +app.state.config = AppConfig() +app.state.config.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL +app.state.config.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY +app.state.config.OPENAI_API_MODEL = AUDIO_OPENAI_API_MODEL +app.state.config.OPENAI_API_VOICE = AUDIO_OPENAI_API_VOICE # setting device type for whisper model whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu" @@ -83,10 +84,10 @@ class OpenAIConfigUpdateForm(BaseModel): @app.get("/config") async def get_openai_config(user=Depends(get_admin_user)): return { - "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.OPENAI_API_KEY, - "OPENAI_API_MODEL": app.state.OPENAI_API_MODEL, - "OPENAI_API_VOICE": app.state.OPENAI_API_VOICE, + "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, + "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL, + "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE, } @@ -97,17 +98,17 @@ async def update_openai_config( if form_data.key == "": raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) - app.state.OPENAI_API_BASE_URL = form_data.url - app.state.OPENAI_API_KEY = form_data.key - app.state.OPENAI_API_MODEL = form_data.model - app.state.OPENAI_API_VOICE = form_data.speaker + app.state.config.OPENAI_API_BASE_URL = form_data.url + app.state.config.OPENAI_API_KEY = form_data.key + app.state.config.OPENAI_API_MODEL = form_data.model + app.state.config.OPENAI_API_VOICE = form_data.speaker return { "status": True, - "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.OPENAI_API_KEY, - "OPENAI_API_MODEL": app.state.OPENAI_API_MODEL, - "OPENAI_API_VOICE": app.state.OPENAI_API_VOICE, + "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, + "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL, + "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE, } @@ -124,13 +125,13 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) headers = {} - headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}" + headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEY}" headers["Content-Type"] = "application/json" r = None try: r = requests.post( - url=f"{app.state.OPENAI_API_BASE_URL}/audio/speech", + url=f"{app.state.config.OPENAI_API_BASE_URL}/audio/speech", data=body, headers=headers, stream=True, diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index f45cf0d12e..4419ccf199 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -42,6 +42,7 @@ from config import ( IMAGE_GENERATION_MODEL, IMAGE_SIZE, IMAGE_STEPS, + AppConfig, ) @@ -60,26 +61,31 @@ app.add_middleware( allow_headers=["*"], ) -app.state.ENGINE = IMAGE_GENERATION_ENGINE -app.state.ENABLED = ENABLE_IMAGE_GENERATION +app.state.config = AppConfig() -app.state.OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL -app.state.OPENAI_API_KEY = IMAGES_OPENAI_API_KEY +app.state.config.ENGINE = IMAGE_GENERATION_ENGINE +app.state.config.ENABLED = ENABLE_IMAGE_GENERATION -app.state.MODEL = IMAGE_GENERATION_MODEL +app.state.config.OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL +app.state.config.OPENAI_API_KEY = IMAGES_OPENAI_API_KEY + +app.state.config.MODEL = IMAGE_GENERATION_MODEL -app.state.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL -app.state.COMFYUI_BASE_URL = COMFYUI_BASE_URL +app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL +app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL -app.state.IMAGE_SIZE = IMAGE_SIZE -app.state.IMAGE_STEPS = IMAGE_STEPS +app.state.config.IMAGE_SIZE = IMAGE_SIZE +app.state.config.IMAGE_STEPS = IMAGE_STEPS @app.get("/config") async def get_config(request: Request, user=Depends(get_admin_user)): - return {"engine": app.state.ENGINE, "enabled": app.state.ENABLED} + return { + "engine": app.state.config.ENGINE, + "enabled": app.state.config.ENABLED, + } class ConfigUpdateForm(BaseModel): @@ -89,9 +95,12 @@ class ConfigUpdateForm(BaseModel): @app.post("/config/update") async def update_config(form_data: ConfigUpdateForm, user=Depends(get_admin_user)): - app.state.ENGINE = form_data.engine - app.state.ENABLED = form_data.enabled - return {"engine": app.state.ENGINE, "enabled": app.state.ENABLED} + app.state.config.ENGINE = form_data.engine + app.state.config.ENABLED = form_data.enabled + return { + "engine": app.state.config.ENGINE, + "enabled": app.state.config.ENABLED, + } class EngineUrlUpdateForm(BaseModel): @@ -102,8 +111,8 @@ class EngineUrlUpdateForm(BaseModel): @app.get("/url") async def get_engine_url(user=Depends(get_admin_user)): return { - "AUTOMATIC1111_BASE_URL": app.state.AUTOMATIC1111_BASE_URL, - "COMFYUI_BASE_URL": app.state.COMFYUI_BASE_URL, + "AUTOMATIC1111_BASE_URL": app.state.config.AUTOMATIC1111_BASE_URL, + "COMFYUI_BASE_URL": app.state.config.COMFYUI_BASE_URL, } @@ -113,29 +122,29 @@ async def update_engine_url( ): if form_data.AUTOMATIC1111_BASE_URL == None: - app.state.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL + app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL else: url = form_data.AUTOMATIC1111_BASE_URL.strip("/") try: r = requests.head(url) - app.state.AUTOMATIC1111_BASE_URL = url + app.state.config.AUTOMATIC1111_BASE_URL = url except Exception as e: raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) if form_data.COMFYUI_BASE_URL == None: - app.state.COMFYUI_BASE_URL = COMFYUI_BASE_URL + app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL else: url = form_data.COMFYUI_BASE_URL.strip("/") try: r = requests.head(url) - app.state.COMFYUI_BASE_URL = url + app.state.config.COMFYUI_BASE_URL = url except Exception as e: raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) return { - "AUTOMATIC1111_BASE_URL": app.state.AUTOMATIC1111_BASE_URL, - "COMFYUI_BASE_URL": app.state.COMFYUI_BASE_URL, + "AUTOMATIC1111_BASE_URL": app.state.config.AUTOMATIC1111_BASE_URL, + "COMFYUI_BASE_URL": app.state.config.COMFYUI_BASE_URL, "status": True, } @@ -148,8 +157,8 @@ class OpenAIConfigUpdateForm(BaseModel): @app.get("/openai/config") async def get_openai_config(user=Depends(get_admin_user)): return { - "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, } @@ -160,13 +169,13 @@ async def update_openai_config( if form_data.key == "": raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) - app.state.OPENAI_API_BASE_URL = form_data.url - app.state.OPENAI_API_KEY = form_data.key + app.state.config.OPENAI_API_BASE_URL = form_data.url + app.state.config.OPENAI_API_KEY = form_data.key return { "status": True, - "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, } @@ -176,7 +185,7 @@ class ImageSizeUpdateForm(BaseModel): @app.get("/size") async def get_image_size(user=Depends(get_admin_user)): - return {"IMAGE_SIZE": app.state.IMAGE_SIZE} + return {"IMAGE_SIZE": app.state.config.IMAGE_SIZE} @app.post("/size/update") @@ -185,9 +194,9 @@ async def update_image_size( ): pattern = r"^\d+x\d+$" # Regular expression pattern if re.match(pattern, form_data.size): - app.state.IMAGE_SIZE = form_data.size + app.state.config.IMAGE_SIZE = form_data.size return { - "IMAGE_SIZE": app.state.IMAGE_SIZE, + "IMAGE_SIZE": app.state.config.IMAGE_SIZE, "status": True, } else: @@ -203,7 +212,7 @@ class ImageStepsUpdateForm(BaseModel): @app.get("/steps") async def get_image_size(user=Depends(get_admin_user)): - return {"IMAGE_STEPS": app.state.IMAGE_STEPS} + return {"IMAGE_STEPS": app.state.config.IMAGE_STEPS} @app.post("/steps/update") @@ -211,9 +220,9 @@ async def update_image_size( form_data: ImageStepsUpdateForm, user=Depends(get_admin_user) ): if form_data.steps >= 0: - app.state.IMAGE_STEPS = form_data.steps + app.state.config.IMAGE_STEPS = form_data.steps return { - "IMAGE_STEPS": app.state.IMAGE_STEPS, + "IMAGE_STEPS": app.state.config.IMAGE_STEPS, "status": True, } else: @@ -226,14 +235,14 @@ async def update_image_size( @app.get("/models") def get_models(user=Depends(get_current_user)): try: - if app.state.ENGINE == "openai": + if app.state.config.ENGINE == "openai": return [ {"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-3", "name": "DALL·E 3"}, ] - elif app.state.ENGINE == "comfyui": + elif app.state.config.ENGINE == "comfyui": - r = requests.get(url=f"{app.state.COMFYUI_BASE_URL}/object_info") + r = requests.get(url=f"{app.state.config.COMFYUI_BASE_URL}/object_info") info = r.json() return list( @@ -245,7 +254,7 @@ def get_models(user=Depends(get_current_user)): else: r = requests.get( - url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models" + url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models" ) models = r.json() return list( @@ -255,23 +264,29 @@ def get_models(user=Depends(get_current_user)): ) ) except Exception as e: - app.state.ENABLED = False + app.state.config.ENABLED = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @app.get("/models/default") async def get_default_model(user=Depends(get_admin_user)): try: - if app.state.ENGINE == "openai": - return {"model": app.state.MODEL if app.state.MODEL else "dall-e-2"} - elif app.state.ENGINE == "comfyui": - return {"model": app.state.MODEL if app.state.MODEL else ""} + if app.state.config.ENGINE == "openai": + return { + "model": ( + app.state.config.MODEL if app.state.config.MODEL else "dall-e-2" + ) + } + elif app.state.config.ENGINE == "comfyui": + return {"model": (app.state.config.MODEL if app.state.config.MODEL else "")} else: - r = requests.get(url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/options") + r = requests.get( + url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options" + ) options = r.json() return {"model": options["sd_model_checkpoint"]} except Exception as e: - app.state.ENABLED = False + app.state.config.ENABLED = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -280,20 +295,20 @@ class UpdateModelForm(BaseModel): def set_model_handler(model: str): - if app.state.ENGINE == "openai": - app.state.MODEL = model - return app.state.MODEL - if app.state.ENGINE == "comfyui": - app.state.MODEL = model - return app.state.MODEL + if app.state.config.ENGINE in ["openai", "comfyui"]: + app.state.config.MODEL = model + return app.state.config.MODEL else: - r = requests.get(url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/options") + r = requests.get( + url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options" + ) options = r.json() if model != options["sd_model_checkpoint"]: options["sd_model_checkpoint"] = model r = requests.post( - url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", json=options + url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", + json=options, ) return options @@ -382,26 +397,32 @@ def generate_image( user=Depends(get_current_user), ): - width, height = tuple(map(int, app.state.IMAGE_SIZE.split("x"))) + width, height = tuple(map(int, app.state.config.IMAGE_SIZE.split("x"))) r = None try: - if app.state.ENGINE == "openai": + if app.state.config.ENGINE == "openai": headers = {} - headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}" + headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEY}" headers["Content-Type"] = "application/json" data = { - "model": app.state.MODEL if app.state.MODEL != "" else "dall-e-2", + "model": ( + app.state.config.MODEL + if app.state.config.MODEL != "" + else "dall-e-2" + ), "prompt": form_data.prompt, "n": form_data.n, - "size": form_data.size if form_data.size else app.state.IMAGE_SIZE, + "size": ( + form_data.size if form_data.size else app.state.config.IMAGE_SIZE + ), "response_format": "b64_json", } r = requests.post( - url=f"{app.state.OPENAI_API_BASE_URL}/images/generations", + url=f"{app.state.config.OPENAI_API_BASE_URL}/images/generations", json=data, headers=headers, ) @@ -421,7 +442,7 @@ def generate_image( return images - elif app.state.ENGINE == "comfyui": + elif app.state.config.ENGINE == "comfyui": data = { "prompt": form_data.prompt, @@ -430,19 +451,19 @@ def generate_image( "n": form_data.n, } - if app.state.IMAGE_STEPS != None: - data["steps"] = app.state.IMAGE_STEPS + if app.state.config.IMAGE_STEPS is not None: + data["steps"] = app.state.config.IMAGE_STEPS - if form_data.negative_prompt != None: + if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt data = ImageGenerationPayload(**data) res = comfyui_generate_image( - app.state.MODEL, + app.state.config.MODEL, data, user.id, - app.state.COMFYUI_BASE_URL, + app.state.config.COMFYUI_BASE_URL, ) log.debug(f"res: {res}") @@ -469,14 +490,14 @@ def generate_image( "height": height, } - if app.state.IMAGE_STEPS != None: - data["steps"] = app.state.IMAGE_STEPS + if app.state.config.IMAGE_STEPS is not None: + data["steps"] = app.state.config.IMAGE_STEPS - if form_data.negative_prompt != None: + if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt r = requests.post( - url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img", + url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img", json=data, ) diff --git a/backend/apps/litellm/main.py b/backend/apps/litellm/main.py index 95f4420672..6a355038b1 100644 --- a/backend/apps/litellm/main.py +++ b/backend/apps/litellm/main.py @@ -1,4 +1,5 @@ import sys +from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, HTTPException from fastapi.routing import APIRoute @@ -46,7 +47,16 @@ import asyncio import subprocess import yaml -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + log.info("startup_event") + # TODO: Check config.yaml file and create one + asyncio.create_task(start_litellm_background()) + yield + + +app = FastAPI(lifespan=lifespan) origins = ["*"] @@ -65,6 +75,10 @@ with open(LITELLM_CONFIG_DIR, "r") as file: litellm_config = yaml.safe_load(file) +app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER.value +app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST.value + + app.state.ENABLE = ENABLE_LITELLM app.state.CONFIG = litellm_config @@ -141,17 +155,6 @@ async def shutdown_litellm_background(): background_process = None -@app.on_event("startup") -async def startup_event(): - log.info("startup_event") - # TODO: Check config.yaml file and create one - asyncio.create_task(start_litellm_background()) - - -app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER -app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST - - @app.get("/") async def get_status(): return {"status": True} diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index 042d0336db..df268067fd 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -46,6 +46,7 @@ from config import ( ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, UPLOAD_DIR, + AppConfig, ) from utils.misc import calculate_sha256 @@ -61,11 +62,12 @@ app.add_middleware( allow_headers=["*"], ) +app.state.config = AppConfig() -app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER -app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST +app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER +app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST -app.state.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS +app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS app.state.MODELS = {} @@ -96,7 +98,7 @@ async def get_status(): @app.get("/urls") async def get_ollama_api_urls(user=Depends(get_admin_user)): - return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS} + return {"OLLAMA_BASE_URLS": app.state.config.OLLAMA_BASE_URLS} class UrlUpdateForm(BaseModel): @@ -105,10 +107,10 @@ class UrlUpdateForm(BaseModel): @app.post("/urls/update") async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): - app.state.OLLAMA_BASE_URLS = form_data.urls + app.state.config.OLLAMA_BASE_URLS = form_data.urls - log.info(f"app.state.OLLAMA_BASE_URLS: {app.state.OLLAMA_BASE_URLS}") - return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS} + log.info(f"app.state.config.OLLAMA_BASE_URLS: {app.state.config.OLLAMA_BASE_URLS}") + return {"OLLAMA_BASE_URLS": app.state.config.OLLAMA_BASE_URLS} @app.get("/cancel/{request_id}") @@ -122,8 +124,9 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user)) async def fetch_url(url): + timeout = aiohttp.ClientTimeout(total=5) try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url) as response: return await response.json() except Exception as e: @@ -153,7 +156,7 @@ def merge_models_lists(model_lists): async def get_all_models(): log.info("get_all_models()") - tasks = [fetch_url(f"{url}/api/tags") for url in app.state.OLLAMA_BASE_URLS] + tasks = [fetch_url(f"{url}/api/tags") for url in app.state.config.OLLAMA_BASE_URLS] responses = await asyncio.gather(*tasks) models = { @@ -175,18 +178,19 @@ async def get_ollama_tags( if url_idx == None: models = await get_all_models() - if app.state.ENABLE_MODEL_FILTER: + if app.state.config.ENABLE_MODEL_FILTER: if user.role == "user": models["models"] = list( filter( - lambda model: model["name"] in app.state.MODEL_FILTER_LIST, + lambda model: model["name"] + in app.state.config.MODEL_FILTER_LIST, models["models"], ) ) return models return models else: - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] try: r = requests.request(method="GET", url=f"{url}/api/tags") r.raise_for_status() @@ -216,7 +220,9 @@ async def get_ollama_versions(url_idx: Optional[int] = None): if url_idx == None: # returns lowest version - tasks = [fetch_url(f"{url}/api/version") for url in app.state.OLLAMA_BASE_URLS] + tasks = [ + fetch_url(f"{url}/api/version") for url in app.state.config.OLLAMA_BASE_URLS + ] responses = await asyncio.gather(*tasks) responses = list(filter(lambda x: x is not None, responses)) @@ -235,7 +241,7 @@ async def get_ollama_versions(url_idx: Optional[int] = None): detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND, ) else: - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] try: r = requests.request(method="GET", url=f"{url}/api/version") r.raise_for_status() @@ -267,7 +273,7 @@ class ModelNameForm(BaseModel): async def pull_model( form_data: ModelNameForm, url_idx: int = 0, user=Depends(get_admin_user) ): - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") r = None @@ -355,7 +361,7 @@ async def push_model( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.debug(f"url: {url}") r = None @@ -417,7 +423,7 @@ async def create_model( form_data: CreateModelForm, url_idx: int = 0, user=Depends(get_admin_user) ): log.debug(f"form_data: {form_data}") - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") r = None @@ -490,7 +496,7 @@ async def copy_model( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") try: @@ -537,7 +543,7 @@ async def delete_model( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") try: @@ -577,7 +583,7 @@ async def show_model_info(form_data: ModelNameForm, user=Depends(get_verified_us ) url_idx = random.choice(app.state.MODELS[form_data.name]["urls"]) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") try: @@ -634,7 +640,7 @@ async def generate_embeddings( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") try: @@ -684,7 +690,7 @@ def generate_ollama_embeddings( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") try: @@ -753,7 +759,7 @@ async def generate_completion( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") r = None @@ -856,7 +862,7 @@ async def generate_chat_completion( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") r = None @@ -965,7 +971,7 @@ async def generate_openai_chat_completion( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), ) - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") r = None @@ -1041,11 +1047,12 @@ async def get_openai_models( if url_idx == None: models = await get_all_models() - if app.state.ENABLE_MODEL_FILTER: + if app.state.config.ENABLE_MODEL_FILTER: if user.role == "user": models["models"] = list( filter( - lambda model: model["name"] in app.state.MODEL_FILTER_LIST, + lambda model: model["name"] + in app.state.config.MODEL_FILTER_LIST, models["models"], ) ) @@ -1064,7 +1071,7 @@ async def get_openai_models( } else: - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] try: r = requests.request(method="GET", url=f"{url}/api/tags") r.raise_for_status() @@ -1198,7 +1205,7 @@ async def download_model( if url_idx == None: url_idx = 0 - url = app.state.OLLAMA_BASE_URLS[url_idx] + url = app.state.config.OLLAMA_BASE_URLS[url_idx] file_name = parse_huggingface_url(form_data.url) @@ -1217,7 +1224,7 @@ async def download_model( def upload_model(file: UploadFile = File(...), url_idx: Optional[int] = None): if url_idx == None: url_idx = 0 - ollama_url = app.state.OLLAMA_BASE_URLS[url_idx] + ollama_url = app.state.config.OLLAMA_BASE_URLS[url_idx] file_path = f"{UPLOAD_DIR}/{file.filename}" @@ -1282,7 +1289,7 @@ def upload_model(file: UploadFile = File(...), url_idx: Optional[int] = None): # async def upload_model(file: UploadFile = File(), url_idx: Optional[int] = None): # if url_idx == None: # url_idx = 0 -# url = app.state.OLLAMA_BASE_URLS[url_idx] +# url = app.state.config.OLLAMA_BASE_URLS[url_idx] # file_location = os.path.join(UPLOAD_DIR, file.filename) # total_size = file.size @@ -1319,7 +1326,7 @@ def upload_model(file: UploadFile = File(...), url_idx: Optional[int] = None): async def deprecated_proxy( path: str, request: Request, user=Depends(get_verified_user) ): - url = app.state.OLLAMA_BASE_URLS[0] + url = app.state.config.OLLAMA_BASE_URLS[0] target_url = f"{url}/{path}" body = await request.body() diff --git a/backend/apps/openai/main.py b/backend/apps/openai/main.py index b5d1e68d68..85ee531f15 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -21,11 +21,13 @@ from utils.utils import ( ) from config import ( SRC_LOG_LEVELS, + ENABLE_OPENAI_API, OPENAI_API_BASE_URLS, OPENAI_API_KEYS, CACHE_DIR, ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, + AppConfig, ) from typing import List, Optional @@ -45,11 +47,16 @@ app.add_middleware( allow_headers=["*"], ) -app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER -app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST -app.state.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS -app.state.OPENAI_API_KEYS = OPENAI_API_KEYS +app.state.config = AppConfig() + +app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER +app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST + + +app.state.config.ENABLE_OPENAI_API = ENABLE_OPENAI_API +app.state.config.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS +app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS app.state.MODELS = {} @@ -65,6 +72,21 @@ async def check_url(request: Request, call_next): return response +@app.get("/config") +async def get_config(user=Depends(get_admin_user)): + return {"ENABLE_OPENAI_API": app.state.config.ENABLE_OPENAI_API} + + +class OpenAIConfigForm(BaseModel): + enable_openai_api: Optional[bool] = None + + +@app.post("/config/update") +async def update_config(form_data: OpenAIConfigForm, user=Depends(get_admin_user)): + app.state.config.ENABLE_OPENAI_API = form_data.enable_openai_api + return {"ENABLE_OPENAI_API": app.state.config.ENABLE_OPENAI_API} + + class UrlsUpdateForm(BaseModel): urls: List[str] @@ -75,32 +97,32 @@ class KeysUpdateForm(BaseModel): @app.get("/urls") async def get_openai_urls(user=Depends(get_admin_user)): - return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS} + return {"OPENAI_API_BASE_URLS": app.state.config.OPENAI_API_BASE_URLS} @app.post("/urls/update") async def update_openai_urls(form_data: UrlsUpdateForm, user=Depends(get_admin_user)): await get_all_models() - app.state.OPENAI_API_BASE_URLS = form_data.urls - return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS} + app.state.config.OPENAI_API_BASE_URLS = form_data.urls + return {"OPENAI_API_BASE_URLS": app.state.config.OPENAI_API_BASE_URLS} @app.get("/keys") async def get_openai_keys(user=Depends(get_admin_user)): - return {"OPENAI_API_KEYS": app.state.OPENAI_API_KEYS} + return {"OPENAI_API_KEYS": app.state.config.OPENAI_API_KEYS} @app.post("/keys/update") async def update_openai_key(form_data: KeysUpdateForm, user=Depends(get_admin_user)): - app.state.OPENAI_API_KEYS = form_data.keys - return {"OPENAI_API_KEYS": app.state.OPENAI_API_KEYS} + app.state.config.OPENAI_API_KEYS = form_data.keys + return {"OPENAI_API_KEYS": app.state.config.OPENAI_API_KEYS} @app.post("/audio/speech") async def speech(request: Request, user=Depends(get_verified_user)): idx = None try: - idx = app.state.OPENAI_API_BASE_URLS.index("https://api.openai.com/v1") + idx = app.state.config.OPENAI_API_BASE_URLS.index("https://api.openai.com/v1") body = await request.body() name = hashlib.sha256(body).hexdigest() @@ -114,13 +136,15 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) headers = {} - headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEYS[idx]}" + headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEYS[idx]}" headers["Content-Type"] = "application/json" - + if "openrouter.ai" in app.state.config.OPENAI_API_BASE_URLS[idx]: + headers["HTTP-Referer"] = "https://openwebui.com/" + headers["X-Title"] = "Open WebUI" r = None try: r = requests.post( - url=f"{app.state.OPENAI_API_BASE_URLS[idx]}/audio/speech", + url=f"{app.state.config.OPENAI_API_BASE_URLS[idx]}/audio/speech", data=body, headers=headers, stream=True, @@ -159,11 +183,15 @@ async def speech(request: Request, user=Depends(get_verified_user)): async def fetch_url(url, key): + timeout = aiohttp.ClientTimeout(total=5) try: - headers = {"Authorization": f"Bearer {key}"} - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers) as response: - return await response.json() + if key != "": + headers = {"Authorization": f"Bearer {key}"} + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=headers) as response: + return await response.json() + else: + return None except Exception as e: # Handle connection error here log.error(f"Connection error: {e}") @@ -180,7 +208,8 @@ def merge_models_lists(model_lists): [ {**model, "urlIdx": idx} for model in models - if "api.openai.com" not in app.state.OPENAI_API_BASE_URLS[idx] + if "api.openai.com" + not in app.state.config.OPENAI_API_BASE_URLS[idx] or "gpt" in model["id"] ] ) @@ -191,12 +220,15 @@ def merge_models_lists(model_lists): async def get_all_models(): log.info("get_all_models()") - if len(app.state.OPENAI_API_KEYS) == 1 and app.state.OPENAI_API_KEYS[0] == "": + if ( + len(app.state.config.OPENAI_API_KEYS) == 1 + and app.state.config.OPENAI_API_KEYS[0] == "" + ) or not app.state.config.ENABLE_OPENAI_API: models = {"data": []} else: tasks = [ - fetch_url(f"{url}/models", app.state.OPENAI_API_KEYS[idx]) - for idx, url in enumerate(app.state.OPENAI_API_BASE_URLS) + fetch_url(f"{url}/models", app.state.config.OPENAI_API_KEYS[idx]) + for idx, url in enumerate(app.state.config.OPENAI_API_BASE_URLS) ] responses = await asyncio.gather(*tasks) @@ -228,18 +260,18 @@ async def get_all_models(): async def get_models(url_idx: Optional[int] = None, user=Depends(get_current_user)): if url_idx == None: models = await get_all_models() - if app.state.ENABLE_MODEL_FILTER: + if app.state.config.ENABLE_MODEL_FILTER: if user.role == "user": models["data"] = list( filter( - lambda model: model["id"] in app.state.MODEL_FILTER_LIST, + lambda model: model["id"] in app.state.config.MODEL_FILTER_LIST, models["data"], ) ) return models return models else: - url = app.state.OPENAI_API_BASE_URLS[url_idx] + url = app.state.config.OPENAI_API_BASE_URLS[url_idx] r = None @@ -303,8 +335,8 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): except json.JSONDecodeError as e: log.error("Error loading request body into a dictionary:", e) - url = app.state.OPENAI_API_BASE_URLS[idx] - key = app.state.OPENAI_API_KEYS[idx] + url = app.state.config.OPENAI_API_BASE_URLS[idx] + key = app.state.config.OPENAI_API_KEYS[idx] target_url = f"{url}/{path}" diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index 2e2a8e209e..9a1a0c13e5 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -69,6 +69,7 @@ from utils.misc import ( from utils.utils import get_current_user, get_admin_user from config import ( + ENV, SRC_LOG_LEVELS, UPLOAD_DIR, DOCS_DIR, @@ -93,6 +94,7 @@ from config import ( RAG_TEMPLATE, ENABLE_RAG_LOCAL_WEB_FETCH, YOUTUBE_LOADER_LANGUAGE, + AppConfig, ) from constants import ERROR_MESSAGES @@ -102,30 +104,32 @@ log.setLevel(SRC_LOG_LEVELS["RAG"]) app = FastAPI() -app.state.TOP_K = RAG_TOP_K -app.state.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD +app.state.config = AppConfig() -app.state.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH -app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( +app.state.config.TOP_K = RAG_TOP_K +app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD + +app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH +app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION ) -app.state.CHUNK_SIZE = CHUNK_SIZE -app.state.CHUNK_OVERLAP = CHUNK_OVERLAP +app.state.config.CHUNK_SIZE = CHUNK_SIZE +app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP -app.state.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE -app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL -app.state.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL -app.state.RAG_TEMPLATE = RAG_TEMPLATE +app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE +app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL +app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL +app.state.config.RAG_TEMPLATE = RAG_TEMPLATE -app.state.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL -app.state.OPENAI_API_KEY = RAG_OPENAI_API_KEY +app.state.config.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL +app.state.config.OPENAI_API_KEY = RAG_OPENAI_API_KEY -app.state.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES +app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES -app.state.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE +app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE app.state.YOUTUBE_LOADER_TRANSLATION = None @@ -133,7 +137,7 @@ def update_embedding_model( embedding_model: str, update_model: bool = False, ): - if embedding_model and app.state.RAG_EMBEDDING_ENGINE == "": + if embedding_model and app.state.config.RAG_EMBEDDING_ENGINE == "": app.state.sentence_transformer_ef = sentence_transformers.SentenceTransformer( get_model_path(embedding_model, update_model), device=DEVICE_TYPE, @@ -158,22 +162,22 @@ def update_reranking_model( update_embedding_model( - app.state.RAG_EMBEDDING_MODEL, + app.state.config.RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, ) update_reranking_model( - app.state.RAG_RERANKING_MODEL, + app.state.config.RAG_RERANKING_MODEL, RAG_RERANKING_MODEL_AUTO_UPDATE, ) app.state.EMBEDDING_FUNCTION = get_embedding_function( - app.state.RAG_EMBEDDING_ENGINE, - app.state.RAG_EMBEDDING_MODEL, + app.state.config.RAG_EMBEDDING_ENGINE, + app.state.config.RAG_EMBEDDING_MODEL, app.state.sentence_transformer_ef, - app.state.OPENAI_API_KEY, - app.state.OPENAI_API_BASE_URL, + app.state.config.OPENAI_API_KEY, + app.state.config.OPENAI_API_BASE_URL, ) origins = ["*"] @@ -200,12 +204,12 @@ class UrlForm(CollectionNameForm): async def get_status(): return { "status": True, - "chunk_size": app.state.CHUNK_SIZE, - "chunk_overlap": app.state.CHUNK_OVERLAP, - "template": app.state.RAG_TEMPLATE, - "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, - "embedding_model": app.state.RAG_EMBEDDING_MODEL, - "reranking_model": app.state.RAG_RERANKING_MODEL, + "chunk_size": app.state.config.CHUNK_SIZE, + "chunk_overlap": app.state.config.CHUNK_OVERLAP, + "template": app.state.config.RAG_TEMPLATE, + "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE, + "embedding_model": app.state.config.RAG_EMBEDDING_MODEL, + "reranking_model": app.state.config.RAG_RERANKING_MODEL, } @@ -213,18 +217,21 @@ async def get_status(): async def get_embedding_config(user=Depends(get_admin_user)): return { "status": True, - "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, - "embedding_model": app.state.RAG_EMBEDDING_MODEL, + "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE, + "embedding_model": app.state.config.RAG_EMBEDDING_MODEL, "openai_config": { - "url": app.state.OPENAI_API_BASE_URL, - "key": app.state.OPENAI_API_KEY, + "url": app.state.config.OPENAI_API_BASE_URL, + "key": app.state.config.OPENAI_API_KEY, }, } @app.get("/reranking") async def get_reraanking_config(user=Depends(get_admin_user)): - return {"status": True, "reranking_model": app.state.RAG_RERANKING_MODEL} + return { + "status": True, + "reranking_model": app.state.config.RAG_RERANKING_MODEL, + } class OpenAIConfigForm(BaseModel): @@ -243,34 +250,34 @@ async def update_embedding_config( form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user) ): log.info( - f"Updating embedding model: {app.state.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}" + f"Updating embedding model: {app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}" ) try: - app.state.RAG_EMBEDDING_ENGINE = form_data.embedding_engine - app.state.RAG_EMBEDDING_MODEL = form_data.embedding_model + app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine + app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model - if app.state.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]: + if app.state.config.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]: if form_data.openai_config != None: - app.state.OPENAI_API_BASE_URL = form_data.openai_config.url - app.state.OPENAI_API_KEY = form_data.openai_config.key + app.state.config.OPENAI_API_BASE_URL = form_data.openai_config.url + app.state.config.OPENAI_API_KEY = form_data.openai_config.key - update_embedding_model(app.state.RAG_EMBEDDING_MODEL, True) + update_embedding_model(app.state.config.RAG_EMBEDDING_MODEL) app.state.EMBEDDING_FUNCTION = get_embedding_function( - app.state.RAG_EMBEDDING_ENGINE, - app.state.RAG_EMBEDDING_MODEL, + app.state.config.RAG_EMBEDDING_ENGINE, + app.state.config.RAG_EMBEDDING_MODEL, app.state.sentence_transformer_ef, - app.state.OPENAI_API_KEY, - app.state.OPENAI_API_BASE_URL, + app.state.config.OPENAI_API_KEY, + app.state.config.OPENAI_API_BASE_URL, ) return { "status": True, - "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, - "embedding_model": app.state.RAG_EMBEDDING_MODEL, + "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE, + "embedding_model": app.state.config.RAG_EMBEDDING_MODEL, "openai_config": { - "url": app.state.OPENAI_API_BASE_URL, - "key": app.state.OPENAI_API_KEY, + "url": app.state.config.OPENAI_API_BASE_URL, + "key": app.state.config.OPENAI_API_KEY, }, } except Exception as e: @@ -290,16 +297,16 @@ async def update_reranking_config( form_data: RerankingModelUpdateForm, user=Depends(get_admin_user) ): log.info( - f"Updating reranking model: {app.state.RAG_RERANKING_MODEL} to {form_data.reranking_model}" + f"Updating reranking model: {app.state.config.RAG_RERANKING_MODEL} to {form_data.reranking_model}" ) try: - app.state.RAG_RERANKING_MODEL = form_data.reranking_model + app.state.config.RAG_RERANKING_MODEL = form_data.reranking_model - update_reranking_model(app.state.RAG_RERANKING_MODEL, True) + update_reranking_model(app.state.config.RAG_RERANKING_MODEL), True return { "status": True, - "reranking_model": app.state.RAG_RERANKING_MODEL, + "reranking_model": app.state.config.RAG_RERANKING_MODEL, } except Exception as e: log.exception(f"Problem updating reranking model: {e}") @@ -313,14 +320,14 @@ async def update_reranking_config( async def get_rag_config(user=Depends(get_admin_user)): return { "status": True, - "pdf_extract_images": app.state.PDF_EXTRACT_IMAGES, + "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES, "chunk": { - "chunk_size": app.state.CHUNK_SIZE, - "chunk_overlap": app.state.CHUNK_OVERLAP, + "chunk_size": app.state.config.CHUNK_SIZE, + "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, - "web_loader_ssl_verification": app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, + "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "youtube": { - "language": app.state.YOUTUBE_LOADER_LANGUAGE, + "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, }, } @@ -345,50 +352,52 @@ class ConfigUpdateForm(BaseModel): @app.post("/config/update") async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_user)): - app.state.PDF_EXTRACT_IMAGES = ( + app.state.config.PDF_EXTRACT_IMAGES = ( form_data.pdf_extract_images - if form_data.pdf_extract_images != None - else app.state.PDF_EXTRACT_IMAGES + if form_data.pdf_extract_images is not None + else app.state.config.PDF_EXTRACT_IMAGES ) - app.state.CHUNK_SIZE = ( - form_data.chunk.chunk_size if form_data.chunk != None else app.state.CHUNK_SIZE + app.state.config.CHUNK_SIZE = ( + form_data.chunk.chunk_size + if form_data.chunk is not None + else app.state.config.CHUNK_SIZE ) - app.state.CHUNK_OVERLAP = ( + app.state.config.CHUNK_OVERLAP = ( form_data.chunk.chunk_overlap - if form_data.chunk != None - else app.state.CHUNK_OVERLAP + if form_data.chunk is not None + else app.state.config.CHUNK_OVERLAP ) - app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( + app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( form_data.web_loader_ssl_verification if form_data.web_loader_ssl_verification != None - else app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION + else app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION ) - app.state.YOUTUBE_LOADER_LANGUAGE = ( + app.state.config.YOUTUBE_LOADER_LANGUAGE = ( form_data.youtube.language - if form_data.youtube != None - else app.state.YOUTUBE_LOADER_LANGUAGE + if form_data.youtube is not None + else app.state.config.YOUTUBE_LOADER_LANGUAGE ) app.state.YOUTUBE_LOADER_TRANSLATION = ( form_data.youtube.translation - if form_data.youtube != None + if form_data.youtube is not None else app.state.YOUTUBE_LOADER_TRANSLATION ) return { "status": True, - "pdf_extract_images": app.state.PDF_EXTRACT_IMAGES, + "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES, "chunk": { - "chunk_size": app.state.CHUNK_SIZE, - "chunk_overlap": app.state.CHUNK_OVERLAP, + "chunk_size": app.state.config.CHUNK_SIZE, + "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, - "web_loader_ssl_verification": app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, + "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "youtube": { - "language": app.state.YOUTUBE_LOADER_LANGUAGE, + "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, }, } @@ -398,7 +407,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ async def get_rag_template(user=Depends(get_current_user)): return { "status": True, - "template": app.state.RAG_TEMPLATE, + "template": app.state.config.RAG_TEMPLATE, } @@ -406,10 +415,10 @@ async def get_rag_template(user=Depends(get_current_user)): async def get_query_settings(user=Depends(get_admin_user)): return { "status": True, - "template": app.state.RAG_TEMPLATE, - "k": app.state.TOP_K, - "r": app.state.RELEVANCE_THRESHOLD, - "hybrid": app.state.ENABLE_RAG_HYBRID_SEARCH, + "template": app.state.config.RAG_TEMPLATE, + "k": app.state.config.TOP_K, + "r": app.state.config.RELEVANCE_THRESHOLD, + "hybrid": app.state.config.ENABLE_RAG_HYBRID_SEARCH, } @@ -424,16 +433,20 @@ class QuerySettingsForm(BaseModel): async def update_query_settings( form_data: QuerySettingsForm, user=Depends(get_admin_user) ): - app.state.RAG_TEMPLATE = form_data.template if form_data.template else RAG_TEMPLATE - app.state.TOP_K = form_data.k if form_data.k else 4 - app.state.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 - app.state.ENABLE_RAG_HYBRID_SEARCH = form_data.hybrid if form_data.hybrid else False + app.state.config.RAG_TEMPLATE = ( + form_data.template if form_data.template else RAG_TEMPLATE + ) + app.state.config.TOP_K = form_data.k if form_data.k else 4 + app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 + app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( + form_data.hybrid if form_data.hybrid else False + ) return { "status": True, - "template": app.state.RAG_TEMPLATE, - "k": app.state.TOP_K, - "r": app.state.RELEVANCE_THRESHOLD, - "hybrid": app.state.ENABLE_RAG_HYBRID_SEARCH, + "template": app.state.config.RAG_TEMPLATE, + "k": app.state.config.TOP_K, + "r": app.state.config.RELEVANCE_THRESHOLD, + "hybrid": app.state.config.ENABLE_RAG_HYBRID_SEARCH, } @@ -451,21 +464,23 @@ def query_doc_handler( user=Depends(get_current_user), ): try: - if app.state.ENABLE_RAG_HYBRID_SEARCH: + if app.state.config.ENABLE_RAG_HYBRID_SEARCH: return query_doc_with_hybrid_search( collection_name=form_data.collection_name, query=form_data.query, embedding_function=app.state.EMBEDDING_FUNCTION, - k=form_data.k if form_data.k else app.state.TOP_K, + k=form_data.k if form_data.k else app.state.config.TOP_K, reranking_function=app.state.sentence_transformer_rf, - r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, + r=( + form_data.r if form_data.r else app.state.config.RELEVANCE_THRESHOLD + ), ) else: return query_doc( collection_name=form_data.collection_name, query=form_data.query, embedding_function=app.state.EMBEDDING_FUNCTION, - k=form_data.k if form_data.k else app.state.TOP_K, + k=form_data.k if form_data.k else app.state.config.TOP_K, ) except Exception as e: log.exception(e) @@ -489,21 +504,23 @@ def query_collection_handler( user=Depends(get_current_user), ): try: - if app.state.ENABLE_RAG_HYBRID_SEARCH: + if app.state.config.ENABLE_RAG_HYBRID_SEARCH: return query_collection_with_hybrid_search( collection_names=form_data.collection_names, query=form_data.query, embedding_function=app.state.EMBEDDING_FUNCTION, - k=form_data.k if form_data.k else app.state.TOP_K, + k=form_data.k if form_data.k else app.state.config.TOP_K, reranking_function=app.state.sentence_transformer_rf, - r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, + r=( + form_data.r if form_data.r else app.state.config.RELEVANCE_THRESHOLD + ), ) else: return query_collection( collection_names=form_data.collection_names, query=form_data.query, embedding_function=app.state.EMBEDDING_FUNCTION, - k=form_data.k if form_data.k else app.state.TOP_K, + k=form_data.k if form_data.k else app.state.config.TOP_K, ) except Exception as e: @@ -520,7 +537,7 @@ def store_youtube_video(form_data: UrlForm, user=Depends(get_current_user)): loader = YoutubeLoader.from_youtube_url( form_data.url, add_video_info=True, - language=app.state.YOUTUBE_LOADER_LANGUAGE, + language=app.state.config.YOUTUBE_LOADER_LANGUAGE, translation=app.state.YOUTUBE_LOADER_TRANSLATION, ) data = loader.load() @@ -548,7 +565,8 @@ def store_web(form_data: UrlForm, user=Depends(get_current_user)): # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm" try: loader = get_web_loader( - form_data.url, verify_ssl=app.state.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION + form_data.url, + verify_ssl=app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, ) data = loader.load() @@ -604,8 +622,8 @@ def resolve_hostname(hostname): def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool: text_splitter = RecursiveCharacterTextSplitter( - chunk_size=app.state.CHUNK_SIZE, - chunk_overlap=app.state.CHUNK_OVERLAP, + chunk_size=app.state.config.CHUNK_SIZE, + chunk_overlap=app.state.config.CHUNK_OVERLAP, add_start_index=True, ) @@ -622,8 +640,8 @@ def store_text_in_vector_db( text, metadata, collection_name, overwrite: bool = False ) -> bool: text_splitter = RecursiveCharacterTextSplitter( - chunk_size=app.state.CHUNK_SIZE, - chunk_overlap=app.state.CHUNK_OVERLAP, + chunk_size=app.state.config.CHUNK_SIZE, + chunk_overlap=app.state.config.CHUNK_OVERLAP, add_start_index=True, ) docs = text_splitter.create_documents([text], metadatas=[metadata]) @@ -646,11 +664,11 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b collection = CHROMA_CLIENT.create_collection(name=collection_name) embedding_func = get_embedding_function( - app.state.RAG_EMBEDDING_ENGINE, - app.state.RAG_EMBEDDING_MODEL, + app.state.config.RAG_EMBEDDING_ENGINE, + app.state.config.RAG_EMBEDDING_MODEL, app.state.sentence_transformer_ef, - app.state.OPENAI_API_KEY, - app.state.OPENAI_API_BASE_URL, + app.state.config.OPENAI_API_KEY, + app.state.config.OPENAI_API_BASE_URL, ) embedding_texts = list(map(lambda x: x.replace("\n", " "), texts)) @@ -724,7 +742,9 @@ def get_loader(filename: str, file_content_type: str, file_path: str): ] if file_ext == "pdf": - loader = PyPDFLoader(file_path, extract_images=app.state.PDF_EXTRACT_IMAGES) + loader = PyPDFLoader( + file_path, extract_images=app.state.config.PDF_EXTRACT_IMAGES + ) elif file_ext == "csv": loader = CSVLoader(file_path) elif file_ext == "rst": @@ -932,3 +952,14 @@ def reset(user=Depends(get_admin_user)) -> bool: log.exception(e) return True + + +if ENV == "dev": + + @app.get("/ef") + async def get_embeddings(): + return {"result": app.state.EMBEDDING_FUNCTION("hello world")} + + @app.get("/ef/{text}") + async def get_embeddings_text(text: str): + return {"result": app.state.EMBEDDING_FUNCTION(text)} diff --git a/backend/apps/web/internal/migrations/008_add_memory.py b/backend/apps/web/internal/migrations/008_add_memory.py new file mode 100644 index 0000000000..9307aa4d5c --- /dev/null +++ b/backend/apps/web/internal/migrations/008_add_memory.py @@ -0,0 +1,53 @@ +"""Peewee migrations -- 002_add_local_sharing.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + @migrator.create_model + class Memory(pw.Model): + id = pw.CharField(max_length=255, unique=True) + user_id = pw.CharField(max_length=255) + content = pw.TextField(null=False) + updated_at = pw.BigIntegerField(null=False) + created_at = pw.BigIntegerField(null=False) + + class Meta: + table_name = "memory" + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_model("memory") diff --git a/backend/apps/web/main.py b/backend/apps/web/main.py index 66cdfb3d4c..2b69663817 100644 --- a/backend/apps/web/main.py +++ b/backend/apps/web/main.py @@ -9,6 +9,7 @@ from apps.web.routers import ( modelfiles, prompts, configs, + memories, utils, ) from config import ( @@ -21,22 +22,27 @@ from config import ( USER_PERMISSIONS, WEBHOOK_URL, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, + JWT_EXPIRES_IN, + AppConfig, ) app = FastAPI() origins = ["*"] -app.state.ENABLE_SIGNUP = ENABLE_SIGNUP -app.state.JWT_EXPIRES_IN = "-1" +app.state.config = AppConfig() -app.state.DEFAULT_MODELS = DEFAULT_MODELS -app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS -app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE -app.state.USER_PERMISSIONS = USER_PERMISSIONS -app.state.WEBHOOK_URL = WEBHOOK_URL +app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP +app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN + +app.state.config.DEFAULT_MODELS = DEFAULT_MODELS +app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS +app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE +app.state.config.USER_PERMISSIONS = USER_PERMISSIONS +app.state.config.WEBHOOK_URL = WEBHOOK_URL app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER + app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -48,9 +54,12 @@ app.add_middleware( app.include_router(auths.router, prefix="/auths", tags=["auths"]) app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(chats.router, prefix="/chats", tags=["chats"]) + app.include_router(documents.router, prefix="/documents", tags=["documents"]) app.include_router(modelfiles.router, prefix="/modelfiles", tags=["modelfiles"]) app.include_router(prompts.router, prefix="/prompts", tags=["prompts"]) +app.include_router(memories.router, prefix="/memories", tags=["memories"]) + app.include_router(configs.router, prefix="/configs", tags=["configs"]) app.include_router(utils.router, prefix="/utils", tags=["utils"]) @@ -61,6 +70,6 @@ async def get_status(): return { "status": True, "auth": WEBUI_AUTH, - "default_models": app.state.DEFAULT_MODELS, - "default_prompt_suggestions": app.state.DEFAULT_PROMPT_SUGGESTIONS, + "default_models": app.state.config.DEFAULT_MODELS, + "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, } diff --git a/backend/apps/web/models/memories.py b/backend/apps/web/models/memories.py new file mode 100644 index 0000000000..8382b3e525 --- /dev/null +++ b/backend/apps/web/models/memories.py @@ -0,0 +1,118 @@ +from pydantic import BaseModel +from peewee import * +from playhouse.shortcuts import model_to_dict +from typing import List, Union, Optional + +from apps.web.internal.db import DB +from apps.web.models.chats import Chats + +import time +import uuid + +#################### +# Memory DB Schema +#################### + + +class Memory(Model): + id = CharField(unique=True) + user_id = CharField() + content = TextField() + updated_at = BigIntegerField() + created_at = BigIntegerField() + + class Meta: + database = DB + + +class MemoryModel(BaseModel): + id: str + user_id: str + content: str + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +#################### +# Forms +#################### + + +class MemoriesTable: + def __init__(self, db): + self.db = db + self.db.create_tables([Memory]) + + def insert_new_memory( + self, + user_id: str, + content: str, + ) -> Optional[MemoryModel]: + id = str(uuid.uuid4()) + + memory = MemoryModel( + **{ + "id": id, + "user_id": user_id, + "content": content, + "created_at": int(time.time()), + "updated_at": int(time.time()), + } + ) + result = Memory.create(**memory.model_dump()) + if result: + return memory + else: + return None + + def get_memories(self) -> List[MemoryModel]: + try: + memories = Memory.select() + return [MemoryModel(**model_to_dict(memory)) for memory in memories] + except: + return None + + def get_memories_by_user_id(self, user_id: str) -> List[MemoryModel]: + try: + memories = Memory.select().where(Memory.user_id == user_id) + return [MemoryModel(**model_to_dict(memory)) for memory in memories] + except: + return None + + def get_memory_by_id(self, id) -> Optional[MemoryModel]: + try: + memory = Memory.get(Memory.id == id) + return MemoryModel(**model_to_dict(memory)) + except: + return None + + def delete_memory_by_id(self, id: str) -> bool: + try: + query = Memory.delete().where(Memory.id == id) + query.execute() # Remove the rows, return number of rows removed. + + return True + + except: + return False + + def delete_memories_by_user_id(self, user_id: str) -> bool: + try: + query = Memory.delete().where(Memory.user_id == user_id) + query.execute() + + return True + except: + return False + + def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool: + try: + query = Memory.delete().where(Memory.id == id, Memory.user_id == user_id) + query.execute() + + return True + except: + return False + + +Memories = MemoriesTable(DB) diff --git a/backend/apps/web/routers/auths.py b/backend/apps/web/routers/auths.py index 9fa962dda2..998e746598 100644 --- a/backend/apps/web/routers/auths.py +++ b/backend/apps/web/routers/auths.py @@ -140,7 +140,7 @@ async def signin(request: Request, form_data: SigninForm): if user: token = create_token( data={"id": user.id}, - expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN), + expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), ) return { @@ -163,7 +163,7 @@ async def signin(request: Request, form_data: SigninForm): @router.post("/signup", response_model=SigninResponse) async def signup(request: Request, form_data: SignupForm): - if not request.app.state.ENABLE_SIGNUP and WEBUI_AUTH: + if not request.app.state.config.ENABLE_SIGNUP and WEBUI_AUTH: raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED ) @@ -180,7 +180,7 @@ async def signup(request: Request, form_data: SignupForm): role = ( "admin" if Users.get_num_users() == 0 - else request.app.state.DEFAULT_USER_ROLE + else request.app.state.config.DEFAULT_USER_ROLE ) hashed = get_password_hash(form_data.password) user = Auths.insert_new_auth( @@ -194,13 +194,13 @@ async def signup(request: Request, form_data: SignupForm): if user: token = create_token( data={"id": user.id}, - expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN), + expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), ) # response.set_cookie(key='token', value=token, httponly=True) - if request.app.state.WEBHOOK_URL: + if request.app.state.config.WEBHOOK_URL: post_webhook( - request.app.state.WEBHOOK_URL, + request.app.state.config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { "action": "signup", @@ -276,13 +276,13 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): @router.get("/signup/enabled", response_model=bool) async def get_sign_up_status(request: Request, user=Depends(get_admin_user)): - return request.app.state.ENABLE_SIGNUP + return request.app.state.config.ENABLE_SIGNUP @router.get("/signup/enabled/toggle", response_model=bool) async def toggle_sign_up(request: Request, user=Depends(get_admin_user)): - request.app.state.ENABLE_SIGNUP = not request.app.state.ENABLE_SIGNUP - return request.app.state.ENABLE_SIGNUP + request.app.state.config.ENABLE_SIGNUP = not request.app.state.config.ENABLE_SIGNUP + return request.app.state.config.ENABLE_SIGNUP ############################ @@ -292,7 +292,7 @@ async def toggle_sign_up(request: Request, user=Depends(get_admin_user)): @router.get("/signup/user/role") async def get_default_user_role(request: Request, user=Depends(get_admin_user)): - return request.app.state.DEFAULT_USER_ROLE + return request.app.state.config.DEFAULT_USER_ROLE class UpdateRoleForm(BaseModel): @@ -304,8 +304,8 @@ async def update_default_user_role( request: Request, form_data: UpdateRoleForm, user=Depends(get_admin_user) ): if form_data.role in ["pending", "user", "admin"]: - request.app.state.DEFAULT_USER_ROLE = form_data.role - return request.app.state.DEFAULT_USER_ROLE + request.app.state.config.DEFAULT_USER_ROLE = form_data.role + return request.app.state.config.DEFAULT_USER_ROLE ############################ @@ -315,7 +315,7 @@ async def update_default_user_role( @router.get("/token/expires") async def get_token_expires_duration(request: Request, user=Depends(get_admin_user)): - return request.app.state.JWT_EXPIRES_IN + return request.app.state.config.JWT_EXPIRES_IN class UpdateJWTExpiresDurationForm(BaseModel): @@ -332,10 +332,10 @@ async def update_token_expires_duration( # Check if the input string matches the pattern if re.match(pattern, form_data.duration): - request.app.state.JWT_EXPIRES_IN = form_data.duration - return request.app.state.JWT_EXPIRES_IN + request.app.state.config.JWT_EXPIRES_IN = form_data.duration + return request.app.state.config.JWT_EXPIRES_IN else: - return request.app.state.JWT_EXPIRES_IN + return request.app.state.config.JWT_EXPIRES_IN ############################ diff --git a/backend/apps/web/routers/chats.py b/backend/apps/web/routers/chats.py index f72ed79b37..aaf1735210 100644 --- a/backend/apps/web/routers/chats.py +++ b/backend/apps/web/routers/chats.py @@ -58,7 +58,7 @@ async def delete_all_user_chats(request: Request, user=Depends(get_current_user) if ( user.role == "user" - and not request.app.state.USER_PERMISSIONS["chat"]["deletion"] + and not request.app.state.config.USER_PERMISSIONS["chat"]["deletion"] ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -266,7 +266,7 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_current_ result = Chats.delete_chat_by_id(id) return result else: - if not request.app.state.USER_PERMISSIONS["chat"]["deletion"]: + if not request.app.state.config.USER_PERMISSIONS["chat"]["deletion"]: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, diff --git a/backend/apps/web/routers/configs.py b/backend/apps/web/routers/configs.py index 0bad55a6a1..143ed5e0ae 100644 --- a/backend/apps/web/routers/configs.py +++ b/backend/apps/web/routers/configs.py @@ -44,8 +44,8 @@ class SetDefaultSuggestionsForm(BaseModel): async def set_global_default_models( request: Request, form_data: SetDefaultModelsForm, user=Depends(get_admin_user) ): - request.app.state.DEFAULT_MODELS = form_data.models - return request.app.state.DEFAULT_MODELS + request.app.state.config.DEFAULT_MODELS = form_data.models + return request.app.state.config.DEFAULT_MODELS @router.post("/default/suggestions", response_model=List[PromptSuggestion]) @@ -55,5 +55,5 @@ async def set_global_default_suggestions( user=Depends(get_admin_user), ): data = form_data.model_dump() - request.app.state.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"] - return request.app.state.DEFAULT_PROMPT_SUGGESTIONS + request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"] + return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS diff --git a/backend/apps/web/routers/memories.py b/backend/apps/web/routers/memories.py new file mode 100644 index 0000000000..f20e026014 --- /dev/null +++ b/backend/apps/web/routers/memories.py @@ -0,0 +1,145 @@ +from fastapi import Response, Request +from fastapi import Depends, FastAPI, HTTPException, status +from datetime import datetime, timedelta +from typing import List, Union, Optional + +from fastapi import APIRouter +from pydantic import BaseModel +import logging + +from apps.web.models.memories import Memories, MemoryModel + +from utils.utils import get_verified_user +from constants import ERROR_MESSAGES + +from config import SRC_LOG_LEVELS, CHROMA_CLIENT + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + + +@router.get("/ef") +async def get_embeddings(request: Request): + return {"result": request.app.state.EMBEDDING_FUNCTION("hello world")} + + +############################ +# GetMemories +############################ + + +@router.get("/", response_model=List[MemoryModel]) +async def get_memories(user=Depends(get_verified_user)): + return Memories.get_memories_by_user_id(user.id) + + +############################ +# AddMemory +############################ + + +class AddMemoryForm(BaseModel): + content: str + + +@router.post("/add", response_model=Optional[MemoryModel]) +async def add_memory( + request: Request, form_data: AddMemoryForm, user=Depends(get_verified_user) +): + memory = Memories.insert_new_memory(user.id, form_data.content) + memory_embedding = request.app.state.EMBEDDING_FUNCTION(memory.content) + + collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") + collection.upsert( + documents=[memory.content], + ids=[memory.id], + embeddings=[memory_embedding], + metadatas=[{"created_at": memory.created_at}], + ) + + return memory + + +############################ +# QueryMemory +############################ + + +class QueryMemoryForm(BaseModel): + content: str + + +@router.post("/query") +async def query_memory( + request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) +): + query_embedding = request.app.state.EMBEDDING_FUNCTION(form_data.content) + collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") + + results = collection.query( + query_embeddings=[query_embedding], + n_results=1, # how many results to return + ) + + return results + + +############################ +# ResetMemoryFromVectorDB +############################ +@router.get("/reset", response_model=bool) +async def reset_memory_from_vector_db( + request: Request, user=Depends(get_verified_user) +): + CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") + collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") + + memories = Memories.get_memories_by_user_id(user.id) + for memory in memories: + memory_embedding = request.app.state.EMBEDDING_FUNCTION(memory.content) + collection.upsert( + documents=[memory.content], + ids=[memory.id], + embeddings=[memory_embedding], + ) + return True + + +############################ +# DeleteMemoriesByUserId +############################ + + +@router.delete("/user", response_model=bool) +async def delete_memory_by_user_id(user=Depends(get_verified_user)): + result = Memories.delete_memories_by_user_id(user.id) + + if result: + try: + CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") + except Exception as e: + log.error(e) + return True + + return False + + +############################ +# DeleteMemoryById +############################ + + +@router.delete("/{memory_id}", response_model=bool) +async def delete_memory_by_id(memory_id: str, user=Depends(get_verified_user)): + result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) + + if result: + collection = CHROMA_CLIENT.get_or_create_collection( + name=f"user-memory-{user.id}" + ) + collection.delete(ids=[memory_id]) + return True + + return False diff --git a/backend/apps/web/routers/users.py b/backend/apps/web/routers/users.py index 59f6c21b71..d77475d8df 100644 --- a/backend/apps/web/routers/users.py +++ b/backend/apps/web/routers/users.py @@ -11,8 +11,9 @@ import logging from apps.web.models.users import UserModel, UserUpdateForm, UserRoleUpdateForm, Users from apps.web.models.auths import Auths +from apps.web.models.chats import Chats -from utils.utils import get_current_user, get_password_hash, get_admin_user +from utils.utils import get_verified_user, get_password_hash, get_admin_user from constants import ERROR_MESSAGES from config import SRC_LOG_LEVELS @@ -39,15 +40,15 @@ async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_admin_user) @router.get("/permissions/user") async def get_user_permissions(request: Request, user=Depends(get_admin_user)): - return request.app.state.USER_PERMISSIONS + return request.app.state.config.USER_PERMISSIONS @router.post("/permissions/user") async def update_user_permissions( request: Request, form_data: dict, user=Depends(get_admin_user) ): - request.app.state.USER_PERMISSIONS = form_data - return request.app.state.USER_PERMISSIONS + request.app.state.config.USER_PERMISSIONS = form_data + return request.app.state.config.USER_PERMISSIONS ############################ @@ -67,6 +68,41 @@ async def update_user_role(form_data: UserRoleUpdateForm, user=Depends(get_admin ) +############################ +# GetUserById +############################ + + +class UserResponse(BaseModel): + name: str + profile_image_url: str + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): + + if user_id.startswith("shared-"): + chat_id = user_id.replace("shared-", "") + chat = Chats.get_chat_by_id(chat_id) + if chat: + user_id = chat.user_id + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + user = Users.get_user_by_id(user_id) + + if user: + return UserResponse(name=user.name, profile_image_url=user.profile_image_url) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + ############################ # UpdateUserById ############################ diff --git a/backend/config.py b/backend/config.py index 5c6247a9f7..1a62e98bf2 100644 --- a/backend/config.py +++ b/backend/config.py @@ -5,6 +5,7 @@ import chromadb from chromadb import Settings from base64 import b64encode from bs4 import BeautifulSoup +from typing import TypeVar, Generic, Union from pathlib import Path import json @@ -17,7 +18,6 @@ import shutil from secrets import token_bytes from constants import ERROR_MESSAGES - #################################### # Load .env file #################################### @@ -71,7 +71,6 @@ for source in log_sources: log.setLevel(SRC_LOG_LEVELS["CONFIG"]) - WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") if WEBUI_NAME != "Open WebUI": WEBUI_NAME += " (Open WebUI)" @@ -161,16 +160,6 @@ CHANGELOG = changelog_json WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.100") -#################################### -# WEBUI_AUTH (Required for security) -#################################### - -WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" -WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( - "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None -) - - #################################### # DATA/FRONTEND BUILD DIR #################################### @@ -184,6 +173,108 @@ try: except: CONFIG_DATA = {} + +#################################### +# Config helpers +#################################### + + +def save_config(): + try: + with open(f"{DATA_DIR}/config.json", "w") as f: + json.dump(CONFIG_DATA, f, indent="\t") + except Exception as e: + log.exception(e) + + +def get_config_value(config_path: str): + path_parts = config_path.split(".") + cur_config = CONFIG_DATA + for key in path_parts: + if key in cur_config: + cur_config = cur_config[key] + else: + return None + return cur_config + + +T = TypeVar("T") + + +class PersistentConfig(Generic[T]): + def __init__(self, env_name: str, config_path: str, env_value: T): + self.env_name = env_name + self.config_path = config_path + self.env_value = env_value + self.config_value = get_config_value(config_path) + if self.config_value is not None: + log.info(f"'{env_name}' loaded from config.json") + self.value = self.config_value + else: + self.value = env_value + + def __str__(self): + return str(self.value) + + @property + def __dict__(self): + raise TypeError( + "PersistentConfig object cannot be converted to dict, use config_get or .value instead." + ) + + def __getattribute__(self, item): + if item == "__dict__": + raise TypeError( + "PersistentConfig object cannot be converted to dict, use config_get or .value instead." + ) + return super().__getattribute__(item) + + def save(self): + # Don't save if the value is the same as the env value and the config value + if self.env_value == self.value: + if self.config_value == self.value: + return + log.info(f"Saving '{self.env_name}' to config.json") + path_parts = self.config_path.split(".") + config = CONFIG_DATA + for key in path_parts[:-1]: + if key not in config: + config[key] = {} + config = config[key] + config[path_parts[-1]] = self.value + save_config() + self.config_value = self.value + + +class AppConfig: + _state: dict[str, PersistentConfig] + + def __init__(self): + super().__setattr__("_state", {}) + + def __setattr__(self, key, value): + if isinstance(value, PersistentConfig): + self._state[key] = value + else: + self._state[key].value = value + self._state[key].save() + + def __getattr__(self, key): + return self._state[key].value + + +#################################### +# WEBUI_AUTH (Required for security) +#################################### + +WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" +WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( + "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None +) +JWT_EXPIRES_IN = PersistentConfig( + "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1") +) + #################################### # Static DIR #################################### @@ -318,12 +409,22 @@ OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "") OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(";")] - +OLLAMA_BASE_URLS = PersistentConfig( + "OLLAMA_BASE_URLS", "ollama.base_urls", OLLAMA_BASE_URLS +) #################################### # OPENAI_API #################################### + +ENABLE_OPENAI_API = PersistentConfig( + "ENABLE_OPENAI_API", + "openai.enable", + os.environ.get("ENABLE_OPENAI_API", "True").lower() == "true", +) + + OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "") @@ -335,7 +436,9 @@ OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "") OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY OPENAI_API_KEYS = [url.strip() for url in OPENAI_API_KEYS.split(";")] - +OPENAI_API_KEYS = PersistentConfig( + "OPENAI_API_KEYS", "openai.api_keys", OPENAI_API_KEYS +) OPENAI_API_BASE_URLS = os.environ.get("OPENAI_API_BASE_URLS", "") OPENAI_API_BASE_URLS = ( @@ -346,37 +449,42 @@ OPENAI_API_BASE_URLS = [ url.strip() if url != "" else "https://api.openai.com/v1" for url in OPENAI_API_BASE_URLS.split(";") ] +OPENAI_API_BASE_URLS = PersistentConfig( + "OPENAI_API_BASE_URLS", "openai.api_base_urls", OPENAI_API_BASE_URLS +) OPENAI_API_KEY = "" try: - OPENAI_API_KEY = OPENAI_API_KEYS[ - OPENAI_API_BASE_URLS.index("https://api.openai.com/v1") + OPENAI_API_KEY = OPENAI_API_KEYS.value[ + OPENAI_API_BASE_URLS.value.index("https://api.openai.com/v1") ] except: pass OPENAI_API_BASE_URL = "https://api.openai.com/v1" - #################################### # WEBUI #################################### -ENABLE_SIGNUP = ( - False - if WEBUI_AUTH == False - else os.environ.get("ENABLE_SIGNUP", "True").lower() == "true" +ENABLE_SIGNUP = PersistentConfig( + "ENABLE_SIGNUP", + "ui.enable_signup", + ( + False + if not WEBUI_AUTH + else os.environ.get("ENABLE_SIGNUP", "True").lower() == "true" + ), +) +DEFAULT_MODELS = PersistentConfig( + "DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None) ) -DEFAULT_MODELS = os.environ.get("DEFAULT_MODELS", None) - -DEFAULT_PROMPT_SUGGESTIONS = ( - CONFIG_DATA["ui"]["prompt_suggestions"] - if "ui" in CONFIG_DATA - and "prompt_suggestions" in CONFIG_DATA["ui"] - and type(CONFIG_DATA["ui"]["prompt_suggestions"]) is list - else [ +DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( + "DEFAULT_PROMPT_SUGGESTIONS", + "ui.prompt_suggestions", + [ { "title": ["Help me study", "vocabulary for a college entrance exam"], "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", @@ -404,23 +512,40 @@ DEFAULT_PROMPT_SUGGESTIONS = ( "title": ["Overcome procrastination", "give me tips"], "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", }, - ] + ], ) - -DEFAULT_USER_ROLE = os.getenv("DEFAULT_USER_ROLE", "pending") +DEFAULT_USER_ROLE = PersistentConfig( + "DEFAULT_USER_ROLE", + "ui.default_user_role", + os.getenv("DEFAULT_USER_ROLE", "pending"), +) USER_PERMISSIONS_CHAT_DELETION = ( os.environ.get("USER_PERMISSIONS_CHAT_DELETION", "True").lower() == "true" ) -USER_PERMISSIONS = {"chat": {"deletion": USER_PERMISSIONS_CHAT_DELETION}} +USER_PERMISSIONS = PersistentConfig( + "USER_PERMISSIONS", + "ui.user_permissions", + {"chat": {"deletion": USER_PERMISSIONS_CHAT_DELETION}}, +) -ENABLE_MODEL_FILTER = os.environ.get("ENABLE_MODEL_FILTER", "False").lower() == "true" +ENABLE_MODEL_FILTER = PersistentConfig( + "ENABLE_MODEL_FILTER", + "model_filter.enable", + os.environ.get("ENABLE_MODEL_FILTER", "False").lower() == "true", +) MODEL_FILTER_LIST = os.environ.get("MODEL_FILTER_LIST", "") -MODEL_FILTER_LIST = [model.strip() for model in MODEL_FILTER_LIST.split(";")] +MODEL_FILTER_LIST = PersistentConfig( + "MODEL_FILTER_LIST", + "model_filter.list", + [model.strip() for model in MODEL_FILTER_LIST.split(";")], +) -WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "") +WEBHOOK_URL = PersistentConfig( + "WEBHOOK_URL", "webhook_url", os.environ.get("WEBHOOK_URL", "") +) ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true" @@ -458,26 +583,45 @@ else: CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) -RAG_TOP_K = int(os.environ.get("RAG_TOP_K", "5")) -RAG_RELEVANCE_THRESHOLD = float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")) - -ENABLE_RAG_HYBRID_SEARCH = ( - os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true" +RAG_TOP_K = PersistentConfig( + "RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "5")) +) +RAG_RELEVANCE_THRESHOLD = PersistentConfig( + "RAG_RELEVANCE_THRESHOLD", + "rag.relevance_threshold", + float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")), ) - -ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( - os.environ.get("ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", "True").lower() == "true" +ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( + "ENABLE_RAG_HYBRID_SEARCH", + "rag.enable_hybrid_search", + os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true", ) -RAG_EMBEDDING_ENGINE = os.environ.get("RAG_EMBEDDING_ENGINE", "") - -PDF_EXTRACT_IMAGES = os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true" - -RAG_EMBEDDING_MODEL = os.environ.get( - "RAG_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2" +ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( + "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", + "rag.enable_web_loader_ssl_verification", + os.environ.get("ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", "True").lower() == "true", ) -log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL}"), + +RAG_EMBEDDING_ENGINE = PersistentConfig( + "RAG_EMBEDDING_ENGINE", + "rag.embedding_engine", + os.environ.get("RAG_EMBEDDING_ENGINE", ""), +) + +PDF_EXTRACT_IMAGES = PersistentConfig( + "PDF_EXTRACT_IMAGES", + "rag.pdf_extract_images", + os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true", +) + +RAG_EMBEDDING_MODEL = PersistentConfig( + "RAG_EMBEDDING_MODEL", + "rag.embedding_model", + os.environ.get("RAG_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2"), +) +log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL.value}"), RAG_EMBEDDING_MODEL_AUTO_UPDATE = ( os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "").lower() == "true" @@ -487,9 +631,13 @@ RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get("RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE", "").lower() == "true" ) -RAG_RERANKING_MODEL = os.environ.get("RAG_RERANKING_MODEL", "") -if not RAG_RERANKING_MODEL == "": - log.info(f"Reranking model set: {RAG_RERANKING_MODEL}"), +RAG_RERANKING_MODEL = PersistentConfig( + "RAG_RERANKING_MODEL", + "rag.reranking_model", + os.environ.get("RAG_RERANKING_MODEL", ""), +) +if RAG_RERANKING_MODEL.value != "": + log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}"), RAG_RERANKING_MODEL_AUTO_UPDATE = ( os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "").lower() == "true" @@ -527,9 +675,14 @@ if USE_CUDA.lower() == "true": else: DEVICE_TYPE = "cpu" - -CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "1500")) -CHUNK_OVERLAP = int(os.environ.get("CHUNK_OVERLAP", "100")) +CHUNK_SIZE = PersistentConfig( + "CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1500")) +) +CHUNK_OVERLAP = PersistentConfig( + "CHUNK_OVERLAP", + "rag.chunk_overlap", + int(os.environ.get("CHUNK_OVERLAP", "100")), +) DEFAULT_RAG_TEMPLATE = """Use the following context as your learned knowledge, inside XML tags. @@ -545,16 +698,32 @@ And answer according to the language of the user's question. Given the context information, answer the query. Query: [query]""" -RAG_TEMPLATE = os.environ.get("RAG_TEMPLATE", DEFAULT_RAG_TEMPLATE) +RAG_TEMPLATE = PersistentConfig( + "RAG_TEMPLATE", + "rag.template", + os.environ.get("RAG_TEMPLATE", DEFAULT_RAG_TEMPLATE), +) -RAG_OPENAI_API_BASE_URL = os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) -RAG_OPENAI_API_KEY = os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY) +RAG_OPENAI_API_BASE_URL = PersistentConfig( + "RAG_OPENAI_API_BASE_URL", + "rag.openai_api_base_url", + os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +) +RAG_OPENAI_API_KEY = PersistentConfig( + "RAG_OPENAI_API_KEY", + "rag.openai_api_key", + os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY), +) ENABLE_RAG_LOCAL_WEB_FETCH = ( os.getenv("ENABLE_RAG_LOCAL_WEB_FETCH", "False").lower() == "true" ) -YOUTUBE_LOADER_LANGUAGE = os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(",") +YOUTUBE_LOADER_LANGUAGE = PersistentConfig( + "YOUTUBE_LOADER_LANGUAGE", + "rag.youtube_loader_language", + os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(","), +) #################################### # Transcribe @@ -571,34 +740,78 @@ WHISPER_MODEL_AUTO_UPDATE = ( # Images #################################### -IMAGE_GENERATION_ENGINE = os.getenv("IMAGE_GENERATION_ENGINE", "") - -ENABLE_IMAGE_GENERATION = ( - os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true" +IMAGE_GENERATION_ENGINE = PersistentConfig( + "IMAGE_GENERATION_ENGINE", + "image_generation.engine", + os.getenv("IMAGE_GENERATION_ENGINE", ""), ) -AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "") -COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "") - -IMAGES_OPENAI_API_BASE_URL = os.getenv( - "IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL +ENABLE_IMAGE_GENERATION = PersistentConfig( + "ENABLE_IMAGE_GENERATION", + "image_generation.enable", + os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true", +) +AUTOMATIC1111_BASE_URL = PersistentConfig( + "AUTOMATIC1111_BASE_URL", + "image_generation.automatic1111.base_url", + os.getenv("AUTOMATIC1111_BASE_URL", ""), ) -IMAGES_OPENAI_API_KEY = os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY) -IMAGE_SIZE = os.getenv("IMAGE_SIZE", "512x512") +COMFYUI_BASE_URL = PersistentConfig( + "COMFYUI_BASE_URL", + "image_generation.comfyui.base_url", + os.getenv("COMFYUI_BASE_URL", ""), +) -IMAGE_STEPS = int(os.getenv("IMAGE_STEPS", 50)) +IMAGES_OPENAI_API_BASE_URL = PersistentConfig( + "IMAGES_OPENAI_API_BASE_URL", + "image_generation.openai.api_base_url", + os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +) +IMAGES_OPENAI_API_KEY = PersistentConfig( + "IMAGES_OPENAI_API_KEY", + "image_generation.openai.api_key", + os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY), +) -IMAGE_GENERATION_MODEL = os.getenv("IMAGE_GENERATION_MODEL", "") +IMAGE_SIZE = PersistentConfig( + "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") +) + +IMAGE_STEPS = PersistentConfig( + "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50)) +) + +IMAGE_GENERATION_MODEL = PersistentConfig( + "IMAGE_GENERATION_MODEL", + "image_generation.model", + os.getenv("IMAGE_GENERATION_MODEL", ""), +) #################################### # Audio #################################### -AUDIO_OPENAI_API_BASE_URL = os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) -AUDIO_OPENAI_API_KEY = os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY) -AUDIO_OPENAI_API_MODEL = os.getenv("AUDIO_OPENAI_API_MODEL", "tts-1") -AUDIO_OPENAI_API_VOICE = os.getenv("AUDIO_OPENAI_API_VOICE", "alloy") +AUDIO_OPENAI_API_BASE_URL = PersistentConfig( + "AUDIO_OPENAI_API_BASE_URL", + "audio.openai.api_base_url", + os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +) +AUDIO_OPENAI_API_KEY = PersistentConfig( + "AUDIO_OPENAI_API_KEY", + "audio.openai.api_key", + os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY), +) +AUDIO_OPENAI_API_MODEL = PersistentConfig( + "AUDIO_OPENAI_API_MODEL", + "audio.openai.api_model", + os.getenv("AUDIO_OPENAI_API_MODEL", "tts-1"), +) +AUDIO_OPENAI_API_VOICE = PersistentConfig( + "AUDIO_OPENAI_API_VOICE", + "audio.openai.api_voice", + os.getenv("AUDIO_OPENAI_API_VOICE", "alloy"), +) #################################### # LiteLLM diff --git a/backend/main.py b/backend/main.py index 139819f7c4..4cf3243f7f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from bs4 import BeautifulSoup import json import markdown @@ -58,6 +59,7 @@ from config import ( SRC_LOG_LEVELS, WEBHOOK_URL, ENABLE_ADMIN_EXPORT, + AppConfig, ) from constants import ERROR_MESSAGES @@ -92,16 +94,41 @@ https://github.com/open-webui/open-webui """ ) -app = FastAPI(docs_url="/docs" if ENV == "dev" else None, redoc_url=None) -app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER -app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST +@asynccontextmanager +async def lifespan(app: FastAPI): + if ENABLE_LITELLM: + asyncio.create_task(start_litellm_background()) + yield + if ENABLE_LITELLM: + await shutdown_litellm_background() -app.state.WEBHOOK_URL = WEBHOOK_URL + +app = FastAPI( + docs_url="/docs" if ENV == "dev" else None, redoc_url=None, lifespan=lifespan +) + +app.state.config = AppConfig() +app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER +app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST + +app.state.config.WEBHOOK_URL = WEBHOOK_URL origins = ["*"] +# Custom middleware to add security headers +# class SecurityHeadersMiddleware(BaseHTTPMiddleware): +# async def dispatch(self, request: Request, call_next): +# response: Response = await call_next(request) +# response.headers["Cross-Origin-Opener-Policy"] = "same-origin" +# response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" +# return response + + +# app.add_middleware(SecurityHeadersMiddleware) + + class RAGMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): return_citations = False @@ -129,12 +156,12 @@ class RAGMiddleware(BaseHTTPMiddleware): data["messages"], citations = rag_messages( docs=data["docs"], messages=data["messages"], - template=rag_app.state.RAG_TEMPLATE, + template=rag_app.state.config.RAG_TEMPLATE, embedding_function=rag_app.state.EMBEDDING_FUNCTION, - k=rag_app.state.TOP_K, + k=rag_app.state.config.TOP_K, reranking_function=rag_app.state.sentence_transformer_rf, - r=rag_app.state.RELEVANCE_THRESHOLD, - hybrid_search=rag_app.state.ENABLE_RAG_HYBRID_SEARCH, + r=rag_app.state.config.RELEVANCE_THRESHOLD, + hybrid_search=rag_app.state.config.ENABLE_RAG_HYBRID_SEARCH, ) del data["docs"] @@ -211,15 +238,15 @@ async def check_url(request: Request, call_next): return response -@app.on_event("startup") -async def on_startup(): - if ENABLE_LITELLM: - asyncio.create_task(start_litellm_background()) +@app.middleware("http") +async def update_embedding_function(request: Request, call_next): + response = await call_next(request) + if "/embedding/update" in request.url.path: + webui_app.state.EMBEDDING_FUNCTION = rag_app.state.EMBEDDING_FUNCTION + return response -app.mount("/api/v1", webui_app) app.mount("/litellm/api", litellm_app) - app.mount("/ollama", ollama_app) app.mount("/openai/api", openai_app) @@ -227,6 +254,10 @@ app.mount("/images/api/v1", images_app) app.mount("/audio/api/v1", audio_app) app.mount("/rag/api/v1", rag_app) +app.mount("/api/v1", webui_app) + +webui_app.state.EMBEDDING_FUNCTION = rag_app.state.EMBEDDING_FUNCTION + @app.get("/api/config") async def get_app_config(): @@ -243,9 +274,9 @@ async def get_app_config(): "version": VERSION, "auth": WEBUI_AUTH, "default_locale": default_locale, - "images": images_app.state.ENABLED, - "default_models": webui_app.state.DEFAULT_MODELS, - "default_prompt_suggestions": webui_app.state.DEFAULT_PROMPT_SUGGESTIONS, + "images": images_app.state.config.ENABLED, + "default_models": webui_app.state.config.DEFAULT_MODELS, + "default_prompt_suggestions": webui_app.state.config.DEFAULT_PROMPT_SUGGESTIONS, "trusted_header_auth": bool(webui_app.state.AUTH_TRUSTED_EMAIL_HEADER), "admin_export_enabled": ENABLE_ADMIN_EXPORT, } @@ -254,8 +285,8 @@ async def get_app_config(): @app.get("/api/config/model/filter") async def get_model_filter_config(user=Depends(get_admin_user)): return { - "enabled": app.state.ENABLE_MODEL_FILTER, - "models": app.state.MODEL_FILTER_LIST, + "enabled": app.state.config.ENABLE_MODEL_FILTER, + "models": app.state.config.MODEL_FILTER_LIST, } @@ -268,28 +299,28 @@ class ModelFilterConfigForm(BaseModel): async def update_model_filter_config( form_data: ModelFilterConfigForm, user=Depends(get_admin_user) ): - app.state.ENABLE_MODEL_FILTER = form_data.enabled - app.state.MODEL_FILTER_LIST = form_data.models + app.state.config.ENABLE_MODEL_FILTER = form_data.enabled + app.state.config.MODEL_FILTER_LIST = form_data.models - ollama_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER - ollama_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST + ollama_app.state.config.ENABLE_MODEL_FILTER = app.state.config.ENABLE_MODEL_FILTER + ollama_app.state.config.MODEL_FILTER_LIST = app.state.config.MODEL_FILTER_LIST - openai_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER - openai_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST + openai_app.state.config.ENABLE_MODEL_FILTER = app.state.config.ENABLE_MODEL_FILTER + openai_app.state.config.MODEL_FILTER_LIST = app.state.config.MODEL_FILTER_LIST - litellm_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER - litellm_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST + litellm_app.state.ENABLE_MODEL_FILTER = app.state.config.ENABLE_MODEL_FILTER + litellm_app.state.MODEL_FILTER_LIST = app.state.config.MODEL_FILTER_LIST return { - "enabled": app.state.ENABLE_MODEL_FILTER, - "models": app.state.MODEL_FILTER_LIST, + "enabled": app.state.config.ENABLE_MODEL_FILTER, + "models": app.state.config.MODEL_FILTER_LIST, } @app.get("/api/webhook") async def get_webhook_url(user=Depends(get_admin_user)): return { - "url": app.state.WEBHOOK_URL, + "url": app.state.config.WEBHOOK_URL, } @@ -299,12 +330,12 @@ class UrlForm(BaseModel): @app.post("/api/webhook") async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)): - app.state.WEBHOOK_URL = form_data.url + app.state.config.WEBHOOK_URL = form_data.url - webui_app.state.WEBHOOK_URL = app.state.WEBHOOK_URL + webui_app.state.WEBHOOK_URL = app.state.config.WEBHOOK_URL return { - "url": app.state.WEBHOOK_URL, + "url": app.state.config.WEBHOOK_URL, } @@ -368,6 +399,11 @@ async def get_opensearch_xml(): return Response(content=xml_content, media_type="application/xml") +@app.get("/health") +async def healthcheck(): + return {"status": True} + + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache") @@ -381,9 +417,3 @@ else: log.warning( f"Frontend build directory not found at '{FRONTEND_BUILD_DIR}'. Serving API only." ) - - -@app.on_event("shutdown") -async def shutdown_event(): - if ENABLE_LITELLM: - await shutdown_litellm_background() diff --git a/cypress/e2e/chat.cy.ts b/cypress/e2e/chat.cy.ts index f46bef57b3..ced9981048 100644 --- a/cypress/e2e/chat.cy.ts +++ b/cypress/e2e/chat.cy.ts @@ -42,5 +42,37 @@ describe('Settings', () => { .find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received .should('exist'); }); + + it('user can share chat', () => { + // Click on the model selector + cy.get('button[aria-label="Select a model"]').click(); + // Select the first model + cy.get('button[aria-label="model-item"]').first().click(); + // Type a message + cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', { + force: true + }); + // Send the message + cy.get('button[type="submit"]').click(); + // User's message should be visible + cy.get('.chat-user').should('exist'); + // Wait for the response + cy.get('.chat-assistant', { timeout: 120_000 }) // .chat-assistant is created after the first token is received + .find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received + .should('exist'); + // spy on requests + const spy = cy.spy(); + cy.intercept('GET', '/api/v1/chats/*', spy); + // Open context menu + cy.get('#chat-context-menu-button').click(); + // Click share button + cy.get('#chat-share-button').click(); + // Check if the share dialog is visible + cy.get('#copy-and-share-chat-button').should('exist'); + cy.wrap({}, { timeout: 5000 }).should(() => { + // Check if the request was made twice (once for to replace chat object and once more due to change event) + expect(spy).to.be.callCount(2); + }); + }); }); }); diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts index 560ce97942..5db232faa7 100644 --- a/cypress/e2e/settings.cy.ts +++ b/cypress/e2e/settings.cy.ts @@ -15,12 +15,8 @@ describe('Settings', () => { cy.loginAdmin(); // Visit the home page cy.visit('/'); - // Open the sidebar if it is not already open - cy.get('[aria-label="Open sidebar"]').then(() => { - cy.get('button[id="sidebar-toggle-button"]').click(); - }); - // Click on the profile link - cy.get('button').contains(adminUser.name).click(); + // Click on the user menu + cy.get('button[aria-label="User Menu"]').click(); // Click on the settings link cy.get('button').contains('Settings').click(); }); diff --git a/docker-compose.api.yaml b/docker-compose.api.yaml index f19974e7d7..8f8fbe59ad 100644 --- a/docker-compose.api.yaml +++ b/docker-compose.api.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: ollama: # Expose Ollama API outside the container stack diff --git a/docker-compose.data.yaml b/docker-compose.data.yaml index a8f9f77b02..4b70601f89 100644 --- a/docker-compose.data.yaml +++ b/docker-compose.data.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: ollama: volumes: diff --git a/docker-compose.gpu.yaml b/docker-compose.gpu.yaml index 424f485a1c..de821235da 100644 --- a/docker-compose.gpu.yaml +++ b/docker-compose.gpu.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: ollama: # GPU support diff --git a/docker-compose.yaml b/docker-compose.yaml index 9daba312aa..74249febd9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: ollama: volumes: diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f78436d54a..3e82c979c8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,7 +17,7 @@ If your issue or contribution pertains directly to the core Ollama technology, p ### 🚨 Reporting Issues -Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/oopen-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently. +Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently. > [!IMPORTANT] > @@ -54,7 +54,7 @@ Help us make Open WebUI more accessible by improving documentation, writing tuto Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project. -We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes][http://www.lingoes.net/en/translator/langcode.htm] to find the appropriate code for a specific language. +We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language. To add a new language: diff --git a/package-lock.json b/package-lock.json index be7ae0dc99..5f98d38f62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "open-webui", - "version": "0.1.124", + "version": "0.1.125", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.1.124", + "version": "0.1.125", "dependencies": { + "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", @@ -22,6 +23,7 @@ "js-sha256": "^0.10.1", "katex": "^0.16.9", "marked": "^9.1.0", + "pyodide": "^0.26.0-alpha.4", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "uuid": "^9.0.1" @@ -50,7 +52,8 @@ "tailwindcss": "^3.3.3", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.4.2" + "vite": "^4.4.2", + "vitest": "^1.6.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -759,6 +762,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -877,6 +892,19 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@pyscript/core": { + "version": "0.4.32", + "resolved": "https://registry.npmjs.org/@pyscript/core/-/core-0.4.32.tgz", + "integrity": "sha512-WQATzPp1ggf871+PukCmTypzScXkEB1EWD/vg5GNxpM96N6rDPqQ13msuA5XvwU01ZVhL8HHSFDLk4IfaXNGWg==", + "dependencies": { + "@ungap/with-resolvers": "^0.1.0", + "basic-devtools": "^0.1.6", + "polyscript": "^0.12.8", + "sticky-module": "^0.1.1", + "to-json-callback": "^0.1.1", + "type-checked-collections": "^0.1.7" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", @@ -965,6 +993,220 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", + "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", + "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", + "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", + "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", + "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", + "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", + "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", + "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", + "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", + "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", + "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", + "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", + "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", + "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", + "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", + "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, "node_modules/@sveltejs/adapter-auto": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.1.1.tgz", @@ -1378,8 +1620,122 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + }, + "node_modules/@ungap/with-resolvers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ungap/with-resolvers/-/with-resolvers-0.1.0.tgz", + "integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==" + }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@webreflection/fetch": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", + "integrity": "sha512-zCcqCJoNLvdeF41asAK71XPlwSPieeRDsE09albBunJEksuYPYNillKNQjf8p5BqSoTKTuKrW3lUm3MNodUC4g==" }, "node_modules/acorn": { "version": "8.11.3", @@ -1401,6 +1757,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1576,6 +1941,15 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1677,6 +2051,11 @@ "dev": true, "optional": true }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1697,6 +2076,11 @@ } ] }, + "node_modules/basic-devtools": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/basic-devtools/-/basic-devtools-0.1.6.tgz", + "integrity": "sha512-g9zJ63GmdUesS3/Fwv0B5SYX6nR56TQXmGr+wE5PRTNCnGQMYWhUx/nZB/mMWnQJVLPPAp89oxDNlasdtNkW5Q==" + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1959,6 +2343,15 @@ "@types/ws": "~8.5.10" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -2031,6 +2424,24 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2047,6 +2458,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -2272,6 +2695,28 @@ "@types/estree": "^1.0.0" } }, + "node_modules/codedent": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/codedent/-/codedent-0.1.2.tgz", + "integrity": "sha512-qEqzcy5viM3UoCN0jYHZeXZoyd4NZQzYFg0kOBj8O1CgoGG9WYYTF+VeQRsN0OSKFjF3G1u4WDUOtOsWEx6N2w==", + "dependencies": { + "plain-tag": "^0.1.3" + } + }, + "node_modules/coincident": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz", + "integrity": "sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==", + "dependencies": { + "@ungap/structured-clone": "^1.2.0", + "@ungap/with-resolvers": "^0.1.0", + "gc-hook": "^0.3.1", + "proxy-target": "^3.0.2" + }, + "optionalDependencies": { + "ws": "^8.16.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2346,6 +2791,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2591,6 +3042,18 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2659,6 +3122,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3585,6 +4057,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gc-hook": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", + "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3903,6 +4389,11 @@ "node": ">=12.0.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -4366,6 +4857,12 @@ "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz", "integrity": "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==" }, + "node_modules/js-tokens": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", + "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==", + "dev": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4600,6 +5097,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -4735,6 +5248,15 @@ "node": ">=8" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4930,6 +5452,18 @@ "node": ">0.9" } }, + "node_modules/mlly": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.0.tgz", + "integrity": "sha512-U9SDaXGEREBYQgfejV97coK0UL1r+qnF2SyO9A3qcI8MzKnsIFKHNVEkrDyNncQTKQQumsasmeq84eNMdBfsNQ==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.1.0", + "ufo": "^1.5.3" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -5272,6 +5806,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5344,6 +5893,40 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.1.tgz", + "integrity": "sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.7", + "mlly": "^1.7.0", + "pathe": "^1.1.2" + } + }, + "node_modules/plain-tag": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/plain-tag/-/plain-tag-0.1.3.tgz", + "integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA==" + }, + "node_modules/polyscript": { + "version": "0.12.8", + "resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.12.8.tgz", + "integrity": "sha512-kcG3W9jU/s1sYjWOTAa2jAh5D2jm3zJRi+glSTsC+lA3D1b/Sd67pEIGpyL9bWNKYSimqAx4se6jAhQjJZ7+jQ==", + "dependencies": { + "@ungap/structured-clone": "^1.2.0", + "@ungap/with-resolvers": "^0.1.0", + "@webreflection/fetch": "^0.1.5", + "basic-devtools": "^0.1.6", + "codedent": "^0.1.2", + "coincident": "^1.2.3", + "gc-hook": "^0.3.1", + "html-escaper": "^3.0.3", + "proxy-target": "^3.0.2", + "sticky-module": "^0.1.1", + "to-json-callback": "^0.1.1" + } + }, "node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", @@ -5601,6 +6184,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -5631,6 +6240,11 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, + "node_modules/proxy-target": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/proxy-target/-/proxy-target-3.0.2.tgz", + "integrity": "sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -5656,6 +6270,18 @@ "node": ">=6" } }, + "node_modules/pyodide": { + "version": "0.26.0-alpha.4", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.26.0-alpha.4.tgz", + "integrity": "sha512-Ixuczq99DwhQlE+Bt0RaS6Ln9MHSZOkbU6iN8azwaeorjHtr7ukaxh+FeTxViFrp2y+ITyKgmcobY+JnBPcULw==", + "dependencies": { + "base-64": "^1.0.0", + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/qs": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", @@ -5768,6 +6394,12 @@ "rimraf": "bin.js" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -6189,6 +6821,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6314,6 +6952,23 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/sticky-module": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sticky-module/-/sticky-module-0.1.1.tgz", + "integrity": "sha512-IuYgnyIMUx/m6rtu14l/LR2MaqOLtpXcWkxPmtPsiScRHEo+S4Tojk+DWFHOncSdFX/OsoLOM4+T92yOmI1AMw==" + }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", @@ -6468,6 +7123,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz", + "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -6923,6 +7590,30 @@ "globrex": "^0.1.2" } }, + "node_modules/tinybench": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", + "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -6940,6 +7631,11 @@ "node": ">=14.14" } }, + "node_modules/to-json-callback": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/to-json-callback/-/to-json-callback-0.1.1.tgz", + "integrity": "sha512-BzOeinTT3NjE+FJ2iCvWB8HvyuyBzoH3WlSnJ+AYVC4tlePyZWSYdkQIFOARWiq0t35/XhmI0uQsFiUsRksRqg==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7049,6 +7745,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-checked-collections": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/type-checked-collections/-/type-checked-collections-0.1.7.tgz", + "integrity": "sha512-fLIydlJy7IG9XL4wjRwEcKhxx/ekLXiWiMvcGo01cOMF+TN+5ZqajM1mRNRz2bNNi1bzou2yofhjZEQi7kgl9A==" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -7074,6 +7784,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, "node_modules/underscore.string": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", @@ -7343,6 +8059,118 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz", + "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/rollup": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", + "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.17.2", + "@rollup/rollup-android-arm64": "4.17.2", + "@rollup/rollup-darwin-arm64": "4.17.2", + "@rollup/rollup-darwin-x64": "4.17.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", + "@rollup/rollup-linux-arm-musleabihf": "4.17.2", + "@rollup/rollup-linux-arm64-gnu": "4.17.2", + "@rollup/rollup-linux-arm64-musl": "4.17.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", + "@rollup/rollup-linux-riscv64-gnu": "4.17.2", + "@rollup/rollup-linux-s390x-gnu": "4.17.2", + "@rollup/rollup-linux-x64-gnu": "4.17.2", + "@rollup/rollup-linux-x64-musl": "4.17.2", + "@rollup/rollup-win32-arm64-msvc": "4.17.2", + "@rollup/rollup-win32-ia32-msvc": "4.17.2", + "@rollup/rollup-win32-x64-msvc": "4.17.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", + "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vite/node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -7722,6 +8550,283 @@ } } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/vitest/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/vitest/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/rollup": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", + "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.17.2", + "@rollup/rollup-android-arm64": "4.17.2", + "@rollup/rollup-darwin-arm64": "4.17.2", + "@rollup/rollup-darwin-x64": "4.17.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", + "@rollup/rollup-linux-arm-musleabihf": "4.17.2", + "@rollup/rollup-linux-arm64-gnu": "4.17.2", + "@rollup/rollup-linux-arm64-musl": "4.17.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", + "@rollup/rollup-linux-riscv64-gnu": "4.17.2", + "@rollup/rollup-linux-s390x-gnu": "4.17.2", + "@rollup/rollup-linux-x64-gnu": "4.17.2", + "@rollup/rollup-linux-x64-musl": "4.17.2", + "@rollup/rollup-win32-arm64-msvc": "4.17.2", + "@rollup/rollup-win32-ia32-msvc": "4.17.2", + "@rollup/rollup-win32-x64-msvc": "4.17.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vitest/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", + "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vue-template-compiler": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", @@ -7784,6 +8889,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -7883,6 +9004,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index f8096269c1..2b412e310b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "open-webui", - "version": "0.1.124", + "version": "0.1.125", "private": true, "scripts": { - "dev": "vite dev --host", - "build": "vite build", + "dev": "npm run pyodide:fetch && vite dev --host", + "build": "npm run pyodide:fetch && vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -12,10 +12,12 @@ "lint:frontend": "eslint . --fix", "lint:types": "npm run check", "lint:backend": "pylint backend/", - "format": "prettier --plugin-search-dir --write '**/*.{js,ts,svelte,css,md,html,json}'", + "format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"", "format:backend": "black . --exclude \"/venv/\"", - "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'", - "cy:open": "cypress open" + "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"", + "cy:open": "cypress open", + "test:frontend": "vitest", + "pyodide:fetch": "node scripts/prepare-pyodide.js" }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", @@ -41,10 +43,12 @@ "tailwindcss": "^3.3.3", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.4.2" + "vite": "^4.4.2", + "vitest": "^1.6.0" }, "type": "module", "dependencies": { + "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", @@ -59,6 +63,7 @@ "js-sha256": "^0.10.1", "katex": "^0.16.9", "marked": "^9.1.0", + "pyodide": "^0.26.0-alpha.4", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "uuid": "^9.0.1" diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js new file mode 100644 index 0000000000..c14a5bf1bc --- /dev/null +++ b/scripts/prepare-pyodide.js @@ -0,0 +1,39 @@ +const packages = [ + 'requests', + 'beautifulsoup4', + 'numpy', + 'pandas', + 'matplotlib', + 'scikit-learn', + 'scipy', + 'regex', + 'seaborn' +]; + +import { loadPyodide } from 'pyodide'; +import { writeFile, copyFile, readdir } from 'fs/promises'; + +async function downloadPackages() { + console.log('Setting up pyodide + micropip'); + const pyodide = await loadPyodide({ + packageCacheDir: 'static/pyodide' + }); + await pyodide.loadPackage('micropip'); + const micropip = pyodide.pyimport('micropip'); + console.log('Downloading Pyodide packages:', packages); + await micropip.install(packages); + console.log('Pyodide packages downloaded, freezing into lock file'); + const lockFile = await micropip.freeze(); + await writeFile('static/pyodide/pyodide-lock.json', lockFile); +} + +async function copyPyodide() { + console.log('Copying Pyodide files into static directory'); + // Copy all files from node_modules/pyodide to static/pyodide + for await (const entry of await readdir('node_modules/pyodide')) { + await copyFile(`node_modules/pyodide/${entry}`, `static/pyodide/${entry}`); + } +} + +await downloadPackages(); +await copyPyodide(); diff --git a/src/app.css b/src/app.css index 7d111bd48b..f7c14bcbd6 100644 --- a/src/app.css +++ b/src/app.css @@ -83,11 +83,31 @@ select { display: none; } -.scrollbar-none:active::-webkit-scrollbar-thumb, -.scrollbar-none:focus::-webkit-scrollbar-thumb, -.scrollbar-none:hover::-webkit-scrollbar-thumb { +.scrollbar-hidden:active::-webkit-scrollbar-thumb, +.scrollbar-hidden:focus::-webkit-scrollbar-thumb, +.scrollbar-hidden:hover::-webkit-scrollbar-thumb { visibility: visible; } -.scrollbar-none::-webkit-scrollbar-thumb { +.scrollbar-hidden::-webkit-scrollbar-thumb { visibility: hidden; } + +.scrollbar-none::-webkit-scrollbar { + display: none; /* for Chrome, Safari and Opera */ +} + +.scrollbar-none { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ +} + +input[type='number'] { + -moz-appearance: textfield; /* Firefox */ +} diff --git a/src/app.html b/src/app.html index 1616cc668d..138fb2829d 100644 --- a/src/app.html +++ b/src/app.html @@ -12,6 +12,7 @@ title="Open WebUI" href="/opensearch.xml" /> + {#if code} -
+
{@html lang}
- + +
+ {#if ['', 'python'].includes(lang) && (lang === 'python' || checkPythonCode(code))} + {#if executing} +
Running
+ {:else} + + {/if} + {/if} + +
{@html highlightedCode || code}
+ +
+ + {#if executing} +
+
STDOUT/STDERR
+
Running...
+
+ {:else if stdout || stderr || result} +
+
STDOUT/STDERR
+
{stdout || stderr || result}
+
+ {/if}
{/if} diff --git a/src/lib/components/chat/Messages/CompareMessages.svelte b/src/lib/components/chat/Messages/CompareMessages.svelte new file mode 100644 index 0000000000..60efdb2abc --- /dev/null +++ b/src/lib/components/chat/Messages/CompareMessages.svelte @@ -0,0 +1,163 @@ + + +
+
+ {#each Object.keys(groupedMessages) as model} + {#if groupedMessagesIdx[model] !== undefined && groupedMessages[model].messages.length > 0} + + + +
{ + currentMessageId = groupedMessages[model].messages[groupedMessagesIdx[model]].id; + + let messageId = groupedMessages[model].messages[groupedMessagesIdx[model]].id; + + console.log(messageId); + let messageChildrenIds = history.messages[messageId].childrenIds; + + while (messageChildrenIds.length !== 0) { + messageId = messageChildrenIds.at(-1); + messageChildrenIds = history.messages[messageId].childrenIds; + } + + history.currentId = messageId; + dispatch('change'); + }} + > + m.id)} + isLastMessage={true} + {updateChatMessages} + {confirmEditResponseMessage} + showPreviousMessage={() => showPreviousMessage(model)} + showNextMessage={() => showNextMessage(model)} + {rateMessage} + {copyToClipboard} + {continueGeneration} + regenerateResponse={async (message) => { + regenerateResponse(message); + await tick(); + groupedMessagesIdx[model] = groupedMessages[model].messages.length - 1; + }} + on:save={async (e) => { + console.log('save', e); + + const message = e.detail; + history.messages[message.id] = message; + await updateChatById(localStorage.token, chatId, { + messages: messages, + history: history + }); + }} + /> +
+ {/if} + {/each} +
+
diff --git a/src/lib/components/chat/Messages/Name.svelte b/src/lib/components/chat/Messages/Name.svelte index dea6ef84ec..6047a08b5f 100644 --- a/src/lib/components/chat/Messages/Name.svelte +++ b/src/lib/components/chat/Messages/Name.svelte @@ -1,3 +1,3 @@ -
+
diff --git a/src/lib/components/chat/Messages/Placeholder.svelte b/src/lib/components/chat/Messages/Placeholder.svelte index 5035904d47..dfb6cfb366 100644 --- a/src/lib/components/chat/Messages/Placeholder.svelte +++ b/src/lib/components/chat/Messages/Placeholder.svelte @@ -43,6 +43,7 @@ > {#if model in modelfiles} modelfile {:else} + import { settings } from '$lib/stores'; + import { WEBUI_BASE_URL } from '$lib/constants'; + export let src = '/user.png'; -
- profile +
+ profile
diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte index 2c6d3608e3..895143e021 100644 --- a/src/lib/components/chat/Messages/RateComment.svelte +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -39,9 +39,9 @@ let selectedReason = null; let comment = ''; - $: if (message.annotation.rating === 1) { + $: if (message?.annotation?.rating === 1) { reasons = LIKE_REASONS; - } else if (message.annotation.rating === -1) { + } else if (message?.annotation?.rating === -1) { reasons = DISLIKE_REASONS; } diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 82cb7a846d..71227989be 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -65,11 +65,11 @@ let generatingImage = false; let showRateComment = false; - let showCitationModal = false; + let selectedCitation = null; - $: tokens = marked.lexer(sanitizeResponseContent(message.content)); + $: tokens = marked.lexer(sanitizeResponseContent(message?.content)); const renderer = new marked.Renderer(); @@ -332,13 +332,17 @@ {#key message.id} -
+
-
+
{#if message.model in modelfiles} {modelfiles[message.model]?.title} @@ -347,8 +351,10 @@ {/if} {#if message.timestamp} - @@ -370,7 +376,7 @@ >
{#if edit === true} -
+