main #3

Merged
tdpeuter merged 116 commits from open-webui/open-webui:main into main 2024-03-26 12:22:23 +01:00
170 changed files with 19354 additions and 3729 deletions
Showing only changes of commit 45311bfa15 - Show all commits

View file

@ -7,7 +7,6 @@ node_modules
/package /package
.env .env
.env.* .env.*
!.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
__pycache__ __pycache__

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# Ollama URL for the backend to connect
# The path '/ollama' will be redirected to the specified backend URL
OLLAMA_BASE_URL='http://localhost:11434'
OPENAI_API_BASE_URL=''
OPENAI_API_KEY=''
# AUTOMATIC1111_BASE_URL="http://localhost:7860"
# DO NOT TRACK
SCARF_NO_ANALYTICS=true
DO_NOT_TRACK=true

View file

@ -32,7 +32,7 @@ assignees: ''
**Confirmation:** **Confirmation:**
- [ ] I have read and followed all the instructions provided in the README.md. - [ ] I have read and followed all the instructions provided in the README.md.
- [ ] I have reviewed the troubleshooting.md document. - [ ] I am on the latest version of both Open WebUI and Ollama.
- [ ] I have included the browser console logs. - [ ] I have included the browser console logs.
- [ ] I have included the Docker container logs. - [ ] I have included the Docker container logs.

32
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,32 @@
## Pull Request Checklist
- [ ] **Description:** Briefly describe the changes 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?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
---
## Description
[Insert a brief description of the changes made in this pull request]
---
### Changelog Entry
### Added
- [List any new features or additions]
### Fixed
- [List any fixes or corrections]
### Changed
- [List any changes or updates]
### Removed
- [List any removed features or files]

59
.github/workflows/build-release.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Release
on:
push:
branches:
- main # or whatever branch you want to use
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check for changes in package.json
run: |
git diff --cached --diff-filter=d package.json || {
echo "No changes to package.json"
exit 1
}
- name: Get version number from package.json
id: get_version
run: |
VERSION=$(jq -r '.version' package.json)
echo "::set-output name=version::$VERSION"
- name: Extract latest CHANGELOG entry
id: changelog
run: |
CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md)
CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g')
echo "Extracted latest release notes from CHANGELOG.md:"
echo -e "$CHANGELOG_CONTENT"
echo "::set-output name=content::$CHANGELOG_ESCAPED"
- name: Create GitHub release
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const changelog = `${{ steps.changelog.outputs.content }}`;
const release = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${{ steps.get_version.outputs.version }}`,
name: `v${{ steps.get_version.outputs.version }}`,
body: changelog,
})
console.log(`Created release ${release.data.html_url}`)
- name: Upload package to GitHub release
uses: actions/upload-artifact@v3
with:
name: package
path: .
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -52,6 +52,7 @@ jobs:
type=ref,event=tag type=ref,event=tag
type=sha,prefix=git- type=sha,prefix=git-
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: | flavor: |
latest=${{ github.ref == 'refs/heads/main' }} latest=${{ github.ref == 'refs/heads/main' }}

211
CHANGELOG.md Normal file
View file

@ -0,0 +1,211 @@
# Changelog
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.114] - 2024-03-20
### Added
- **🔗 Webhook Integration**: Now you can subscribe to new user sign-up events via webhook. Simply navigate to the admin panel > admin settings > webhook URL.
- **🛡️ Enhanced Model Filtering**: Alongside Ollama, OpenAI proxy model whitelisting, we've added model filtering functionality for LiteLLM proxy.
- **🌍 Expanded Language Support**: Spanish, Catalan, and Vietnamese languages are now available, with improvements made to others.
### Fixed
- **🔧 Input Field Spelling**: Resolved issue with spelling mistakes in input fields.
- **🖊️ Light Mode Styling**: Fixed styling issue with light mode in document adding.
### Changed
- **🔄 Language Sorting**: Languages are now sorted alphabetically by their code for improved organization.
## [0.1.113] - 2024-03-18
### Added
- 🌍 **Localization**: You can now change the UI language in Settings > General. We support Ukrainian, German, Farsi (Persian), Traditional and Simplified Chinese and French translations. You can help us to translate the UI into your language! More info in our [CONTRIBUTION.md](https://github.com/open-webui/open-webui/blob/main/docs/CONTRIBUTING.md#-translations-and-internationalization).
- 🎨 **System-wide Theme**: Introducing a new system-wide theme for enhanced visual experience.
### Fixed
- 🌑 **Dark Background on Select Fields**: Improved readability by adding a dark background to select fields, addressing issues on certain browsers/devices.
- **Multiple OPENAI_API_BASE_URLS Issue**: Resolved issue where multiple base URLs caused conflicts when one wasn't functioning.
- **RAG Encoding Issue**: Fixed encoding problem in RAG.
- **npm Audit Fix**: Addressed npm audit findings.
- **Reduced Scroll Threshold**: Improved auto-scroll experience by reducing the scroll threshold from 50px to 5px.
### Changed
- 🔄 **Sidebar UI Update**: Updated sidebar UI to feature a chat menu dropdown, replacing two icons for improved navigation.
## [0.1.112] - 2024-03-15
### Fixed
- 🗨️ Resolved chat malfunction after image generation.
- 🎨 Fixed various RAG issues.
- 🧪 Rectified experimental broken GGUF upload logic.
## [0.1.111] - 2024-03-10
### Added
- 🛡️ **Model Whitelisting**: Admins now have the ability to whitelist models for users with the 'user' role.
- 🔄 **Update All Models**: Added a convenient button to update all models at once.
- 📄 **Toggle PDF OCR**: Users can now toggle PDF OCR option for improved parsing performance.
- 🎨 **DALL-E Integration**: Introduced DALL-E integration for image generation alongside automatic1111.
- 🛠️ **RAG API Refactoring**: Refactored RAG logic and exposed its API, with additional documentation to follow.
### Fixed
- 🔒 **Max Token Settings**: Added max token settings for anthropic/claude-3-sonnet-20240229 (Issue #1094).
- 🔧 **Misalignment Issue**: Corrected misalignment of Edit and Delete Icons when Chat Title is Empty (Issue #1104).
- 🔄 **Context Loss Fix**: Resolved RAG losing context on model response regeneration with Groq models via API key (Issue #1105).
- 📁 **File Handling Bug**: Addressed File Not Found Notification when Dropping a Conversation Element (Issue #1098).
- 🖱️ **Dragged File Styling**: Fixed dragged file layover styling issue.
## [0.1.110] - 2024-03-06
### Added
- **🌐 Multiple OpenAI Servers Support**: Enjoy seamless integration with multiple OpenAI-compatible APIs, now supported natively.
### Fixed
- **🔍 OCR Issue**: Resolved PDF parsing issue caused by OCR malfunction.
- **🚫 RAG Issue**: Fixed the RAG functionality, ensuring it operates smoothly.
- **📄 "Add Docs" Model Button**: Addressed the non-functional behavior of the "Add Docs" model button.
## [0.1.109] - 2024-03-06
### Added
- **🔄 Multiple Ollama Servers Support**: Enjoy enhanced scalability and performance with support for multiple Ollama servers in a single WebUI. Load balancing features are now available, providing improved efficiency (#788, #278).
- **🔧 Support for Claude 3 and Gemini**: Responding to user requests, we've expanded our toolset to include Claude 3 and Gemini, offering a wider range of functionalities within our platform (#1064).
- **🔍 OCR Functionality for PDF Loader**: We've augmented our PDF loader with Optical Character Recognition (OCR) capabilities. Now, extract text from scanned documents and images within PDFs, broadening the scope of content processing (#1050).
### Fixed
- **🛠️ RAG Collection**: Implemented a dynamic mechanism to recreate RAG collections, ensuring users have up-to-date and accurate data (#1031).
- **📝 User Agent Headers**: Fixed issue of RAG web requests being sent with empty user_agent headers, reducing rejections from certain websites. Realistic headers are now utilized for these requests (#1024).
- **⏹️ Playground Cancel Functionality**: Introducing a new "Cancel" option for stopping Ollama generation in the Playground, enhancing user control and usability (#1006).
- **🔤 Typographical Error in 'ASSISTANT' Field**: Corrected a typographical error in the 'ASSISTANT' field within the GGUF model upload template for accuracy and consistency (#1061).
### Changed
- **🔄 Refactored Message Deletion Logic**: Streamlined message deletion process for improved efficiency and user experience, simplifying interactions within the platform (#1004).
- **⚠️ Deprecation of `OLLAMA_API_BASE_URL`**: Deprecated `OLLAMA_API_BASE_URL` environment variable; recommend using `OLLAMA_BASE_URL` instead. Refer to our documentation for further details.
## [0.1.108] - 2024-03-02
### Added
- **🎮 Playground Feature (Beta)**: Explore the full potential of the raw API through an intuitive UI with our new playground feature, accessible to admins. Simply click on the bottom name area of the sidebar to access it. The playground feature offers two modes text completion (notebook) and chat completion. As it's in beta, please report any issues you encounter.
- **🛠️ Direct Database Download for Admins**: Admins can now download the database directly from the WebUI via the admin settings.
- **🎨 Additional RAG Settings**: Customize your RAG process with the ability to edit the TOP K value. Navigate to Documents > Settings > General to make changes.
- **🖥️ UI Improvements**: Tooltips now available in the input area and sidebar handle. More tooltips will be added across other parts of the UI.
### Fixed
- Resolved input autofocus issue on mobile when the sidebar is open, making it easier to use.
- Corrected numbered list display issue in Safari (#963).
- Restricted user ability to delete chats without proper permissions (#993).
### Changed
- **Simplified Ollama Settings**: Ollama settings now don't require the `/api` suffix. You can now utilize the Ollama base URL directly, e.g., `http://localhost:11434`. Also, an `OLLAMA_BASE_URL` environment variable has been added.
- **Database Renaming**: Starting from this release, `ollama.db` will be automatically renamed to `webui.db`.
## [0.1.107] - 2024-03-01
### Added
- **🚀 Makefile and LLM Update Script**: Included Makefile and a script for LLM updates in the repository.
### Fixed
- Corrected issue where links in the settings modal didn't appear clickable (#960).
- Fixed problem with web UI port not taking effect due to incorrect environment variable name in run-compose.sh (#996).
- Enhanced user experience by displaying chat in browser title and enabling automatic scrolling to the bottom (#992).
### Changed
- Upgraded toast library from `svelte-french-toast` to `svelte-sonner` for a more polished UI.
- Enhanced accessibility with the addition of dark mode on the authentication page.
## [0.1.106] - 2024-02-27
### Added
- **🎯 Auto-focus Feature**: The input area now automatically focuses when initiating or opening a chat conversation.
### Fixed
- Corrected typo from "HuggingFace" to "Hugging Face" (Issue #924).
- Resolved bug causing errors in chat completion API calls to OpenAI due to missing "num_ctx" parameter (Issue #927).
- Fixed issues preventing text editing, selection, and cursor retention in the input field (Issue #940).
- Fixed a bug where defining an OpenAI-compatible API server using 'OPENAI_API_BASE_URL' containing 'openai' string resulted in hiding models not containing 'gpt' string from the model menu. (Issue #930)
## [0.1.105] - 2024-02-25
### Added
- **📄 Document Selection**: Now you can select and delete multiple documents at once for easier management.
### Changed
- **🏷️ Document Pre-tagging**: Simply click the "+" button at the top, enter tag names in the popup window, or select from a list of existing tags. Then, upload files with the added tags for streamlined organization.
## [0.1.104] - 2024-02-25
### Added
- **🔄 Check for Updates**: Keep your system current by checking for updates conveniently located in Settings > About.
- **🗑️ Automatic Tag Deletion**: Unused tags on the sidebar will now be deleted automatically with just a click.
### Changed
- **🎨 Modernized Styling**: Enjoy a refreshed look with updated styling for a more contemporary experience.
## [0.1.103] - 2024-02-25
### Added
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
- **🖼️ Image Generation Enhancements**: Introducing Advanced Settings with Image Preview Feature.
- Customize image generation by setting the number of steps; defaults to A1111 value.
### Fixed
- Resolved issue with RAG scan halting document loading upon encountering unsupported MIME types or exceptions (Issue #866).
### Changed
- Ollama is no longer required to run Open WebUI.
- Access our comprehensive documentation at [Open WebUI Documentation](https://docs.openwebui.com/).
## [0.1.102] - 2024-02-22
### Added
- **🖼️ Image Generation**: Generate Images using the AUTOMATIC1111/stable-diffusion-webui API. You can set this up in Settings > Images.
- **📝 Change title generation prompt**: Change the prompt used to generate titles for your chats. You can set this up in the Settings > Interface.
- **🤖 Change embedding model**: Change the embedding model used to generate embeddings for your chats in the Dockerfile. Use any sentence transformer model from huggingface.co.
- **📢 CHANGELOG.md/Popup**: This popup will show you the latest changes.
## [0.1.101] - 2024-02-22
### Fixed
- LaTex output formatting issue (#828)
### Changed
- Instead of having the previous 1.0.0-alpha.101, we switched to semantic versioning as a way to respect global conventions.

View file

@ -20,7 +20,7 @@ FROM python:3.11-slim-bookworm as base
ENV ENV=prod ENV ENV=prod
ENV PORT "" ENV PORT ""
ENV OLLAMA_API_BASE_URL "/ollama/api" ENV OLLAMA_BASE_URL "/ollama"
ENV OPENAI_API_BASE_URL "" ENV OPENAI_API_BASE_URL ""
ENV OPENAI_API_KEY "" ENV OPENAI_API_KEY ""
@ -30,15 +30,31 @@ ENV WEBUI_SECRET_KEY ""
ENV SCARF_NO_ANALYTICS true ENV SCARF_NO_ANALYTICS true
ENV DO_NOT_TRACK true ENV DO_NOT_TRACK true
#Whisper TTS Settings ######## Preloaded models ########
# whisper TTS Settings
ENV WHISPER_MODEL="base" ENV WHISPER_MODEL="base"
ENV WHISPER_MODEL_DIR="/app/backend/data/cache/whisper/models" ENV WHISPER_MODEL_DIR="/app/backend/data/cache/whisper/models"
# RAG Embedding Model Settings
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
# Leaderboard: https://huggingface.co/spaces/mteb/leaderboard
# for better persormance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB)
# IMPORTANT: If you change the default model (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.
ENV RAG_EMBEDDING_MODEL="all-MiniLM-L6-v2"
# device type for whisper tts and embbeding models - "cpu" (default), "cuda" (nvidia gpu and CUDA required) or "mps" (apple silicon) - choosing this right can lead to better performance
ENV RAG_EMBEDDING_MODEL_DEVICE_TYPE="cpu"
ENV RAG_EMBEDDING_MODEL_DIR="/app/backend/data/cache/embedding/models"
ENV SENTENCE_TRANSFORMERS_HOME $RAG_EMBEDDING_MODEL_DIR
######## Preloaded models ########
WORKDIR /app/backend WORKDIR /app/backend
# install python dependencies # install python dependencies
COPY ./backend/requirements.txt ./requirements.txt COPY ./backend/requirements.txt ./requirements.txt
RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir
RUN pip3 install -r requirements.txt --no-cache-dir RUN pip3 install -r requirements.txt --no-cache-dir
@ -48,9 +64,10 @@ RUN apt-get update \
&& apt-get install -y pandoc netcat-openbsd \ && apt-get install -y pandoc netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# RUN python -c "from sentence_transformers import SentenceTransformer; model = SentenceTransformer('all-MiniLM-L6-v2')" # preload embedding model
RUN 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'])" RUN python -c "import os; from chromadb.utils import embedding_functions; sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=os.environ['RAG_EMBEDDING_MODEL'], device=os.environ['RAG_EMBEDDING_MODEL_DEVICE_TYPE'])"
# preload tts model
RUN python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='auto', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"
# copy embedding weight from build # copy embedding weight from build
RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2 RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
@ -58,6 +75,8 @@ COPY --from=build /app/onnx /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onn
# copy built frontend files # copy built frontend files
COPY --from=build /app/build /app/build 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 backend files # copy backend files
COPY ./backend . COPY ./backend .

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
install:
@docker-compose up -d
remove:
@chmod +x confirm_remove.sh
@./confirm_remove.sh
start:
@docker-compose start
stop:
@docker-compose stop
update:
# Calls the LLM update script
chmod +x update_ollama_models.sh
@./update_ollama_models.sh
@git pull
@docker-compose down
# Make sure the ollama-webui container is stopped before rebuilding
@docker stop open-webui || true
@docker-compose up --build -d
@docker-compose start

254
README.md
View file

@ -1,22 +1,20 @@
# Open WebUI (Formerly Ollama WebUI) 👋 # Open WebUI (Formerly Ollama WebUI) 👋
![GitHub stars](https://img.shields.io/github/stars/ollama-webui/ollama-webui?style=social) ![GitHub stars](https://img.shields.io/github/stars/open-webui/open-webui?style=social)
![GitHub forks](https://img.shields.io/github/forks/ollama-webui/ollama-webui?style=social) ![GitHub forks](https://img.shields.io/github/forks/open-webui/open-webui?style=social)
![GitHub watchers](https://img.shields.io/github/watchers/ollama-webui/ollama-webui?style=social) ![GitHub watchers](https://img.shields.io/github/watchers/open-webui/open-webui?style=social)
![GitHub repo size](https://img.shields.io/github/repo-size/ollama-webui/ollama-webui) ![GitHub repo size](https://img.shields.io/github/repo-size/open-webui/open-webui)
![GitHub language count](https://img.shields.io/github/languages/count/ollama-webui/ollama-webui) ![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui)
![GitHub top language](https://img.shields.io/github/languages/top/ollama-webui/ollama-webui) ![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/ollama-webui/ollama-webui?color=red) ![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false) ![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
ChatGPT-Style Web Interface for Ollama 🦙 Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
![Open WebUI Demo](./demo.gif) ![Open WebUI Demo](./demo.gif)
Also check our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles for Ollama! 🦙🔍
## Features ⭐ ## Features ⭐
- 🖥️ **Intuitive Interface**: Our chat interface takes inspiration from ChatGPT, ensuring a user-friendly experience. - 🖥️ **Intuitive Interface**: Our chat interface takes inspiration from ChatGPT, ensuring a user-friendly experience.
@ -55,8 +53,6 @@ Also check our sibling project, [Open WebUI Community](https://openwebui.com/),
- 💬 **Collaborative Chat**: Harness the collective intelligence of multiple models by seamlessly orchestrating group conversations. Use the `@` command to specify the model, enabling dynamic and diverse dialogues within your chat interface. Immerse yourself in the collective intelligence woven into your chat environment. - 💬 **Collaborative Chat**: Harness the collective intelligence of multiple models by seamlessly orchestrating group conversations. Use the `@` command to specify the model, enabling dynamic and diverse dialogues within your chat interface. Immerse yourself in the collective intelligence woven into your chat environment.
- 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible API for versatile conversations alongside Ollama models. Customize the API Base URL to link with **LMStudio, Mistral, OpenRouter, and more**.
- 🔄 **Regeneration History Access**: Easily revisit and explore your entire regeneration history. - 🔄 **Regeneration History Access**: Easily revisit and explore your entire regeneration history.
- 📜 **Chat History**: Effortlessly access and manage your conversation history. - 📜 **Chat History**: Effortlessly access and manage your conversation history.
@ -67,66 +63,39 @@ Also check our sibling project, [Open WebUI Community](https://openwebui.com/),
- ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs. - ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs.
- 🎨🤖 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using AUTOMATIC1111 API (local) and DALL-E, enriching your chat experience with dynamic visual content.
- 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible API for versatile conversations alongside Ollama models. Customize the API Base URL to link with **LMStudio, Mistral, OpenRouter, and more**.
- ✨ **Multiple OpenAI-Compatible API Support**: Seamlessly integrate and customize various OpenAI-compatible APIs, enhancing the versatility of your chat interactions.
- 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable. - 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable.
- 🔀 **Multiple Ollama Instance Load Balancing**: Effortlessly distribute chat requests across multiple Ollama instances for enhanced performance and reliability.
- 👥 **Multi-User Management**: Easily oversee and administer users via our intuitive admin panel, streamlining user management processes.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators. - 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Open WebUI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security. - 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Open WebUI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates and new features. - 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates and new features.
## 🔗 Also Check Out Open WebUI Community! ## 🔗 Also Check Out Open WebUI Community!
Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Ollama! 🚀 Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀
## How to Install 🚀 ## How to Install 🚀
🌟 **Important Note on User Roles and Privacy:** > [!NOTE]
> Please note that for certain Docker environments, additional configurations might be needed. If you encounter any connection issues, our detailed guide on [Open WebUI Documentation](https://docs.openwebui.com/) is ready to assist you.
- **Admin Creation:** The very first account to sign up on the Open WebUI will be granted **Administrator privileges**. This account will have comprehensive control over the platform, including user management and system settings. ### Quick Start with Docker 🐳
- **User Registrations:** All subsequent users signing up will initially have their accounts set to **Pending** status by default. These accounts will require approval from the Administrator to gain access to the platform functionalities. > [!IMPORTANT]
> When using Docker to install Open WebUI, make sure to include the `-v open-webui:/app/backend/data` in your Docker command. This step is crucial as it ensures your database is properly mounted and prevents any loss of data.
- **Privacy and Data Security:** We prioritize your privacy and data security above all. Please be reassured that all data entered into the Open WebUI is stored locally on your device. Our system is designed to be privacy-first, ensuring that no external requests are made, and your data does not leave your local environment. We are committed to maintaining the highest standards of data privacy and security, ensuring that your information remains confidential and under your control.
### Steps to Install Open WebUI
#### Before You Begin
1. **Installing Docker:**
- **For Windows and Mac Users:**
- Download Docker Desktop from [Docker's official website](https://www.docker.com/products/docker-desktop).
- Follow the installation instructions provided on the website. After installation, open Docker Desktop to ensure it's running properly.
- **For Ubuntu and Other Linux Users:**
- Open your terminal.
- Set up your Docker apt repository according to the [Docker documentation](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository)
- Update your package index:
```bash
sudo apt-get update
```
- Install Docker using the following command:
```bash
sudo apt-get install docker-ce docker-ce-cli containerd.io
```
- Verify the Docker installation with:
```bash
sudo docker run hello-world
```
This command downloads a test image and runs it in a container, which prints an informational message.
2. **Ensure You Have the Latest Version of Ollama:**
- Download the latest version from [https://ollama.com/](https://ollama.com/).
3. **Verify Ollama Installation:**
- After installing Ollama, check if it's working by visiting [http://127.0.0.1:11434/](http://127.0.0.1:11434/) in your web browser. Remember, the port number might be different for you.
#### Installing with Docker 🐳
- **Important:** When using Docker to install Open WebUI, make sure to include the `-v open-webui:/app/backend/data` in your Docker command. This step is crucial as it ensures your database is properly mounted and prevents any loss of data.
- **If Ollama is on your computer**, use this command: - **If Ollama is on your computer**, use this command:
@ -134,158 +103,51 @@ Don't forget to explore our sibling project, [Open WebUI Community](https://open
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
``` ```
- **To build the container yourself**, follow these steps: - **If Ollama is on a Different Server**, use this command:
- To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL:
```bash ```bash
docker build -t open-webui . docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://example.com -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always open-webui
``` ```
- After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). - After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄
#### Using Ollama on a Different Server #### Open WebUI: Server Connection Error
- To connect to Ollama on another server, change the `OLLAMA_API_BASE_URL` to the server's URL: If you're experiencing connection issues, its often due to the WebUI docker container not being able to reach the Ollama server at 127.0.0.1:11434 (host.docker.internal:11434) inside the container . Use the `--network=host` flag in your docker command to resolve this. Note that the port changes from 3000 to 8080, resulting in the link: `http://localhost:8080`.
**Example Docker Command**:
```bash ```bash
docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
``` ```
Or for a self-built container: ### Other Installation Methods
We offer various installation alternatives, including non-Docker methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
### Troubleshooting
Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).
### Keeping Your Docker Installation Up-to-Date
In case you want to update your local Docker installation to the latest version, you can do it with [Watchtower](https://containrrr.dev/watchtower/):
```bash ```bash
docker build -t open-webui . docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui
docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api -v open-webui:/app/backend/data --name open-webui --restart always open-webui
``` ```
### Installing Ollama and Open WebUI Together In the last part of the command, replace `open-webui` with your container name if it is different.
#### Using Docker Compose ### Moving from Ollama WebUI to Open WebUI
- If you don't have Ollama yet, use Docker Compose for easy installation. Run this command: Check our Migration Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/migration/).
```bash ## What's Next? 🌟
docker compose up -d --build
```
- **For GPU Support:** Use an additional Docker Compose file: Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
```bash
docker compose -f docker-compose.yaml -f docker-compose.gpu.yaml up -d --build
```
- **To Expose Ollama API:** Use another Docker Compose file:
```bash
docker compose -f docker-compose.yaml -f docker-compose.api.yaml up -d --build
```
#### Using `run-compose.sh` Script (Linux or Docker-Enabled WSL2 on Windows)
- Give execute permission to the script:
```bash
chmod +x run-compose.sh
```
- For CPU-only container:
```bash
./run-compose.sh
```
- For GPU support (read the note about GPU compatibility):
```bash
./run-compose.sh --enable-gpu
```
- To build the latest local version, add `--build`:
```bash
./run-compose.sh --enable-gpu --build
```
### Alternative Installation Methods
For other ways to install, like using Kustomize or Helm, check out [INSTALLATION.md](/INSTALLATION.md). Join our [Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) for more help and information.
### Updating your Docker Installation
In case you want to update your local Docker installation to the latest version, you can do it performing the following actions:
```bash
docker rm -f open-webui
docker pull ghcr.io/open-webui/open-webui:main
[insert command you used to install]
```
In the last line, you need to use the very same command you used to install (local install, remote server, etc.)
## How to Install Without Docker
While we strongly recommend using our convenient Docker container installation for optimal support, we understand that some situations may require a non-Docker setup, especially for development purposes. Please note that non-Docker installations are not officially supported, and you might need to troubleshoot on your own.
### Project Components
The Open WebUI consists of two primary components: the frontend and the backend (which serves as a reverse proxy, handling static frontend files, and additional features). Both need to be running concurrently for the development environment.
> [!IMPORTANT]
> The backend is required for proper functionality
### Requirements 📦
- 🐰 [Bun](https://bun.sh) >= 1.0.21 or 🐢 [Node.js](https://nodejs.org/en) >= 20.10
- 🐍 [Python](https://python.org) >= 3.11
### Build and Install 🛠️
Run the following commands to install:
```sh
git clone https://github.com/open-webui/open-webui.git
cd open-webui/
# Copying required .env file
cp -RPp example.env .env
# Building Frontend Using Node
npm i
npm run build
# or Building Frontend Using Bun
# bun install
# bun run build
# Serving Frontend with the Backend
cd ./backend
pip install -r requirements.txt -U
sh start.sh
```
You should have the Open WebUI up and running at http://localhost:8080/. Enjoy! 😄
## Troubleshooting
See [TROUBLESHOOTING.md](/TROUBLESHOOTING.md) for information on how to troubleshoot and/or join our [Open WebUI Discord community](https://discord.gg/5rJgQTnV4s).
## What's Next? 🚀
### Roadmap 📝
Here are some exciting tasks on our roadmap:
- 🔊 **Local Text-to-Speech Integration**: Seamlessly incorporate text-to-speech functionality directly within the platform, allowing for a smoother and more immersive user experience.
- 🛡️ **Granular Permissions and User Groups**: Empower administrators to finely control access levels and group users according to their roles and responsibilities. This feature ensures robust security measures and streamlined management of user privileges, enhancing overall platform functionality.
- 🔄 **Function Calling**: Empower your interactions by running code directly within the chat. Execute functions and commands effortlessly, enhancing the functionality of your conversations.
- ⚙️ **Custom Python Backend Actions**: Empower your Open WebUI by creating or downloading custom Python backend actions. Unleash the full potential of your web interface with tailored actions that suit your specific needs, enhancing functionality and versatility.
- 🔧 **Fine-tune Model (LoRA)**: Fine-tune your model directly from the user interface. This feature allows for precise customization and optimization of the chat experience to better suit your needs and preferences.
- 🧠 **Long-Term Memory**: Witness the power of persistent memory in our agents. Enjoy conversations that feel continuous as agents remember and reference past interactions, creating a more cohesive and personalized user experience.
- 🧪 **Research-Centric Features**: Empower researchers in the fields of LLM and HCI with a comprehensive web UI for conducting user studies. Stay tuned for ongoing feature enhancements (e.g., surveys, analytics, and participant tracking) to facilitate their research.
- 📈 **User Study Tools**: Providing specialized tools, like heat maps and behavior tracking modules, to empower researchers in capturing and analyzing user behavior patterns with precision and accuracy.
- 📚 **Enhanced Documentation**: Elevate your setup and customization experience with improved, comprehensive documentation.
Feel free to contribute and help us make Open WebUI even better! 🙌
## Supporters ✨ ## Supporters ✨
@ -308,6 +170,16 @@ This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LI
If you have any questions, suggestions, or need assistance, please open an issue or join our If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝 [Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date" />
</picture>
</a>
--- ---
Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open Web UI even more amazing together! 💪 Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open Web UI even more amazing together! 💪

View file

@ -4,7 +4,7 @@
The Open WebUI system is designed to streamline interactions between the client (your browser) and the Ollama API. At the heart of this design is a backend reverse proxy, enhancing security and resolving CORS issues. The Open WebUI system is designed to streamline interactions between the client (your browser) and the Ollama API. At the heart of this design is a backend reverse proxy, enhancing security and resolving CORS issues.
- **How it Works**: The Open WebUI is designed to interact with the Ollama API through a specific route. When a request is made from the WebUI to Ollama, it is not directly sent to the Ollama API. Initially, the request is sent to the Open WebUI backend via `/ollama/api` route. From there, the backend is responsible for forwarding the request to the Ollama API. This forwarding is accomplished by using the route specified in the `OLLAMA_API_BASE_URL` environment variable. Therefore, a request made to `/ollama/api` in the WebUI is effectively the same as making a request to `OLLAMA_API_BASE_URL` in the backend. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_API_BASE_URL/tags` in the backend. - **How it Works**: The Open WebUI is designed to interact with the Ollama API through a specific route. When a request is made from the WebUI to Ollama, it is not directly sent to the Ollama API. Initially, the request is sent to the Open WebUI backend via `/ollama` route. From there, the backend is responsible for forwarding the request to the Ollama API. This forwarding is accomplished by using the route specified in the `OLLAMA_BASE_URL` environment variable. Therefore, a request made to `/ollama` in the WebUI is effectively the same as making a request to `OLLAMA_BASE_URL` in the backend. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_BASE_URL/api/tags` in the backend.
- **Security Benefits**: This design prevents direct exposure of the Ollama API to the frontend, safeguarding against potential CORS (Cross-Origin Resource Sharing) issues and unauthorized access. Requiring authentication to access the Ollama API further enhances this security layer. - **Security Benefits**: This design prevents direct exposure of the Ollama API to the frontend, safeguarding against potential CORS (Cross-Origin Resource Sharing) issues and unauthorized access. Requiring authentication to access the Ollama API further enhances this security layer.
@ -15,7 +15,7 @@ If you're experiencing connection issues, its often due to the WebUI docker c
**Example Docker Command**: **Example Docker Command**:
```bash ```bash
docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name open-webui --restart always ghcr.io/open-webui/open-webui:main docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
``` ```
### General Connection Errors ### General Connection Errors
@ -25,8 +25,8 @@ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_API_BASE_
**Troubleshooting Steps**: **Troubleshooting Steps**:
1. **Verify Ollama URL Format**: 1. **Verify Ollama URL Format**:
- When running the Web UI container, ensure the `OLLAMA_API_BASE_URL` is correctly set, including the `/api` suffix. (e.g., `http://192.168.1.1:11434/api` for different host setups). - When running the Web UI container, ensure the `OLLAMA_BASE_URL` is correctly set. (e.g., `http://192.168.1.1:11434` for different host setups).
- In the Open WebUI, navigate to "Settings" > "General". - In the Open WebUI, navigate to "Settings" > "General".
- Confirm that the Ollama Server URL is correctly set to `[OLLAMA URL]/api` (e.g., `http://localhost:11434/api`), including the `/api` suffix. - Confirm that the Ollama Server URL is correctly set to `[OLLAMA URL]` (e.g., `http://localhost:11434`).
By following these enhanced troubleshooting steps, connection issues should be effectively resolved. For further assistance or queries, feel free to reach out to us on our community Discord. By following these enhanced troubleshooting steps, connection issues should be effectively resolved. For further assistance or queries, feel free to reach out to us on our community Discord.

View file

@ -5,3 +5,10 @@ uploads
.ipynb_checkpoints .ipynb_checkpoints
*.db *.db
_test _test
!/data
/data/*
!/data/litellm
/data/litellm/*
!data/litellm/config.yaml
!data/config.json

8
backend/.gitignore vendored
View file

@ -6,5 +6,11 @@ uploads
*.db *.db
_test _test
Pipfile Pipfile
data/* !/data
/data/*
!/data/litellm
/data/litellm/*
!data/litellm/config.yaml
!data/config.json
.webui_secret_key .webui_secret_key

View file

@ -56,7 +56,7 @@ def transcribe(
model = WhisperModel( model = WhisperModel(
WHISPER_MODEL, WHISPER_MODEL,
device="cpu", device="auto",
compute_type="int8", compute_type="int8",
download_root=WHISPER_MODEL_DIR, download_root=WHISPER_MODEL_DIR,
) )

365
backend/apps/images/main.py Normal file
View file

@ -0,0 +1,365 @@
import re
import requests
from fastapi import (
FastAPI,
Request,
Depends,
HTTPException,
status,
UploadFile,
File,
Form,
)
from fastapi.middleware.cors import CORSMiddleware
from faster_whisper import WhisperModel
from constants import ERROR_MESSAGES
from utils.utils import (
get_current_user,
get_admin_user,
)
from utils.misc import calculate_sha256
from typing import Optional
from pydantic import BaseModel
from pathlib import Path
import uuid
import base64
import json
from config import CACHE_DIR, AUTOMATIC1111_BASE_URL
IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/")
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.state.ENGINE = ""
app.state.ENABLED = False
app.state.OPENAI_API_KEY = ""
app.state.MODEL = ""
app.state.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
app.state.IMAGE_SIZE = "512x512"
app.state.IMAGE_STEPS = 50
@app.get("/config")
async def get_config(request: Request, user=Depends(get_admin_user)):
return {"engine": app.state.ENGINE, "enabled": app.state.ENABLED}
class ConfigUpdateForm(BaseModel):
engine: str
enabled: bool
@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}
class UrlUpdateForm(BaseModel):
url: str
@app.get("/url")
async def get_automatic1111_url(user=Depends(get_admin_user)):
return {"AUTOMATIC1111_BASE_URL": app.state.AUTOMATIC1111_BASE_URL}
@app.post("/url/update")
async def update_automatic1111_url(
form_data: UrlUpdateForm, user=Depends(get_admin_user)
):
if form_data.url == "":
app.state.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
else:
url = form_data.url.strip("/")
try:
r = requests.head(url)
app.state.AUTOMATIC1111_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,
"status": True,
}
class OpenAIKeyUpdateForm(BaseModel):
key: str
@app.get("/key")
async def get_openai_key(user=Depends(get_admin_user)):
return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY}
@app.post("/key/update")
async def update_openai_key(
form_data: OpenAIKeyUpdateForm, user=Depends(get_admin_user)
):
if form_data.key == "":
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
app.state.OPENAI_API_KEY = form_data.key
return {
"OPENAI_API_KEY": app.state.OPENAI_API_KEY,
"status": True,
}
class ImageSizeUpdateForm(BaseModel):
size: str
@app.get("/size")
async def get_image_size(user=Depends(get_admin_user)):
return {"IMAGE_SIZE": app.state.IMAGE_SIZE}
@app.post("/size/update")
async def update_image_size(
form_data: ImageSizeUpdateForm, user=Depends(get_admin_user)
):
pattern = r"^\d+x\d+$" # Regular expression pattern
if re.match(pattern, form_data.size):
app.state.IMAGE_SIZE = form_data.size
return {
"IMAGE_SIZE": app.state.IMAGE_SIZE,
"status": True,
}
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 512x512)."),
)
class ImageStepsUpdateForm(BaseModel):
steps: int
@app.get("/steps")
async def get_image_size(user=Depends(get_admin_user)):
return {"IMAGE_STEPS": app.state.IMAGE_STEPS}
@app.post("/steps/update")
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
return {
"IMAGE_STEPS": app.state.IMAGE_STEPS,
"status": True,
}
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 50)."),
)
@app.get("/models")
def get_models(user=Depends(get_current_user)):
try:
if app.state.ENGINE == "openai":
return [
{"id": "dall-e-2", "name": "DALL·E 2"},
{"id": "dall-e-3", "name": "DALL·E 3"},
]
else:
r = requests.get(
url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models"
)
models = r.json()
return list(
map(
lambda model: {"id": model["title"], "name": model["model_name"]},
models,
)
)
except Exception as e:
app.state.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"}
else:
r = requests.get(url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/options")
options = r.json()
return {"model": options["sd_model_checkpoint"]}
except Exception as e:
app.state.ENABLED = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
class UpdateModelForm(BaseModel):
model: str
def set_model_handler(model: str):
if app.state.ENGINE == "openai":
app.state.MODEL = model
return app.state.MODEL
else:
r = requests.get(url=f"{app.state.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
)
return options
@app.post("/models/default/update")
def update_default_model(
form_data: UpdateModelForm,
user=Depends(get_current_user),
):
return set_model_handler(form_data.model)
class GenerateImageForm(BaseModel):
model: Optional[str] = None
prompt: str
n: int = 1
size: Optional[str] = None
negative_prompt: Optional[str] = None
def save_b64_image(b64_str):
image_id = str(uuid.uuid4())
file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.png")
try:
# Split the base64 string to get the actual image data
img_data = base64.b64decode(b64_str)
# Write the image data to a file
with open(file_path, "wb") as f:
f.write(img_data)
return image_id
except Exception as e:
print(f"Error saving image: {e}")
return None
@app.post("/generations")
def generate_image(
form_data: GenerateImageForm,
user=Depends(get_current_user),
):
r = None
try:
if app.state.ENGINE == "openai":
headers = {}
headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}"
headers["Content-Type"] = "application/json"
data = {
"model": app.state.MODEL if app.state.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,
"response_format": "b64_json",
}
r = requests.post(
url=f"https://api.openai.com/v1/images/generations",
json=data,
headers=headers,
)
r.raise_for_status()
res = r.json()
images = []
for image in res["data"]:
image_id = save_b64_image(image["b64_json"])
images.append({"url": f"/cache/image/generations/{image_id}.png"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json")
with open(file_body_path, "w") as f:
json.dump(data, f)
return images
else:
if form_data.model:
set_model_handler(form_data.model)
width, height = tuple(map(int, app.state.IMAGE_SIZE.split("x")))
data = {
"prompt": form_data.prompt,
"batch_size": form_data.n,
"width": width,
"height": height,
}
if app.state.IMAGE_STEPS != None:
data["steps"] = app.state.IMAGE_STEPS
if form_data.negative_prompt != None:
data["negative_prompt"] = form_data.negative_prompt
r = requests.post(
url=f"{app.state.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img",
json=data,
)
res = r.json()
print(res)
images = []
for image in res["images"]:
image_id = save_b64_image(image)
images.append({"url": f"/cache/image/generations/{image_id}.png"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json")
with open(file_body_path, "w") as f:
json.dump({**data, "info": res["info"]}, f)
return images
except Exception as e:
error = e
if r != None:
data = r.json()
if "error" in data:
error = data["error"]["message"]
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))

View file

@ -0,0 +1,95 @@
from litellm.proxy.proxy_server import ProxyConfig, initialize
from litellm.proxy.proxy_server import app
from fastapi import FastAPI, Request, Depends, status, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import StreamingResponse
import json
from utils.utils import get_http_authorization_cred, get_current_user
from config import ENV
from config import (
MODEL_FILTER_ENABLED,
MODEL_FILTER_LIST,
)
proxy_config = ProxyConfig()
async def config():
router, model_list, general_settings = await proxy_config.load_config(
router=None, config_file_path="./data/litellm/config.yaml"
)
await initialize(config="./data/litellm/config.yaml", telemetry=False)
async def startup():
await config()
@app.on_event("startup")
async def on_startup():
await startup()
app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED
app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
auth_header = request.headers.get("Authorization", "")
request.state.user = None
try:
user = get_current_user(get_http_authorization_cred(auth_header))
print(user)
request.state.user = user
except Exception as e:
return JSONResponse(status_code=400, content={"detail": str(e)})
response = await call_next(request)
return response
class ModifyModelsResponseMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
user = request.state.user
if "/models" in request.url.path:
if isinstance(response, StreamingResponse):
# Read the content of the streaming response
body = b""
async for chunk in response.body_iterator:
body += chunk
data = json.loads(body.decode("utf-8"))
if app.state.MODEL_FILTER_ENABLED:
if user and user.role == "user":
data["data"] = list(
filter(
lambda model: model["id"]
in app.state.MODEL_FILTER_LIST,
data["data"],
)
)
# Modified Flag
data["modified"] = True
return JSONResponse(content=data)
return response
app.add_middleware(ModifyModelsResponseMiddleware)

View file

@ -3,15 +3,22 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, ConfigDict
import random
import requests import requests
import json import json
import uuid import uuid
from pydantic import BaseModel import aiohttp
import asyncio
from apps.web.models.users import Users from apps.web.models.users import Users
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from utils.utils import decode_token, get_current_user, get_admin_user from utils.utils import decode_token, get_current_user, get_admin_user
from config import OLLAMA_API_BASE_URL, WEBUI_AUTH from config import OLLAMA_BASE_URLS, MODEL_FILTER_ENABLED, MODEL_FILTER_LIST
from typing import Optional, List, Union
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
@ -22,27 +29,48 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.state.OLLAMA_API_BASE_URL = OLLAMA_API_BASE_URL
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED
app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST
app.state.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
app.state.MODELS = {}
REQUEST_POOL = [] REQUEST_POOL = []
@app.get("/url") # TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
async def get_ollama_api_url(user=Depends(get_admin_user)): # Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL} # least connections, or least response time for better resource utilization and performance optimization.
@app.middleware("http")
async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
else:
pass
response = await call_next(request)
return response
@app.get("/urls")
async def get_ollama_api_urls(user=Depends(get_admin_user)):
return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS}
class UrlUpdateForm(BaseModel): class UrlUpdateForm(BaseModel):
url: str urls: List[str]
@app.post("/url/update") @app.post("/urls/update")
async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)):
app.state.OLLAMA_API_BASE_URL = form_data.url app.state.OLLAMA_BASE_URLS = form_data.urls
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL}
print(app.state.OLLAMA_BASE_URLS)
return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS}
@app.get("/cancel/{request_id}") @app.get("/cancel/{request_id}")
@ -55,9 +83,824 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user))
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
async def fetch_url(url):
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.json()
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
return None
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
digest = model["digest"]
if digest not in merged_models:
model["urls"] = [idx]
merged_models[digest] = model
else:
merged_models[digest]["urls"].append(idx)
return list(merged_models.values())
# user=Depends(get_current_user)
async def get_all_models():
print("get_all_models")
tasks = [fetch_url(f"{url}/api/tags") for url in app.state.OLLAMA_BASE_URLS]
responses = await asyncio.gather(*tasks)
models = {
"models": merge_models_lists(
map(lambda response: response["models"] if response else None, responses)
)
}
app.state.MODELS = {model["model"]: model for model in models["models"]}
return models
@app.get("/api/tags")
@app.get("/api/tags/{url_idx}")
async def get_ollama_tags(
url_idx: Optional[int] = None, user=Depends(get_current_user)
):
if url_idx == None:
models = await get_all_models()
if app.state.MODEL_FILTER_ENABLED:
if user.role == "user":
models["models"] = list(
filter(
lambda model: model["name"] in app.state.MODEL_FILTER_LIST,
models["models"],
)
)
return models
return models
else:
url = app.state.OLLAMA_BASE_URLS[url_idx]
try:
r = requests.request(method="GET", url=f"{url}/api/tags")
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.get("/api/version")
@app.get("/api/version/{url_idx}")
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]
responses = await asyncio.gather(*tasks)
responses = list(filter(lambda x: x is not None, responses))
if len(responses) > 0:
lowest_version = min(
responses, key=lambda x: tuple(map(int, x["version"].split(".")))
)
return {"version": lowest_version["version"]}
else:
raise HTTPException(
status_code=500,
detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND,
)
else:
url = app.state.OLLAMA_BASE_URLS[url_idx]
try:
r = requests.request(method="GET", url=f"{url}/api/version")
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class ModelNameForm(BaseModel):
name: str
@app.post("/api/pull")
@app.post("/api/pull/{url_idx}")
async def pull_model(
form_data: ModelNameForm, url_idx: int = 0, user=Depends(get_admin_user)
):
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/pull",
data=form_data.model_dump_json(exclude_none=True).encode(),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class PushModelForm(BaseModel):
name: str
insecure: Optional[bool] = None
stream: Optional[bool] = None
@app.delete("/api/push")
@app.delete("/api/push/{url_idx}")
async def push_model(
form_data: PushModelForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/push",
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class CreateModelForm(BaseModel):
name: str
modelfile: Optional[str] = None
stream: Optional[bool] = None
path: Optional[str] = None
@app.post("/api/create")
@app.post("/api/create/{url_idx}")
async def create_model(
form_data: CreateModelForm, url_idx: int = 0, user=Depends(get_admin_user)
):
print(form_data)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/create",
data=form_data.model_dump_json(exclude_none=True).encode(),
stream=True,
)
r.raise_for_status()
print(r)
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class CopyModelForm(BaseModel):
source: str
destination: str
@app.post("/api/copy")
@app.post("/api/copy/{url_idx}")
async def copy_model(
form_data: CopyModelForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.source in app.state.MODELS:
url_idx = app.state.MODELS[form_data.source]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/copy",
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
print(r.text)
return True
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.delete("/api/delete")
@app.delete("/api/delete/{url_idx}")
async def delete_model(
form_data: ModelNameForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="DELETE",
url=f"{url}/api/delete",
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
print(r.text)
return True
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.post("/api/show")
async def show_model_info(form_data: ModelNameForm, user=Depends(get_current_user)):
if form_data.name not in app.state.MODELS:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url_idx = random.choice(app.state.MODELS[form_data.name]["urls"])
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/show",
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class GenerateEmbeddingsForm(BaseModel):
model: str
prompt: str
options: Optional[dict] = None
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/embeddings")
@app.post("/api/embeddings/{url_idx}")
async def generate_embeddings(
form_data: GenerateEmbeddingsForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/embeddings",
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class GenerateCompletionForm(BaseModel):
model: str
prompt: str
images: Optional[List[str]] = None
format: Optional[str] = None
options: Optional[dict] = None
system: Optional[str] = None
template: Optional[str] = None
context: Optional[str] = None
stream: Optional[bool] = True
raw: Optional[bool] = None
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/generate")
@app.post("/api/generate/{url_idx}")
async def generate_completion(
form_data: GenerateCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail="error_detail",
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/api/generate",
data=form_data.model_dump_json(exclude_none=True).encode(),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class ChatMessage(BaseModel):
role: str
content: str
images: Optional[List[str]] = None
class GenerateChatCompletionForm(BaseModel):
model: str
messages: List[ChatMessage]
format: Optional[str] = None
options: Optional[dict] = None
template: Optional[str] = None
stream: Optional[bool] = None
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/chat")
@app.post("/api/chat/{url_idx}")
async def generate_chat_completion(
form_data: GenerateChatCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
print(form_data.model_dump_json(exclude_none=True).encode())
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/api/chat",
data=form_data.model_dump_json(exclude_none=True).encode(),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
print(e)
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
# TODO: we should update this part once Ollama supports other types
class OpenAIChatMessage(BaseModel):
role: str
content: str
model_config = ConfigDict(extra="allow")
class OpenAIChatCompletionForm(BaseModel):
model: str
messages: List[OpenAIChatMessage]
model_config = ConfigDict(extra="allow")
@app.post("/v1/chat/completions")
@app.post("/v1/chat/completions/{url_idx}")
async def generate_openai_chat_completion(
form_data: OpenAIChatCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps(
{"request_id": request_id, "done": False}
) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/v1/chat/completions",
data=form_data.model_dump_json(exclude_none=True).encode(),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_current_user)): async def deprecated_proxy(path: str, request: Request, user=Depends(get_current_user)):
target_url = f"{app.state.OLLAMA_API_BASE_URL}/{path}" url = app.state.OLLAMA_BASE_URLS[0]
target_url = f"{url}/{path}"
body = await request.body() body = await request.body()
headers = dict(request.headers) headers = dict(request.headers)
@ -91,7 +934,13 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)):
def stream_content(): def stream_content():
try: try:
if path in ["chat"]: if path == "generate":
data = json.loads(body.decode("utf-8"))
if not ("stream" in data and data["stream"] == False):
yield json.dumps({"id": request_id, "done": False}) + "\n"
elif path == "chat":
yield json.dumps({"id": request_id, "done": False}) + "\n" yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
@ -103,6 +952,7 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)):
finally: finally:
if hasattr(r, "close"): if hasattr(r, "close"):
r.close() r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id) REQUEST_POOL.remove(request_id)
r = requests.request( r = requests.request(

View file

@ -1,127 +0,0 @@
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
import requests
import json
from pydantic import BaseModel
from apps.web.models.users import Users
from constants import ERROR_MESSAGES
from utils.utils import decode_token, get_current_user
from config import OLLAMA_API_BASE_URL, WEBUI_AUTH
import aiohttp
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.state.OLLAMA_API_BASE_URL = OLLAMA_API_BASE_URL
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL
@app.get("/url")
async def get_ollama_api_url(user=Depends(get_current_user)):
if user and user.role == "admin":
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL}
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
class UrlUpdateForm(BaseModel):
url: str
@app.post("/url/update")
async def update_ollama_api_url(
form_data: UrlUpdateForm, user=Depends(get_current_user)
):
if user and user.role == "admin":
app.state.OLLAMA_API_BASE_URL = form_data.url
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL}
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
# async def fetch_sse(method, target_url, body, headers):
# async with aiohttp.ClientSession() as session:
# try:
# async with session.request(
# method, target_url, data=body, headers=headers
# ) as response:
# print(response.status)
# async for line in response.content:
# yield line
# except Exception as e:
# print(e)
# error_detail = "Open WebUI: Server Connection Error"
# yield json.dumps({"error": error_detail, "message": str(e)}).encode()
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_current_user)):
target_url = f"{app.state.OLLAMA_API_BASE_URL}/{path}"
print(target_url)
body = await request.body()
headers = dict(request.headers)
if user.role in ["user", "admin"]:
if path in ["pull", "delete", "push", "copy", "create"]:
if user.role != "admin":
raise HTTPException(
status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
headers.pop("Host", None)
headers.pop("Authorization", None)
headers.pop("Origin", None)
headers.pop("Referer", None)
session = aiohttp.ClientSession()
response = None
try:
response = await session.request(
request.method, target_url, data=body, headers=headers
)
print(response)
if not response.ok:
data = await response.json()
print(data)
response.raise_for_status()
async def generate():
async for line in response.content:
print(line)
yield line
await session.close()
return StreamingResponse(generate(), response.status)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if response is not None:
try:
res = await response.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
await session.close()
raise HTTPException(
status_code=response.status if response else 500,
detail=error_detail,
)

View file

@ -3,7 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, JSONResponse, FileResponse from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
import requests import requests
import aiohttp
import asyncio
import json import json
from pydantic import BaseModel from pydantic import BaseModel
@ -15,7 +18,15 @@ from utils.utils import (
get_verified_user, get_verified_user,
get_admin_user, get_admin_user,
) )
from config import OPENAI_API_BASE_URL, OPENAI_API_KEY, CACHE_DIR from config import (
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
CACHE_DIR,
MODEL_FILTER_ENABLED,
MODEL_FILTER_LIST,
)
from typing import List, Optional
import hashlib import hashlib
from pathlib import Path from pathlib import Path
@ -29,49 +40,62 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.state.OPENAI_API_BASE_URL = OPENAI_API_BASE_URL app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED
app.state.OPENAI_API_KEY = OPENAI_API_KEY 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.MODELS = {}
class UrlUpdateForm(BaseModel): @app.middleware("http")
url: str async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
else:
pass
response = await call_next(request)
return response
class KeyUpdateForm(BaseModel): class UrlsUpdateForm(BaseModel):
key: str urls: List[str]
@app.get("/url") class KeysUpdateForm(BaseModel):
async def get_openai_url(user=Depends(get_admin_user)): keys: List[str]
return {"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL}
@app.post("/url/update") @app.get("/urls")
async def update_openai_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): async def get_openai_urls(user=Depends(get_admin_user)):
app.state.OPENAI_API_BASE_URL = form_data.url return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS}
return {"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL}
@app.get("/key") @app.post("/urls/update")
async def get_openai_key(user=Depends(get_admin_user)): async def update_openai_urls(form_data: UrlsUpdateForm, user=Depends(get_admin_user)):
return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY} app.state.OPENAI_API_BASE_URLS = form_data.urls
return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS}
@app.post("/key/update") @app.get("/keys")
async def update_openai_key(form_data: KeyUpdateForm, user=Depends(get_admin_user)): async def get_openai_keys(user=Depends(get_admin_user)):
app.state.OPENAI_API_KEY = form_data.key return {"OPENAI_API_KEYS": app.state.OPENAI_API_KEYS}
return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY}
@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.post("/audio/speech") @app.post("/audio/speech")
async def speech(request: Request, user=Depends(get_verified_user)): async def speech(request: Request, user=Depends(get_verified_user)):
target_url = f"{app.state.OPENAI_API_BASE_URL}/audio/speech" idx = None
try:
if app.state.OPENAI_API_KEY == "": idx = app.state.OPENAI_API_BASE_URLS.index("https://api.openai.com/v1")
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
body = await request.body() body = await request.body()
name = hashlib.sha256(body).hexdigest() name = hashlib.sha256(body).hexdigest()
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
@ -84,13 +108,13 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path) return FileResponse(file_path)
headers = {} headers = {}
headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}" headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEYS[idx]}"
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
r = None
try: try:
print("openai")
r = requests.post( r = requests.post(
url=target_url, url=f"{app.state.OPENAI_API_BASE_URLS[idx]}/audio/speech",
data=body, data=body,
headers=headers, headers=headers,
stream=True, stream=True,
@ -120,25 +144,137 @@ async def speech(request: Request, user=Depends(get_verified_user)):
except: except:
error_detail = f"External: {e}" error_detail = f"External: {e}"
raise HTTPException(status_code=r.status_code, detail=error_detail) raise HTTPException(
status_code=r.status_code if r else 500, detail=error_detail
)
except ValueError:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND)
async def fetch_url(url, key):
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()
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
return None
def merge_models_lists(model_lists):
merged_list = []
for idx, models in enumerate(model_lists):
if models is not None and "error" not in models:
merged_list.extend(
[
{**model, "urlIdx": idx}
for model in models
if "api.openai.com" not in app.state.OPENAI_API_BASE_URLS[idx]
or "gpt" in model["id"]
]
)
return merged_list
async def get_all_models():
print("get_all_models")
if len(app.state.OPENAI_API_KEYS) == 1 and app.state.OPENAI_API_KEYS[0] == "":
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)
]
responses = await asyncio.gather(*tasks)
models = {
"data": merge_models_lists(
list(
map(
lambda response: (
response["data"]
if response and "data" in response
else None
),
responses,
)
)
)
}
print(models)
app.state.MODELS = {model["id"]: model for model in models["data"]}
return models
@app.get("/models")
@app.get("/models/{url_idx}")
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.MODEL_FILTER_ENABLED:
if user.role == "user":
models["data"] = list(
filter(
lambda model: model["id"] in app.state.MODEL_FILTER_LIST,
models["data"],
)
)
return models
return models
else:
url = app.state.OPENAI_API_BASE_URLS[url_idx]
r = None
try:
r = requests.request(method="GET", url=f"{url}/models")
r.raise_for_status()
response_data = r.json()
if "api.openai.com" in url:
response_data["data"] = list(
filter(lambda model: "gpt" in model["id"], response_data["data"])
)
return response_data
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"External: {res['error']}"
except:
error_detail = f"External: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_verified_user)): async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
target_url = f"{app.state.OPENAI_API_BASE_URL}/{path}" idx = 0
print(target_url, app.state.OPENAI_API_KEY)
if app.state.OPENAI_API_KEY == "":
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
body = await request.body() body = await request.body()
# TODO: Remove below after gpt-4-vision fix from Open AI # TODO: Remove below after gpt-4-vision fix from Open AI
# Try to decode the body of the request from bytes to a UTF-8 string (Require add max_token to fix gpt-4-vision) # Try to decode the body of the request from bytes to a UTF-8 string (Require add max_token to fix gpt-4-vision)
try: try:
body = body.decode("utf-8") body = body.decode("utf-8")
body = json.loads(body) body = json.loads(body)
idx = app.state.MODELS[body.get("model")]["urlIdx"]
# Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000 # Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000
# This is a workaround until OpenAI fixes the issue with this model # This is a workaround until OpenAI fixes the issue with this model
if body.get("model") == "gpt-4-vision-preview": if body.get("model") == "gpt-4-vision-preview":
@ -146,15 +282,32 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
body["max_tokens"] = 4000 body["max_tokens"] = 4000
print("Modified body_dict:", body) print("Modified body_dict:", body)
# Fix for ChatGPT calls failing because the num_ctx key is in body
if "num_ctx" in body:
# If 'num_ctx' is in the dictionary, delete it
# Leaving it there generates an error with the
# OpenAI API (Feb 2024)
del body["num_ctx"]
# Convert the modified body back to JSON # Convert the modified body back to JSON
body = json.dumps(body) body = json.dumps(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print("Error loading request body into a dictionary:", e) print("Error loading request body into a dictionary:", e)
url = app.state.OPENAI_API_BASE_URLS[idx]
key = app.state.OPENAI_API_KEYS[idx]
target_url = f"{url}/{path}"
if key == "":
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
headers = {} headers = {}
headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}" headers["Authorization"] = f"Bearer {key}"
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
r = None
try: try:
r = requests.request( r = requests.request(
method=request.method, method=request.method,
@ -174,21 +327,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=dict(r.headers), headers=dict(r.headers),
) )
else: else:
# For non-SSE, read the response and return it
# response_data = (
# r.json()
# if r.headers.get("Content-Type", "")
# == "application/json"
# else r.text
# )
response_data = r.json() response_data = r.json()
if "openai" in app.state.OPENAI_API_BASE_URL and path == "models":
response_data["data"] = list(
filter(lambda model: "gpt" in model["id"], response_data["data"])
)
return response_data return response_data
except Exception as e: except Exception as e:
print(e) print(e)
@ -201,4 +340,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
except: except:
error_detail = f"External: {e}" error_detail = f"External: {e}"
raise HTTPException(status_code=r.status_code, detail=error_detail) raise HTTPException(
status_code=r.status_code if r else 500, detail=error_detail
)

View file

@ -1,6 +1,5 @@
from fastapi import ( from fastapi import (
FastAPI, FastAPI,
Request,
Depends, Depends,
HTTPException, HTTPException,
status, status,
@ -10,9 +9,12 @@ from fastapi import (
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import os, shutil import os, shutil
from pathlib import Path
from typing import List from typing import List
# from chromadb.utils import embedding_functions from sentence_transformers import SentenceTransformer
from chromadb.utils import embedding_functions
from langchain_community.document_loaders import ( from langchain_community.document_loaders import (
WebBaseLoader, WebBaseLoader,
@ -28,27 +30,68 @@ from langchain_community.document_loaders import (
UnstructuredExcelLoader, UnstructuredExcelLoader,
) )
from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import mimetypes
import uuid import uuid
import time import json
from utils.misc import calculate_sha256, calculate_sha256_string
from apps.web.models.documents import (
Documents,
DocumentForm,
DocumentResponse,
)
from apps.rag.utils import query_doc, query_collection
from utils.misc import (
calculate_sha256,
calculate_sha256_string,
sanitize_filename,
extract_folders_after_data_docs,
)
from utils.utils import get_current_user, get_admin_user from utils.utils import get_current_user, get_admin_user
from config import UPLOAD_DIR, EMBED_MODEL, CHROMA_CLIENT, CHUNK_SIZE, CHUNK_OVERLAP from config import (
UPLOAD_DIR,
DOCS_DIR,
RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_DEVICE_TYPE,
CHROMA_CLIENT,
CHUNK_SIZE,
CHUNK_OVERLAP,
RAG_TEMPLATE,
)
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
# EMBEDDING_FUNC = embedding_functions.SentenceTransformerEmbeddingFunction( #
# model_name=EMBED_MODEL # if RAG_EMBEDDING_MODEL:
# sentence_transformer_ef = SentenceTransformer(
# model_name_or_path=RAG_EMBEDDING_MODEL,
# cache_folder=RAG_EMBEDDING_MODEL_DIR,
# device=RAG_EMBEDDING_MODEL_DEVICE_TYPE,
# ) # )
app = FastAPI() app = FastAPI()
app.state.PDF_EXTRACT_IMAGES = False
app.state.CHUNK_SIZE = CHUNK_SIZE
app.state.CHUNK_OVERLAP = CHUNK_OVERLAP
app.state.RAG_TEMPLATE = RAG_TEMPLATE
app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.TOP_K = 4
app.state.sentence_transformer_ef = (
embedding_functions.SentenceTransformerEmbeddingFunction(
model_name=app.state.RAG_EMBEDDING_MODEL,
device=RAG_EMBEDDING_MODEL_DEVICE_TYPE,
)
)
origins = ["*"] origins = ["*"]
app.add_middleware( app.add_middleware(
@ -68,9 +111,9 @@ class StoreWebForm(CollectionNameForm):
url: str url: str
def store_data_in_vector_db(data, collection_name) -> bool: def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool:
text_splitter = RecursiveCharacterTextSplitter( text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP chunk_size=app.state.CHUNK_SIZE, chunk_overlap=app.state.CHUNK_OVERLAP
) )
docs = text_splitter.split_documents(data) docs = text_splitter.split_documents(data)
@ -78,7 +121,16 @@ def store_data_in_vector_db(data, collection_name) -> bool:
metadatas = [doc.metadata for doc in docs] metadatas = [doc.metadata for doc in docs]
try: try:
collection = CHROMA_CLIENT.create_collection(name=collection_name) if overwrite:
for collection in CHROMA_CLIENT.list_collections():
if collection_name == collection.name:
print(f"deleting existing collection {collection_name}")
CHROMA_CLIENT.delete_collection(name=collection_name)
collection = CHROMA_CLIENT.create_collection(
name=collection_name,
embedding_function=app.state.sentence_transformer_ef,
)
collection.add( collection.add(
documents=texts, metadatas=metadatas, ids=[str(uuid.uuid1()) for _ in texts] documents=texts, metadatas=metadatas, ids=[str(uuid.uuid1()) for _ in texts]
@ -94,26 +146,133 @@ def store_data_in_vector_db(data, collection_name) -> bool:
@app.get("/") @app.get("/")
async def get_status(): async def get_status():
return {"status": True} return {
"status": True,
"chunk_size": app.state.CHUNK_SIZE,
"chunk_overlap": app.state.CHUNK_OVERLAP,
"template": app.state.RAG_TEMPLATE,
"embedding_model": app.state.RAG_EMBEDDING_MODEL,
}
@app.get("/embedding/model")
async def get_embedding_model(user=Depends(get_admin_user)):
return {
"status": True,
"embedding_model": app.state.RAG_EMBEDDING_MODEL,
}
class EmbeddingModelUpdateForm(BaseModel):
embedding_model: str
@app.post("/embedding/model/update")
async def update_embedding_model(
form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)
):
app.state.RAG_EMBEDDING_MODEL = form_data.embedding_model
app.state.sentence_transformer_ef = (
embedding_functions.SentenceTransformerEmbeddingFunction(
model_name=app.state.RAG_EMBEDDING_MODEL,
device=RAG_EMBEDDING_MODEL_DEVICE_TYPE,
)
)
return {
"status": True,
"embedding_model": app.state.RAG_EMBEDDING_MODEL,
}
@app.get("/config")
async def get_rag_config(user=Depends(get_admin_user)):
return {
"status": True,
"pdf_extract_images": app.state.PDF_EXTRACT_IMAGES,
"chunk": {
"chunk_size": app.state.CHUNK_SIZE,
"chunk_overlap": app.state.CHUNK_OVERLAP,
},
}
class ChunkParamUpdateForm(BaseModel):
chunk_size: int
chunk_overlap: int
class ConfigUpdateForm(BaseModel):
pdf_extract_images: bool
chunk: ChunkParamUpdateForm
@app.post("/config/update")
async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_user)):
app.state.PDF_EXTRACT_IMAGES = form_data.pdf_extract_images
app.state.CHUNK_SIZE = form_data.chunk.chunk_size
app.state.CHUNK_OVERLAP = form_data.chunk.chunk_overlap
return {
"status": True,
"pdf_extract_images": app.state.PDF_EXTRACT_IMAGES,
"chunk": {
"chunk_size": app.state.CHUNK_SIZE,
"chunk_overlap": app.state.CHUNK_OVERLAP,
},
}
@app.get("/template")
async def get_rag_template(user=Depends(get_current_user)):
return {
"status": True,
"template": app.state.RAG_TEMPLATE,
}
@app.get("/query/settings")
async def get_query_settings(user=Depends(get_admin_user)):
return {
"status": True,
"template": app.state.RAG_TEMPLATE,
"k": app.state.TOP_K,
}
class QuerySettingsForm(BaseModel):
k: Optional[int] = None
template: Optional[str] = None
@app.post("/query/settings/update")
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
return {"status": True, "template": app.state.RAG_TEMPLATE}
class QueryDocForm(BaseModel): class QueryDocForm(BaseModel):
collection_name: str collection_name: str
query: str query: str
k: Optional[int] = 4 k: Optional[int] = None
@app.post("/query/doc") @app.post("/query/doc")
def query_doc( def query_doc_handler(
form_data: QueryDocForm, form_data: QueryDocForm,
user=Depends(get_current_user), user=Depends(get_current_user),
): ):
try: try:
collection = CHROMA_CLIENT.get_collection( return query_doc(
name=form_data.collection_name, collection_name=form_data.collection_name,
query=form_data.query,
k=form_data.k if form_data.k else app.state.TOP_K,
embedding_function=app.state.sentence_transformer_ef,
) )
result = collection.query(query_texts=[form_data.query], n_results=form_data.k)
return result
except Exception as e: except Exception as e:
print(e) print(e)
raise HTTPException( raise HTTPException(
@ -125,74 +284,20 @@ def query_doc(
class QueryCollectionsForm(BaseModel): class QueryCollectionsForm(BaseModel):
collection_names: List[str] collection_names: List[str]
query: str query: str
k: Optional[int] = 4 k: Optional[int] = None
def merge_and_sort_query_results(query_results, k):
# Initialize lists to store combined data
combined_ids = []
combined_distances = []
combined_metadatas = []
combined_documents = []
# Combine data from each dictionary
for data in query_results:
combined_ids.extend(data["ids"][0])
combined_distances.extend(data["distances"][0])
combined_metadatas.extend(data["metadatas"][0])
combined_documents.extend(data["documents"][0])
# Create a list of tuples (distance, id, metadata, document)
combined = list(
zip(combined_distances, combined_ids, combined_metadatas, combined_documents)
)
# Sort the list based on distances
combined.sort(key=lambda x: x[0])
# Unzip the sorted list
sorted_distances, sorted_ids, sorted_metadatas, sorted_documents = zip(*combined)
# Slicing the lists to include only k elements
sorted_distances = list(sorted_distances)[:k]
sorted_ids = list(sorted_ids)[:k]
sorted_metadatas = list(sorted_metadatas)[:k]
sorted_documents = list(sorted_documents)[:k]
# Create the output dictionary
merged_query_results = {
"ids": [sorted_ids],
"distances": [sorted_distances],
"metadatas": [sorted_metadatas],
"documents": [sorted_documents],
"embeddings": None,
"uris": None,
"data": None,
}
return merged_query_results
@app.post("/query/collection") @app.post("/query/collection")
def query_collection( def query_collection_handler(
form_data: QueryCollectionsForm, form_data: QueryCollectionsForm,
user=Depends(get_current_user), user=Depends(get_current_user),
): ):
results = [] return query_collection(
collection_names=form_data.collection_names,
for collection_name in form_data.collection_names: query=form_data.query,
try: k=form_data.k if form_data.k else app.state.TOP_K,
collection = CHROMA_CLIENT.get_collection( embedding_function=app.state.sentence_transformer_ef,
name=collection_name,
) )
result = collection.query(
query_texts=[form_data.query], n_results=form_data.k
)
results.append(result)
except:
pass
return merge_and_sort_query_results(results, form_data.k)
@app.post("/web") @app.post("/web")
@ -206,7 +311,7 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)):
if collection_name == "": if collection_name == "":
collection_name = calculate_sha256_string(form_data.url)[:63] collection_name = calculate_sha256_string(form_data.url)[:63]
store_data_in_vector_db(data, collection_name) store_data_in_vector_db(data, collection_name, overwrite=True)
return { return {
"status": True, "status": True,
"collection_name": collection_name, "collection_name": collection_name,
@ -220,8 +325,8 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)):
) )
def get_loader(file, file_path): def get_loader(filename: str, file_content_type: str, file_path: str):
file_ext = file.filename.split(".")[-1].lower() file_ext = filename.split(".")[-1].lower()
known_type = True known_type = True
known_source_ext = [ known_source_ext = [
@ -270,7 +375,7 @@ def get_loader(file, file_path):
] ]
if file_ext == "pdf": if file_ext == "pdf":
loader = PyPDFLoader(file_path) loader = PyPDFLoader(file_path, extract_images=app.state.PDF_EXTRACT_IMAGES)
elif file_ext == "csv": elif file_ext == "csv":
loader = CSVLoader(file_path) loader = CSVLoader(file_path)
elif file_ext == "rst": elif file_ext == "rst":
@ -279,23 +384,25 @@ def get_loader(file, file_path):
loader = UnstructuredXMLLoader(file_path) loader = UnstructuredXMLLoader(file_path)
elif file_ext == "md": elif file_ext == "md":
loader = UnstructuredMarkdownLoader(file_path) loader = UnstructuredMarkdownLoader(file_path)
elif file.content_type == "application/epub+zip": elif file_content_type == "application/epub+zip":
loader = UnstructuredEPubLoader(file_path) loader = UnstructuredEPubLoader(file_path)
elif ( elif (
file.content_type file_content_type
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document" == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
or file_ext in ["doc", "docx"] or file_ext in ["doc", "docx"]
): ):
loader = Docx2txtLoader(file_path) loader = Docx2txtLoader(file_path)
elif file.content_type in [ elif file_content_type in [
"application/vnd.ms-excel", "application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
] or file_ext in ["xls", "xlsx"]: ] or file_ext in ["xls", "xlsx"]:
loader = UnstructuredExcelLoader(file_path) loader = UnstructuredExcelLoader(file_path)
elif file_ext in known_source_ext or file.content_type.find("text/") >= 0: elif file_ext in known_source_ext or (
loader = TextLoader(file_path) file_content_type and file_content_type.find("text/") >= 0
):
loader = TextLoader(file_path, autodetect_encoding=True)
else: else:
loader = TextLoader(file_path) loader = TextLoader(file_path, autodetect_encoding=True)
known_type = False known_type = False
return loader, known_type return loader, known_type
@ -323,7 +430,7 @@ def store_doc(
collection_name = calculate_sha256(f)[:63] collection_name = calculate_sha256(f)[:63]
f.close() f.close()
loader, known_type = get_loader(file, file_path) loader, known_type = get_loader(file.filename, file.content_type, file_path)
data = loader.load() data = loader.load()
result = store_data_in_vector_db(data, collection_name) result = store_data_in_vector_db(data, collection_name)
@ -353,6 +460,63 @@ def store_doc(
) )
@app.get("/scan")
def scan_docs_dir(user=Depends(get_admin_user)):
for path in Path(DOCS_DIR).rglob("./**/*"):
try:
if path.is_file() and not path.name.startswith("."):
tags = extract_folders_after_data_docs(path)
filename = path.name
file_content_type = mimetypes.guess_type(path)
f = open(path, "rb")
collection_name = calculate_sha256(f)[:63]
f.close()
loader, known_type = get_loader(
filename, file_content_type[0], str(path)
)
data = loader.load()
result = store_data_in_vector_db(data, collection_name)
if result:
sanitized_filename = sanitize_filename(filename)
doc = Documents.get_doc_by_name(sanitized_filename)
if doc == None:
doc = Documents.insert_new_doc(
user.id,
DocumentForm(
**{
"name": sanitized_filename,
"title": filename,
"collection_name": collection_name,
"filename": filename,
"content": (
json.dumps(
{
"tags": list(
map(
lambda name: {"name": name},
tags,
)
)
}
)
if len(tags)
else "{}"
),
}
),
)
except Exception as e:
print(e)
return True
@app.get("/reset/db") @app.get("/reset/db")
def reset_vector_db(user=Depends(get_admin_user)): def reset_vector_db(user=Depends(get_admin_user)):
CHROMA_CLIENT.reset() CHROMA_CLIENT.reset()

182
backend/apps/rag/utils.py Normal file
View file

@ -0,0 +1,182 @@
import re
from typing import List
from config import CHROMA_CLIENT
def query_doc(collection_name: str, query: str, k: int, embedding_function):
try:
# if you use docker use the model from the environment variable
collection = CHROMA_CLIENT.get_collection(
name=collection_name,
embedding_function=embedding_function,
)
result = collection.query(
query_texts=[query],
n_results=k,
)
return result
except Exception as e:
raise e
def merge_and_sort_query_results(query_results, k):
# Initialize lists to store combined data
combined_ids = []
combined_distances = []
combined_metadatas = []
combined_documents = []
# Combine data from each dictionary
for data in query_results:
combined_ids.extend(data["ids"][0])
combined_distances.extend(data["distances"][0])
combined_metadatas.extend(data["metadatas"][0])
combined_documents.extend(data["documents"][0])
# Create a list of tuples (distance, id, metadata, document)
combined = list(
zip(combined_distances, combined_ids, combined_metadatas, combined_documents)
)
# Sort the list based on distances
combined.sort(key=lambda x: x[0])
# Unzip the sorted list
sorted_distances, sorted_ids, sorted_metadatas, sorted_documents = zip(*combined)
# Slicing the lists to include only k elements
sorted_distances = list(sorted_distances)[:k]
sorted_ids = list(sorted_ids)[:k]
sorted_metadatas = list(sorted_metadatas)[:k]
sorted_documents = list(sorted_documents)[:k]
# Create the output dictionary
merged_query_results = {
"ids": [sorted_ids],
"distances": [sorted_distances],
"metadatas": [sorted_metadatas],
"documents": [sorted_documents],
"embeddings": None,
"uris": None,
"data": None,
}
return merged_query_results
def query_collection(
collection_names: List[str], query: str, k: int, embedding_function
):
results = []
for collection_name in collection_names:
try:
# if you use docker use the model from the environment variable
collection = CHROMA_CLIENT.get_collection(
name=collection_name,
embedding_function=embedding_function,
)
result = collection.query(
query_texts=[query],
n_results=k,
)
results.append(result)
except:
pass
return merge_and_sort_query_results(results, k)
def rag_template(template: str, context: str, query: str):
template = template.replace("[context]", context)
template = template.replace("[query]", query)
return template
def rag_messages(docs, messages, template, k, embedding_function):
print(docs)
last_user_message_idx = None
for i in range(len(messages) - 1, -1, -1):
if messages[i]["role"] == "user":
last_user_message_idx = i
break
user_message = messages[last_user_message_idx]
if isinstance(user_message["content"], list):
# Handle list content input
content_type = "list"
query = ""
for content_item in user_message["content"]:
if content_item["type"] == "text":
query = content_item["text"]
break
elif isinstance(user_message["content"], str):
# Handle text content input
content_type = "text"
query = user_message["content"]
else:
# Fallback in case the input does not match expected types
content_type = None
query = ""
relevant_contexts = []
for doc in docs:
context = None
try:
if doc["type"] == "collection":
context = query_collection(
collection_names=doc["collection_names"],
query=query,
k=k,
embedding_function=embedding_function,
)
else:
context = query_doc(
collection_name=doc["collection_name"],
query=query,
k=k,
embedding_function=embedding_function,
)
except Exception as e:
print(e)
context = None
relevant_contexts.append(context)
context_string = ""
for context in relevant_contexts:
if context:
context_string += " ".join(context["documents"][0]) + "\n"
ra_content = rag_template(
template=template,
context=context_string,
query=query,
)
if content_type == "list":
new_content = []
for content_item in user_message["content"]:
if content_item["type"] == "text":
# Update the text item's content with ra_content
new_content.append({"type": "text", "text": ra_content})
else:
# Keep other types of content as they are
new_content.append(content_item)
new_user_message = {**user_message, "content": new_content}
else:
new_user_message = {
**user_message,
"content": ra_content,
}
messages[last_user_message_idx] = new_user_message
return messages

View file

@ -1,6 +1,16 @@
from peewee import * from peewee import *
from config import DATA_DIR from config import DATA_DIR
import os
DB = SqliteDatabase(f"{DATA_DIR}/ollama.db") # Check if the file exists
if os.path.exists(f"{DATA_DIR}/ollama.db"):
# Rename the file
os.rename(f"{DATA_DIR}/ollama.db", f"{DATA_DIR}/webui.db")
print("File renamed successfully.")
else:
pass
DB = SqliteDatabase(f"{DATA_DIR}/webui.db")
DB.connect() DB.connect()

View file

@ -19,6 +19,7 @@ from config import (
DEFAULT_USER_ROLE, DEFAULT_USER_ROLE,
ENABLE_SIGNUP, ENABLE_SIGNUP,
USER_PERMISSIONS, USER_PERMISSIONS,
WEBHOOK_URL,
) )
app = FastAPI() app = FastAPI()
@ -26,10 +27,13 @@ app = FastAPI()
origins = ["*"] origins = ["*"]
app.state.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.JWT_EXPIRES_IN = "-1"
app.state.DEFAULT_MODELS = DEFAULT_MODELS app.state.DEFAULT_MODELS = DEFAULT_MODELS
app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
app.state.USER_PERMISSIONS = USER_PERMISSIONS app.state.USER_PERMISSIONS = USER_PERMISSIONS
app.state.WEBHOOK_URL = WEBHOOK_URL
app.add_middleware( app.add_middleware(
@ -55,7 +59,6 @@ app.include_router(utils.router, prefix="/utils", tags=["utils"])
async def get_status(): async def get_status():
return { return {
"status": True, "status": True,
"version": WEBUI_VERSION,
"auth": WEBUI_AUTH, "auth": WEBUI_AUTH,
"default_models": app.state.DEFAULT_MODELS, "default_models": app.state.DEFAULT_MODELS,
"default_prompt_suggestions": app.state.DEFAULT_PROMPT_SUGGESTIONS, "default_prompt_suggestions": app.state.DEFAULT_PROMPT_SUGGESTIONS,

View file

@ -167,6 +167,27 @@ class TagTable:
.count() .count()
) )
def delete_tag_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> bool:
try:
query = ChatIdTag.delete().where(
(ChatIdTag.tag_name == tag_name) & (ChatIdTag.user_id == user_id)
)
res = query.execute() # Remove the rows, return number of rows removed.
print(res)
tag_count = self.count_chat_ids_by_tag_name_and_user_id(tag_name, user_id)
if tag_count == 0:
# Remove tag item from Tag col as well
query = Tag.delete().where(
(Tag.name == tag_name) & (Tag.user_id == user_id)
)
query.execute() # Remove the rows, return number of rows removed.
return True
except Exception as e:
print("delete_tag", e)
return False
def delete_tag_by_tag_name_and_chat_id_and_user_id( def delete_tag_by_tag_name_and_chat_id_and_user_id(
self, tag_name: str, chat_id: str, user_id: str self, tag_name: str, chat_id: str, user_id: str
) -> bool: ) -> bool:

View file

@ -7,6 +7,7 @@ from fastapi import APIRouter, status
from pydantic import BaseModel from pydantic import BaseModel
import time import time
import uuid import uuid
import re
from apps.web.models.auths import ( from apps.web.models.auths import (
SigninForm, SigninForm,
@ -25,8 +26,9 @@ from utils.utils import (
get_admin_user, get_admin_user,
create_token, create_token,
) )
from utils.misc import get_gravatar_url, validate_email_format from utils.misc import parse_duration, validate_email_format
from constants import ERROR_MESSAGES from utils.webhook import post_webhook
from constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
router = APIRouter() router = APIRouter()
@ -95,10 +97,13 @@ async def update_password(
@router.post("/signin", response_model=SigninResponse) @router.post("/signin", response_model=SigninResponse)
async def signin(form_data: SigninForm): async def signin(request: Request, form_data: SigninForm):
user = Auths.authenticate_user(form_data.email.lower(), form_data.password) user = Auths.authenticate_user(form_data.email.lower(), form_data.password)
if user: if user:
token = create_token(data={"id": user.id}) token = create_token(
data={"id": user.id},
expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN),
)
return { return {
"token": token, "token": token,
@ -145,9 +150,23 @@ async def signup(request: Request, form_data: SignupForm):
) )
if user: if user:
token = create_token(data={"id": user.id}) token = create_token(
data={"id": user.id},
expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN),
)
# response.set_cookie(key='token', value=token, httponly=True) # response.set_cookie(key='token', value=token, httponly=True)
if request.app.state.WEBHOOK_URL:
post_webhook(
request.app.state.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
return { return {
"token": token, "token": token,
"token_type": "Bearer", "token_type": "Bearer",
@ -200,3 +219,33 @@ async def update_default_user_role(
if form_data.role in ["pending", "user", "admin"]: if form_data.role in ["pending", "user", "admin"]:
request.app.state.DEFAULT_USER_ROLE = form_data.role request.app.state.DEFAULT_USER_ROLE = form_data.role
return request.app.state.DEFAULT_USER_ROLE return request.app.state.DEFAULT_USER_ROLE
############################
# JWT Expiration
############################
@router.get("/token/expires")
async def get_token_expires_duration(request: Request, user=Depends(get_admin_user)):
return request.app.state.JWT_EXPIRES_IN
class UpdateJWTExpiresDurationForm(BaseModel):
duration: str
@router.post("/token/expires/update")
async def update_token_expires_duration(
request: Request,
form_data: UpdateJWTExpiresDurationForm,
user=Depends(get_admin_user),
):
pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$"
# 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
else:
return request.app.state.JWT_EXPIRES_IN

View file

@ -115,9 +115,12 @@ async def get_user_chats_by_tag_name(
for chat_id_tag in Tags.get_chat_ids_by_tag_name_and_user_id(tag_name, user.id) for chat_id_tag in Tags.get_chat_ids_by_tag_name_and_user_id(tag_name, user.id)
] ]
print(chat_ids) chats = Chats.get_chat_lists_by_chat_ids(chat_ids, skip, limit)
return Chats.get_chat_lists_by_chat_ids(chat_ids, skip, limit) if len(chats) == 0:
Tags.delete_tag_by_tag_name_and_user_id(tag_name, user.id)
return chats
############################ ############################
@ -268,6 +271,16 @@ async def delete_all_chat_tags_by_id(id: str, user=Depends(get_current_user)):
@router.delete("/", response_model=bool) @router.delete("/", response_model=bool)
async def delete_all_user_chats(user=Depends(get_current_user)): 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"]
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Chats.delete_chats_by_user_id(user.id) result = Chats.delete_chats_by_user_id(user.id)
return result return result

View file

@ -96,6 +96,10 @@ async def get_doc_by_name(name: str, user=Depends(get_current_user)):
############################ ############################
class TagItem(BaseModel):
name: str
class TagDocumentForm(BaseModel): class TagDocumentForm(BaseModel):
name: str name: str
tags: List[dict] tags: List[dict]

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, UploadFile, File, BackgroundTasks from fastapi import APIRouter, UploadFile, File, BackgroundTasks
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse, FileResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -9,9 +10,11 @@ import os
import aiohttp import aiohttp
import json import json
from utils.utils import get_admin_user
from utils.misc import calculate_sha256, get_gravatar_url from utils.misc import calculate_sha256, get_gravatar_url
from config import OLLAMA_API_BASE_URL, DATA_DIR, UPLOAD_DIR from config import OLLAMA_BASE_URLS, DATA_DIR, UPLOAD_DIR
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
@ -72,7 +75,7 @@ async def download_file_stream(url, file_path, file_name, chunk_size=1024 * 1024
hashed = calculate_sha256(file) hashed = calculate_sha256(file)
file.seek(0) file.seek(0)
url = f"{OLLAMA_API_BASE_URL}/blobs/sha256:{hashed}" url = f"{OLLAMA_BASE_URLS[0]}/api/blobs/sha256:{hashed}"
response = requests.post(url, data=file) response = requests.post(url, data=file)
if response.ok: if response.ok:
@ -144,7 +147,7 @@ def upload(file: UploadFile = File(...)):
hashed = calculate_sha256(f) hashed = calculate_sha256(f)
f.seek(0) f.seek(0)
url = f"{OLLAMA_API_BASE_URL}/blobs/sha256:{hashed}" url = f"{OLLAMA_BASE_URLS[0]}/blobs/sha256:{hashed}"
response = requests.post(url, data=f) response = requests.post(url, data=f)
if response.ok: if response.ok:
@ -172,3 +175,13 @@ async def get_gravatar(
email: str, email: str,
): ):
return get_gravatar_url(email) return get_gravatar_url(email)
@router.get("/db/download")
async def download_db(user=Depends(get_admin_user)):
return FileResponse(
f"{DATA_DIR}/webui.db",
media_type="application/octet-stream",
filename="webui.db",
)

View file

@ -1,10 +1,20 @@
import os import os
import chromadb import chromadb
from chromadb import Settings from chromadb import Settings
from secrets import token_bytes
from base64 import b64encode from base64 import b64encode
from constants import ERROR_MESSAGES from bs4 import BeautifulSoup
from pathlib import Path from pathlib import Path
import json
import yaml
import markdown
import requests
import shutil
from secrets import token_bytes
from constants import ERROR_MESSAGES
try: try:
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
@ -13,6 +23,8 @@ try:
except ImportError: except ImportError:
print("dotenv not installed, skipping...") print("dotenv not installed, skipping...")
WEBUI_NAME = "Open WebUI"
shutil.copyfile("../build/favicon.png", "./static/favicon.png")
#################################### ####################################
# ENV (dev,test,prod) # ENV (dev,test,prod)
@ -20,6 +32,102 @@ except ImportError:
ENV = os.environ.get("ENV", "dev") ENV = os.environ.get("ENV", "dev")
try:
with open(f"../package.json", "r") as f:
PACKAGE_DATA = json.load(f)
except:
PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"]
# Function to parse each section
def parse_section(section):
items = []
for li in section.find_all("li"):
# Extract raw HTML string
raw_html = str(li)
# Extract text without HTML tags
text = li.get_text(separator=" ", strip=True)
# Split into title and content
parts = text.split(": ", 1)
title = parts[0].strip() if len(parts) > 1 else ""
content = parts[1].strip() if len(parts) > 1 else text
items.append({"title": title, "content": content, "raw": raw_html})
return items
try:
with open("../CHANGELOG.md", "r") as file:
changelog_content = file.read()
except:
changelog_content = ""
# Convert markdown content to HTML
html_content = markdown.markdown(changelog_content)
# Parse the HTML content
soup = BeautifulSoup(html_content, "html.parser")
# Initialize JSON structure
changelog_json = {}
# Iterate over each version
for version in soup.find_all("h2"):
version_number = version.get_text().strip().split(" - ")[0][1:-1] # Remove brackets
date = version.get_text().strip().split(" - ")[1]
version_data = {"date": date}
# Find the next sibling that is a h3 tag (section title)
current = version.find_next_sibling()
while current and current.name != "h2":
if current.name == "h3":
section_title = current.get_text().lower() # e.g., "added", "fixed"
section_items = parse_section(current.find_next_sibling("ul"))
version_data[section_title] = section_items
# Move to the next element
current = current.find_next_sibling()
changelog_json[version_number] = version_data
CHANGELOG = changelog_json
####################################
# CUSTOM_NAME
####################################
CUSTOM_NAME = os.environ.get("CUSTOM_NAME", "")
if CUSTOM_NAME:
try:
r = requests.get(f"https://api.openwebui.com/api/v1/custom/{CUSTOM_NAME}")
data = r.json()
if r.ok:
if "logo" in data:
url = (
f"https://api.openwebui.com{data['logo']}"
if data["logo"][0] == "/"
else data["logo"]
)
r = requests.get(url, stream=True)
if r.status_code == 200:
with open("./static/favicon.png", "wb") as f:
r.raw.decode_content = True
shutil.copyfileobj(r.raw, f)
WEBUI_NAME = data["name"]
except Exception as e:
print(e)
pass
#################################### ####################################
# DATA/FRONTEND BUILD DIR # DATA/FRONTEND BUILD DIR
@ -28,6 +136,12 @@ ENV = os.environ.get("ENV", "dev")
DATA_DIR = str(Path(os.getenv("DATA_DIR", "./data")).resolve()) DATA_DIR = str(Path(os.getenv("DATA_DIR", "./data")).resolve())
FRONTEND_BUILD_DIR = str(Path(os.getenv("FRONTEND_BUILD_DIR", "../build"))) FRONTEND_BUILD_DIR = str(Path(os.getenv("FRONTEND_BUILD_DIR", "../build")))
try:
with open(f"{DATA_DIR}/config.json", "r") as f:
CONFIG_DATA = json.load(f)
except:
CONFIG_DATA = {}
#################################### ####################################
# File Upload DIR # File Upload DIR
#################################### ####################################
@ -43,17 +157,76 @@ Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
CACHE_DIR = f"{DATA_DIR}/cache" CACHE_DIR = f"{DATA_DIR}/cache"
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
#################################### ####################################
# OLLAMA_API_BASE_URL # Docs DIR
####################################
DOCS_DIR = f"{DATA_DIR}/docs"
Path(DOCS_DIR).mkdir(parents=True, exist_ok=True)
####################################
# LITELLM_CONFIG
####################################
def create_config_file(file_path):
directory = os.path.dirname(file_path)
# Check if directory exists, if not, create it
if not os.path.exists(directory):
os.makedirs(directory)
# Data to write into the YAML file
config_data = {
"general_settings": {},
"litellm_settings": {},
"model_list": [],
"router_settings": {},
}
# Write data to YAML file
with open(file_path, "w") as file:
yaml.dump(config_data, file)
LITELLM_CONFIG_PATH = f"{DATA_DIR}/litellm/config.yaml"
if not os.path.exists(LITELLM_CONFIG_PATH):
print("Config file doesn't exist. Creating...")
create_config_file(LITELLM_CONFIG_PATH)
print("Config file created successfully.")
####################################
# OLLAMA_BASE_URL
#################################### ####################################
OLLAMA_API_BASE_URL = os.environ.get( OLLAMA_API_BASE_URL = os.environ.get(
"OLLAMA_API_BASE_URL", "http://localhost:11434/api" "OLLAMA_API_BASE_URL", "http://localhost:11434/api"
) )
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "")
if OLLAMA_BASE_URL == "" and OLLAMA_API_BASE_URL != "":
OLLAMA_BASE_URL = (
OLLAMA_API_BASE_URL[:-4]
if OLLAMA_API_BASE_URL.endswith("/api")
else OLLAMA_API_BASE_URL
)
if ENV == "prod": if ENV == "prod":
if OLLAMA_API_BASE_URL == "/ollama/api": if OLLAMA_BASE_URL == "/ollama":
OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api" OLLAMA_BASE_URL = "http://host.docker.internal:11434"
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(";")]
#################################### ####################################
# OPENAI_API # OPENAI_API
@ -62,19 +235,40 @@ if ENV == "prod":
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "") OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "")
if OPENAI_API_BASE_URL == "": if OPENAI_API_BASE_URL == "":
OPENAI_API_BASE_URL = "https://api.openai.com/v1" OPENAI_API_BASE_URL = "https://api.openai.com/v1"
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_BASE_URLS = os.environ.get("OPENAI_API_BASE_URLS", "")
OPENAI_API_BASE_URLS = (
OPENAI_API_BASE_URLS if OPENAI_API_BASE_URLS != "" else OPENAI_API_BASE_URL
)
OPENAI_API_BASE_URLS = [
url.strip() if url != "" else "https://api.openai.com/v1"
for url in OPENAI_API_BASE_URLS.split(";")
]
#################################### ####################################
# WEBUI # WEBUI
#################################### ####################################
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", True) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "True").lower() == "true"
DEFAULT_MODELS = os.environ.get("DEFAULT_MODELS", None) DEFAULT_MODELS = os.environ.get("DEFAULT_MODELS", None)
DEFAULT_PROMPT_SUGGESTIONS = os.environ.get(
"DEFAULT_PROMPT_SUGGESTIONS",
[ 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 [
{ {
"title": ["Help me study", "vocabulary for a college entrance exam"], "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.", "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.",
@ -91,12 +285,25 @@ DEFAULT_PROMPT_SUGGESTIONS = os.environ.get(
"title": ["Show me a code snippet", "of a website's sticky header"], "title": ["Show me a code snippet", "of a website's sticky header"],
"content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.",
}, },
], ]
) )
DEFAULT_USER_ROLE = "pending"
USER_PERMISSIONS = {"chat": {"deletion": True}}
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}}
MODEL_FILTER_ENABLED = os.environ.get("MODEL_FILTER_ENABLED", "False").lower() == "true"
MODEL_FILTER_LIST = os.environ.get("MODEL_FILTER_LIST", "")
MODEL_FILTER_LIST = [model.strip() for model in MODEL_FILTER_LIST.split(";")]
WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "")
#################################### ####################################
# WEBUI_VERSION # WEBUI_VERSION
#################################### ####################################
@ -128,7 +335,12 @@ if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
#################################### ####################################
CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db"
EMBED_MODEL = "all-MiniLM-L6-v2" # 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 (all-MiniLM-L6-v2)
RAG_EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
# device type ebbeding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance
RAG_EMBEDDING_MODEL_DEVICE_TYPE = os.environ.get(
"RAG_EMBEDDING_MODEL_DEVICE_TYPE", "cpu"
)
CHROMA_CLIENT = chromadb.PersistentClient( CHROMA_CLIENT = chromadb.PersistentClient(
path=CHROMA_DATA_PATH, path=CHROMA_DATA_PATH,
settings=Settings(allow_reset=True, anonymized_telemetry=False), settings=Settings(allow_reset=True, anonymized_telemetry=False),
@ -136,9 +348,31 @@ CHROMA_CLIENT = chromadb.PersistentClient(
CHUNK_SIZE = 1500 CHUNK_SIZE = 1500
CHUNK_OVERLAP = 100 CHUNK_OVERLAP = 100
RAG_TEMPLATE = """Use the following context as your learned knowledge, inside <context></context> XML tags.
<context>
[context]
</context>
When answer to user:
- If you don't know, just say that you don't know.
- If you don't know when you are not sure, ask for clarification.
Avoid mentioning that you obtained the information from the context.
And answer according to the language of the user's question.
Given the context information, answer the query.
Query: [query]"""
#################################### ####################################
# Transcribe # Transcribe
#################################### ####################################
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base") WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base")
WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models") WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
####################################
# Images
####################################
AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "")

View file

@ -5,6 +5,13 @@ class MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}" DEFAULT = lambda msg="": f"{msg if msg else ''}"
class WEBHOOK_MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}"
USER_SIGNUP = lambda username="": (
f"New user signed up: {username}" if username else "New user signed up"
)
class ERROR_MESSAGES(str, Enum): class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str: def __str__(self) -> str:
return super().__str__() return super().__str__()
@ -41,6 +48,15 @@ class ERROR_MESSAGES(str, Enum):
NOT_FOUND = "We could not find what you're looking for :/" NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/" USER_NOT_FOUND = "We could not find what you're looking for :/"
API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature." API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature."
MALICIOUS = "Unusual activities detected, please try again in a few minutes." MALICIOUS = "Unusual activities detected, please try again in a few minutes."
PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance." PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance."
INCORRECT_FORMAT = (
lambda err="": f"Invalid format. Please use the correct format{err}"
)
RATE_LIMIT_EXCEEDED = "API rate limit exceeded"
MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found"
OPENAI_NOT_FOUND = lambda name="": f"OpenAI API was not found"
OLLAMA_NOT_FOUND = "WebUI could not connect to Ollama"

35
backend/data/config.json Normal file
View file

@ -0,0 +1,35 @@
{
"version": 0,
"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."
},
{
"title": [
"Give me ideas",
"for what to do with my kids' art"
],
"content": "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter."
},
{
"title": [
"Tell me a fun fact",
"about the Roman Empire"
],
"content": "Tell me a random fun fact about the Roman Empire"
},
{
"title": [
"Show me a code snippet",
"of a website's sticky header"
],
"content": "Show me a code snippet of a website's sticky header in CSS and JavaScript."
}
]
}
}

View file

@ -0,0 +1,4 @@
general_settings: {}
litellm_settings: {}
model_list: []
router_settings: {}

View file

@ -1,22 +1,46 @@
from bs4 import BeautifulSoup
import json
import markdown
import time import time
import os
import sys
import requests
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Depends, status
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.middleware.wsgi import WSGIMiddleware from fastapi.middleware.wsgi import WSGIMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from apps.ollama.main import app as ollama_app from apps.ollama.main import app as ollama_app
from apps.openai.main import app as openai_app from apps.openai.main import app as openai_app
from apps.litellm.main import app as litellm_app, startup as litellm_app_startup
from apps.audio.main import app as audio_app from apps.audio.main import app as audio_app
from apps.images.main import app as images_app
from apps.web.main import app as webui_app
from apps.rag.main import app as rag_app from apps.rag.main import app as rag_app
from apps.web.main import app as webui_app
from config import ENV, FRONTEND_BUILD_DIR from pydantic import BaseModel
from typing import List
from utils.utils import get_admin_user
from apps.rag.utils import rag_messages
from config import (
WEBUI_NAME,
ENV,
VERSION,
CHANGELOG,
FRONTEND_BUILD_DIR,
MODEL_FILTER_ENABLED,
MODEL_FILTER_LIST,
WEBHOOK_URL,
)
from constants import ERROR_MESSAGES
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
@ -32,8 +56,70 @@ class SPAStaticFiles(StaticFiles):
app = FastAPI(docs_url="/docs" if ENV == "dev" else None, redoc_url=None) app = FastAPI(docs_url="/docs" if ENV == "dev" else None, redoc_url=None)
app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED
app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST
app.state.WEBHOOK_URL = WEBHOOK_URL
origins = ["*"] origins = ["*"]
class RAGMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method == "POST" and (
"/api/chat" in request.url.path or "/chat/completions" in request.url.path
):
print(request.url.path)
# Read the original request body
body = await request.body()
# Decode body to string
body_str = body.decode("utf-8")
# Parse string to JSON
data = json.loads(body_str) if body_str else {}
# Example: Add a new key-value pair or modify existing ones
# data["modified"] = True # Example modification
if "docs" in data:
data = {**data}
data["messages"] = rag_messages(
data["docs"],
data["messages"],
rag_app.state.RAG_TEMPLATE,
rag_app.state.TOP_K,
rag_app.state.sentence_transformer_ef,
)
del data["docs"]
print(data["messages"])
modified_body_bytes = json.dumps(data).encode("utf-8")
# Replace the request body with the modified one
request._body = modified_body_bytes
# Set custom header to ensure content-length matches new body length
request.headers.__dict__["_list"] = [
(b"content-length", str(len(modified_body_bytes)).encode("utf-8")),
*[
(k, v)
for k, v in request.headers.raw
if k.lower() != b"content-length"
],
]
response = await call_next(request)
return response
async def _receive(self, body: bytes):
return {"type": "http.request", "body": body, "more_body": False}
app.add_middleware(RAGMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
@ -53,15 +139,124 @@ async def check_url(request: Request, call_next):
return response return response
app.mount("/api/v1", webui_app) @app.on_event("startup")
async def on_startup():
await litellm_app_startup()
app.mount("/ollama/api", ollama_app)
app.mount("/api/v1", webui_app)
app.mount("/litellm/api", litellm_app)
app.mount("/ollama", ollama_app)
app.mount("/openai/api", openai_app) app.mount("/openai/api", openai_app)
app.mount("/images/api/v1", images_app)
app.mount("/audio/api/v1", audio_app) app.mount("/audio/api/v1", audio_app)
app.mount("/rag/api/v1", rag_app) app.mount("/rag/api/v1", rag_app)
@app.get("/api/config")
async def get_app_config():
return {
"status": True,
"name": WEBUI_NAME,
"version": VERSION,
"images": images_app.state.ENABLED,
"default_models": webui_app.state.DEFAULT_MODELS,
"default_prompt_suggestions": webui_app.state.DEFAULT_PROMPT_SUGGESTIONS,
}
@app.get("/api/config/model/filter")
async def get_model_filter_config(user=Depends(get_admin_user)):
return {
"enabled": app.state.MODEL_FILTER_ENABLED,
"models": app.state.MODEL_FILTER_LIST,
}
class ModelFilterConfigForm(BaseModel):
enabled: bool
models: List[str]
@app.post("/api/config/model/filter")
async def update_model_filter_config(
form_data: ModelFilterConfigForm, user=Depends(get_admin_user)
):
app.state.MODEL_FILTER_ENABLED = form_data.enabled
app.state.MODEL_FILTER_LIST = form_data.models
ollama_app.state.MODEL_FILTER_ENABLED = app.state.MODEL_FILTER_ENABLED
ollama_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST
openai_app.state.MODEL_FILTER_ENABLED = app.state.MODEL_FILTER_ENABLED
openai_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST
return {
"enabled": app.state.MODEL_FILTER_ENABLED,
"models": app.state.MODEL_FILTER_LIST,
}
@app.get("/api/webhook")
async def get_webhook_url(user=Depends(get_admin_user)):
return {
"url": app.state.WEBHOOK_URL,
}
class UrlForm(BaseModel):
url: str
@app.post("/api/webhook")
async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)):
app.state.WEBHOOK_URL = form_data.url
webui_app.state.WEBHOOK_URL = app.state.WEBHOOK_URL
return {
"url": app.state.WEBHOOK_URL,
}
@app.get("/api/version")
async def get_app_config():
return {
"version": VERSION,
}
@app.get("/api/changelog")
async def get_app_changelog():
return CHANGELOG
@app.get("/api/version/updates")
async def get_app_latest_release_version():
try:
response = requests.get(
f"https://api.github.com/repos/open-webui/open-webui/releases/latest"
)
response.raise_for_status()
latest_version = response.json()["tag_name"]
return {"current": VERSION, "latest": latest_version[1:]}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
)
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/cache", StaticFiles(directory="data/cache"), name="cache")
app.mount( app.mount(
"/", "/",
SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True),

View file

@ -16,8 +16,14 @@ aiohttp
peewee peewee
bcrypt bcrypt
litellm==1.30.7
argon2-cffi
apscheduler
google-generativeai
langchain langchain
langchain-community langchain-community
fake_useragent
chromadb chromadb
sentence_transformers sentence_transformers
pypdf pypdf
@ -30,6 +36,9 @@ openpyxl
pyxlsb pyxlsb
xlrd xlrd
opencv-python-headless
rapidocr-onnxruntime
faster-whisper faster-whisper
PyJWT PyJWT

BIN
backend/static/favicon.png Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -1,5 +1,8 @@
from pathlib import Path
import hashlib import hashlib
import re import re
from datetime import timedelta
from typing import Optional
def get_gravatar_url(email): def get_gravatar_url(email):
@ -38,3 +41,71 @@ def validate_email_format(email: str) -> bool:
if not re.match(r"[^@]+@[^@]+\.[^@]+", email): if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
return False return False
return True return True
def sanitize_filename(file_name):
# Convert to lowercase
lower_case_file_name = file_name.lower()
# Remove special characters using regular expression
sanitized_file_name = re.sub(r"[^\w\s]", "", lower_case_file_name)
# Replace spaces with dashes
final_file_name = re.sub(r"\s+", "-", sanitized_file_name)
return final_file_name
def extract_folders_after_data_docs(path):
# Convert the path to a Path object if it's not already
path = Path(path)
# Extract parts of the path
parts = path.parts
# Find the index of '/data/docs' in the path
try:
index_data_docs = parts.index("data") + 1
index_docs = parts.index("docs", index_data_docs) + 1
except ValueError:
return []
# Exclude the filename and accumulate folder names
tags = []
folders = parts[index_docs:-1]
for idx, part in enumerate(folders):
tags.append("/".join(folders[: idx + 1]))
return tags
def parse_duration(duration: str) -> Optional[timedelta]:
if duration == "-1" or duration == "0":
return None
# Regular expression to find number and unit pairs
pattern = r"(-?\d+(\.\d+)?)(ms|s|m|h|d|w)"
matches = re.findall(pattern, duration)
if not matches:
raise ValueError("Invalid duration string")
total_duration = timedelta()
for number, _, unit in matches:
number = float(number)
if unit == "ms":
total_duration += timedelta(milliseconds=number)
elif unit == "s":
total_duration += timedelta(seconds=number)
elif unit == "m":
total_duration += timedelta(minutes=number)
elif unit == "h":
total_duration += timedelta(hours=number)
elif unit == "d":
total_duration += timedelta(days=number)
elif unit == "w":
total_duration += timedelta(weeks=number)
return total_duration

View file

@ -58,6 +58,14 @@ def extract_token_from_auth_header(auth_header: str):
return auth_header[len("Bearer ") :] return auth_header[len("Bearer ") :]
def get_http_authorization_cred(auth_header: str):
try:
scheme, credentials = auth_header.split(" ")
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
except:
raise ValueError(ERROR_MESSAGES.INVALID_TOKEN)
def get_current_user( def get_current_user(
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
): ):

20
backend/utils/webhook.py Normal file
View file

@ -0,0 +1,20 @@
import requests
def post_webhook(url: str, message: str, event_data: dict) -> bool:
try:
payload = {}
if "https://hooks.slack.com" in url:
payload["text"] = message
elif "https://discord.com/api/webhooks" in url:
payload["content"] = message
else:
payload = {**event_data}
r = requests.post(url, json=payload)
r.raise_for_status()
return True
except Exception as e:
print(e)
return False

BIN
bun.lockb

Binary file not shown.

8
confirm_remove.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/bash
echo "Warning: This will remove all containers and volumes, including persistent data. Do you want to continue? [Y/N]"
read ans
if [ "$ans" == "Y" ] || [ "$ans" == "y" ]; then
docker-compose down -v
else
echo "Operation cancelled."
fi

BIN
demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 MiB

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -14,7 +14,7 @@ services:
build: build:
context: . context: .
args: args:
OLLAMA_API_BASE_URL: '/ollama/api' OLLAMA_BASE_URL: '/ollama'
dockerfile: Dockerfile dockerfile: Dockerfile
image: ghcr.io/open-webui/open-webui:main image: ghcr.io/open-webui/open-webui:main
container_name: open-webui container_name: open-webui
@ -23,9 +23,9 @@ services:
depends_on: depends_on:
- ollama - ollama
ports: ports:
- ${OLLAMA_WEBUI_PORT-3000}:8080 - ${OPEN_WEBUI_PORT-3000}:8080
environment: environment:
- 'OLLAMA_API_BASE_URL=http://ollama:11434/api' - 'OLLAMA_BASE_URL=http://ollama:11434'
- 'WEBUI_SECRET_KEY=' - 'WEBUI_SECRET_KEY='
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway

View file

@ -50,6 +50,18 @@ We welcome pull requests. Before submitting one, please:
Help us make Open WebUI more accessible by improving documentation, writing tutorials, or creating guides on setting up and optimizing the web UI. Help us make Open WebUI more accessible by improving documentation, writing tutorials, or creating guides on setting up and optimizing the web UI.
### 🌐 Translations and Internationalization
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.
To add a new language:
- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`.
- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object.
- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`.
### 🤔 Questions & Feedback ### 🤔 Questions & Feedback
Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue. We're here to help! Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue. We're here to help!

View file

@ -1,10 +0,0 @@
# Ollama URL for the backend to connect
# The path '/ollama/api' will be redirected to the specified backend URL
OLLAMA_API_BASE_URL='http://localhost:11434/api'
OPENAI_API_BASE_URL=''
OPENAI_API_KEY=''
# DO NOT TRACK
SCARF_NO_ANALYTICS=true
DO_NOT_TRACK=true

38
i18next-parser.config.ts Normal file
View file

@ -0,0 +1,38 @@
// i18next-parser.config.ts
import { getLanguages } from './src/lib/i18n/index.ts';
const getLangCodes = async () => {
const languages = await getLanguages();
return languages.map((l) => l.code);
};
export default {
contextSeparator: '_',
createOldCatalogs: false,
defaultNamespace: 'translation',
defaultValue: '',
indentation: 2,
keepRemoved: false,
keySeparator: false,
lexers: {
svelte: ['JavascriptLexer'],
js: ['JavascriptLexer'],
ts: ['JavascriptLexer'],
default: ['JavascriptLexer']
},
lineEnding: 'auto',
locales: await getLangCodes(),
namespaceSeparator: false,
output: 'src/lib/i18n/locales/$LOCALE/$NAMESPACE.json',
pluralSeparator: '_',
input: 'src/**/*.{js,svelte}',
sort: true,
verbose: true,
failOnWarnings: false,
failOnUpdate: false,
customValueTemplate: null,
resetDefaultValueLocale: null,
i18nextOptions: null,
yamlOptions: null
};

View file

@ -0,0 +1 @@
values-minikube.yaml

View file

@ -1,5 +1,21 @@
apiVersion: v2 apiVersion: v2
name: ollama-webui name: open-webui
description: "Ollama Web UI: A User-Friendly Web Interface for Chat Interactions 👋"
version: 1.0.0 version: 1.0.0
icon: https://raw.githubusercontent.com/ollama-webui/ollama-webui/main/static/favicon.png appVersion: "latest"
home: https://www.openwebui.com/
icon: https://raw.githubusercontent.com/open-webui/open-webui/main/static/favicon.png
description: "Open WebUI: A User-Friendly Web Interface for Chat Interactions 👋"
keywords:
- llm
- chat
- web-ui
sources:
- https://github.com/open-webui/open-webui/tree/main/kubernetes/helm
- https://hub.docker.com/r/ollama/ollama
- https://github.com/open-webui/open-webui/pkgs/container/open-webui
annotations:
licenses: MIT

View file

@ -0,0 +1,47 @@
{{- define "open-webui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end -}}
{{- define "ollama.name" -}}
ollama
{{- end -}}
{{- define "ollama.url" -}}
{{- printf "http://%s.%s.svc.cluster.local:%d/api" (include "ollama.name" .) (.Release.Namespace) (.Values.ollama.service.port | int) }}
{{- end }}
{{- define "chart.name" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "base.labels" -}}
helm.sh/chart: {{ include "chart.name" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "base.selectorLabels" -}}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "open-webui.selectorLabels" -}}
{{ include "base.selectorLabels" . }}
app.kubernetes.io/component: {{ .Chart.Name }}
{{- end }}
{{- define "open-webui.labels" -}}
{{ include "base.labels" . }}
{{ include "open-webui.selectorLabels" . }}
{{- end }}
{{- define "ollama.selectorLabels" -}}
{{ include "base.selectorLabels" . }}
app.kubernetes.io/component: {{ include "ollama.name" . }}
{{- end }}
{{- define "ollama.labels" -}}
{{ include "base.labels" . }}
{{ include "ollama.selectorLabels" . }}
{{- end }}

View file

@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.namespace }}

View file

@ -1,13 +1,21 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: ollama-service name: {{ include "ollama.name" . }}
namespace: {{ .Values.namespace }} labels:
{{- include "ollama.labels" . | nindent 4 }}
{{- with .Values.ollama.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
type: {{ .Values.ollama.service.type }}
selector: selector:
app: ollama {{- include "ollama.selectorLabels" . | nindent 4 }}
{{- with .Values.ollama.service }}
type: {{ .type }}
ports: ports:
- protocol: TCP - protocol: TCP
port: {{ .Values.ollama.servicePort }} name: http
targetPort: {{ .Values.ollama.servicePort }} port: {{ .port }}
targetPort: http
{{- end }}

View file

@ -1,24 +1,43 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: ollama name: {{ include "ollama.name" . }}
namespace: {{ .Values.namespace }} labels:
{{- include "ollama.labels" . | nindent 4 }}
{{- with .Values.ollama.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
serviceName: "ollama" serviceName: {{ include "ollama.name" . }}
replicas: {{ .Values.ollama.replicaCount }} replicas: {{ .Values.ollama.replicaCount }}
selector: selector:
matchLabels: matchLabels:
app: ollama {{- include "ollama.selectorLabels" . | nindent 6 }}
template: template:
metadata: metadata:
labels: labels:
app: ollama {{- include "ollama.labels" . | nindent 8 }}
{{- with .Values.ollama.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
enableServiceLinks: false
automountServiceAccountToken: false
{{- with .Values.ollama.runtimeClassName }}
runtimeClassName: {{ . }}
{{- end }}
containers: containers:
- name: ollama - name: {{ include "ollama.name" . }}
image: {{ .Values.ollama.image }} {{- with .Values.ollama.image }}
image: {{ .repository }}:{{ .tag }}
imagePullPolicy: {{ .pullPolicy }}
{{- end }}
tty: true
ports: ports:
- containerPort: {{ .Values.ollama.servicePort }} - name: http
containerPort: {{ .Values.ollama.service.containerPort }}
env: env:
{{- if .Values.ollama.gpu.enabled }} {{- if .Values.ollama.gpu.enabled }}
- name: PATH - name: PATH
@ -28,28 +47,50 @@ spec:
- name: NVIDIA_DRIVER_CAPABILITIES - name: NVIDIA_DRIVER_CAPABILITIES
value: compute,utility value: compute,utility
{{- end }} {{- end }}
{{- if .Values.ollama.resources }} {{- with .Values.ollama.resources }}
resources: {{- toYaml .Values.ollama.resources | nindent 10 }} resources: {{- toYaml . | nindent 10 }}
{{- end }} {{- end }}
volumeMounts: volumeMounts:
- name: ollama-volume - name: data
mountPath: /root/.ollama mountPath: /root/.ollama
tty: true
{{- with .Values.ollama.nodeSelector }} {{- with .Values.ollama.nodeSelector }}
nodeSelector: nodeSelector:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
{{- with .Values.ollama.tolerations }}
tolerations: tolerations:
{{- if .Values.ollama.gpu.enabled }} {{- toYaml . | nindent 8 }}
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
{{- end }} {{- end }}
volumes:
{{- if and .Values.ollama.persistence.enabled .Values.ollama.persistence.existingClaim }}
- name: data
persistentVolumeClaim:
claimName: {{ .Values.ollama.persistence.existingClaim }}
{{- else if not .Values.ollama.persistence.enabled }}
- name: data
emptyDir: {}
{{- else if and .Values.ollama.persistence.enabled (not .Values.ollama.persistence.existingClaim) }}
[]
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - metadata:
name: ollama-volume name: data
labels:
{{- include "ollama.selectorLabels" . | nindent 8 }}
{{- with .Values.ollama.persistence.annotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
accessModes: [ "ReadWriteOnce" ] accessModes:
{{- range .Values.ollama.persistence.accessModes }}
- {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.ollama.volumeSize }} storage: {{ .Values.ollama.persistence.size | quote }}
storageClass: {{ .Values.ollama.persistence.storageClass }}
{{- with .Values.ollama.persistence.selector }}
selector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -1,38 +1,62 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: ollama-webui-deployment name: {{ include "open-webui.name" . }}
namespace: {{ .Values.namespace }} labels:
{{- include "open-webui.labels" . | nindent 4 }}
{{- with .Values.webui.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
replicas: 1 replicas: {{ .Values.webui.replicaCount }}
selector: selector:
matchLabels: matchLabels:
app: ollama-webui {{- include "open-webui.selectorLabels" . | nindent 6 }}
template: template:
metadata: metadata:
labels: labels:
app: ollama-webui {{- include "open-webui.labels" . | nindent 8 }}
{{- with .Values.webui.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
enableServiceLinks: false
automountServiceAccountToken: false
containers: containers:
- name: ollama-webui - name: {{ .Chart.Name }}
image: {{ .Values.webui.image }} {{- with .Values.webui.image }}
image: {{ .repository }}:{{ .tag | default $.Chart.AppVersion }}
imagePullPolicy: {{ .pullPolicy }}
{{- end }}
ports: ports:
- containerPort: 8080 - name: http
{{- if .Values.webui.resources }} containerPort: {{ .Values.webui.service.containerPort }}
resources: {{- toYaml .Values.webui.resources | nindent 10 }} {{- with .Values.webui.resources }}
resources: {{- toYaml . | nindent 10 }}
{{- end }} {{- end }}
volumeMounts: volumeMounts:
- name: webui-volume - name: data
mountPath: /app/backend/data mountPath: /app/backend/data
env: env:
- name: OLLAMA_API_BASE_URL - name: OLLAMA_BASE_URL
value: "http://ollama-service.{{ .Values.namespace }}.svc.cluster.local:{{ .Values.ollama.servicePort }}/api" value: {{ include "ollama.url" . | quote }}
tty: true tty: true
{{- with .Values.webui.nodeSelector }} {{- with .Values.webui.nodeSelector }}
nodeSelector: nodeSelector:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
volumes: volumes:
- name: webui-volume {{- if and .Values.webui.persistence.enabled .Values.webui.persistence.existingClaim }}
- name: data
persistentVolumeClaim: persistentVolumeClaim:
claimName: ollama-webui-pvc claimName: {{ .Values.webui.persistence.existingClaim }}
{{- else if not .Values.webui.persistence.enabled }}
- name: data
emptyDir: {}
{{- else if and .Values.webui.persistence.enabled (not .Values.webui.persistence.existingClaim) }}
- name: data
persistentVolumeClaim:
claimName: {{ include "open-webui.name" . }}
{{- end }}

View file

@ -2,13 +2,23 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: ollama-webui-ingress name: {{ include "open-webui.name" . }}
namespace: {{ .Values.namespace }} labels:
{{- if .Values.webui.ingress.annotations }} {{- include "open-webui.labels" . | nindent 4 }}
{{- with .Values.webui.ingress.annotations }}
annotations: annotations:
{{ toYaml .Values.webui.ingress.annotations | trimSuffix "\n" | indent 4 }} {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}
spec: spec:
{{- with .Values.webui.ingress.class }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.webui.ingress.tls }}
tls:
- hosts:
- {{ .Values.webui.ingress.host | quote }}
secretName: {{ default (printf "%s-tls" .Release.Name) .Values.webui.ingress.existingSecret }}
{{- end }}
rules: rules:
- host: {{ .Values.webui.ingress.host }} - host: {{ .Values.webui.ingress.host }}
http: http:
@ -17,7 +27,7 @@ spec:
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: ollama-webui-service name: {{ include "open-webui.name" . }}
port: port:
number: {{ .Values.webui.servicePort }} name: http
{{- end }} {{- end }}

View file

@ -1,12 +1,25 @@
{{- if and .Values.webui.persistence.enabled (not .Values.webui.persistence.existingClaim) }}
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: {{ include "open-webui.name" . }}
labels: labels:
app: ollama-webui {{- include "open-webui.selectorLabels" . | nindent 4 }}
name: ollama-webui-pvc {{- with .Values.webui.persistence.annotations }}
namespace: {{ .Values.namespace }} annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
accessModes: [ "ReadWriteOnce" ] accessModes:
{{- range .Values.webui.persistence.accessModes }}
- {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.webui.volumeSize }} storage: {{ .Values.webui.persistence.size }}
storageClass: {{ .Values.webui.persistence.storageClass }}
{{- with .Values.webui.persistence.selector }}
selector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -1,15 +1,29 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: ollama-webui-service name: {{ include "open-webui.name" . }}
namespace: {{ .Values.namespace }} labels:
{{- include "open-webui.labels" . | nindent 4 }}
{{- with .Values.webui.service.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.webui.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
type: {{ .Values.webui.service.type }} # Default: NodePort # Use LoadBalancer if you're on a cloud that supports it
selector: selector:
app: ollama-webui {{- include "open-webui.selectorLabels" . | nindent 4 }}
type: {{ .Values.webui.service.type | default "ClusterIP" }}
ports: ports:
- protocol: TCP - protocol: TCP
port: {{ .Values.webui.servicePort }} name: http
targetPort: {{ .Values.webui.servicePort }} port: {{ .Values.webui.service.port }}
# If using NodePort, you can optionally specify the nodePort: targetPort: http
# nodePort: 30000 {{- if .Values.webui.service.nodePort }}
nodePort: {{ .Values.webui.service.nodePort | int }}
{{- end }}
{{- if .Values.webui.service.loadBalancerClass }}
loadBalancerClass: {{ .Values.webui.service.loadBalancerClass | quote }}
{{- end }}

View file

@ -0,0 +1,27 @@
ollama:
resources:
requests:
cpu: "2000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "4Gi"
nvidia.com/gpu: "0"
service:
type: ClusterIP
gpu:
enabled: false
webui:
resources:
requests:
cpu: "500m"
memory: "500Mi"
limits:
cpu: "1000m"
memory: "1Gi"
ingress:
enabled: true
host: open-webui.minikube.local
service:
type: NodePort

View file

@ -1,38 +1,74 @@
namespace: ollama-namespace nameOverride: ""
ollama: ollama:
annotations: {}
podAnnotations: {}
replicaCount: 1 replicaCount: 1
image: ollama/ollama:latest image:
servicePort: 11434 repository: ollama/ollama
resources: tag: latest
limits: pullPolicy: Always
cpu: "2000m" resources: {}
memory: "2Gi" persistence:
nvidia.com/gpu: "0" enabled: true
volumeSize: 1Gi size: 30Gi
existingClaim: ""
accessModes:
- ReadWriteOnce
storageClass: ""
selector: {}
annotations: {}
nodeSelector: {}
# -- If using a special runtime container such as nvidia, set it here.
runtimeClassName: ""
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
service:
type: ClusterIP
annotations: {}
port: 80
containerPort: 11434
gpu:
# -- Enable additional ENV values to help Ollama discover GPU usage
enabled: false
webui:
annotations: {}
podAnnotations: {}
replicaCount: 1
image:
repository: ghcr.io/open-webui/open-webui
tag: ""
pullPolicy: Always
resources: {}
ingress:
enabled: false
class: ""
# -- Use appropriate annotations for your Ingress controller, e.g., for NGINX:
# nginx.ingress.kubernetes.io/rewrite-target: /
annotations: {}
host: ""
tls: false
existingSecret: ""
persistence:
enabled: true
size: 2Gi
existingClaim: ""
# -- If using multiple replicas, you must update accessModes to ReadWriteMany
accessModes:
- ReadWriteOnce
storageClass: ""
selector: {}
annotations: {}
nodeSelector: {} nodeSelector: {}
tolerations: [] tolerations: []
service: service:
type: ClusterIP type: ClusterIP
gpu: annotations: {}
enabled: false port: 80
containerPort: 8080
webui: nodePort: ""
replicaCount: 1 labels: {}
image: ghcr.io/ollama-webui/ollama-webui:main loadBalancerClass: ""
servicePort: 8080
resources:
limits:
cpu: "500m"
memory: "500Mi"
ingress:
enabled: true
annotations:
# Use appropriate annotations for your Ingress controller, e.g., for NGINX:
# nginx.ingress.kubernetes.io/rewrite-target: /
host: ollama.minikube.local
volumeSize: 1Gi
nodeSelector: {}
tolerations: []
service:
type: NodePort

View file

@ -2,7 +2,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: ollama-service name: ollama-service
namespace: ollama-namespace namespace: open-webui
spec: spec:
selector: selector:
app: ollama app: ollama

View file

@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: ollama name: ollama
namespace: ollama-namespace namespace: open-webui
spec: spec:
serviceName: "ollama" serviceName: "ollama"
replicas: 1 replicas: 1
@ -20,9 +20,13 @@ spec:
ports: ports:
- containerPort: 11434 - containerPort: 11434
resources: resources:
limits: requests:
cpu: "2000m" cpu: "2000m"
memory: "2Gi" memory: "2Gi"
limits:
cpu: "4000m"
memory: "4Gi"
nvidia.com/gpu: "0"
volumeMounts: volumeMounts:
- name: ollama-volume - name: ollama-volume
mountPath: /root/.ollama mountPath: /root/.ollama
@ -34,4 +38,4 @@ spec:
accessModes: [ "ReadWriteOnce" ] accessModes: [ "ReadWriteOnce" ]
resources: resources:
requests: requests:
storage: 1Gi storage: 30Gi

View file

@ -1,4 +1,4 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: ollama-namespace name: open-webui

View file

@ -1,28 +1,38 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: ollama-webui-deployment name: open-webui-deployment
namespace: ollama-namespace namespace: open-webui
spec: spec:
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: ollama-webui app: open-webui
template: template:
metadata: metadata:
labels: labels:
app: ollama-webui app: open-webui
spec: spec:
containers: containers:
- name: ollama-webui - name: open-webui
image: ghcr.io/ollama-webui/ollama-webui:main image: ghcr.io/open-webui/open-webui:main
ports: ports:
- containerPort: 8080 - containerPort: 8080
resources: resources:
limits: requests:
cpu: "500m" cpu: "500m"
memory: "500Mi" memory: "500Mi"
limits:
cpu: "1000m"
memory: "1Gi"
env: env:
- name: OLLAMA_API_BASE_URL - name: OLLAMA_BASE_URL
value: "http://ollama-service.ollama-namespace.svc.cluster.local:11434/api" value: "http://ollama-service.open-webui.svc.cluster.local:11434"
tty: true tty: true
volumeMounts:
- name: webui-volume
mountPath: /app/backend/data
volumes:
- name: webui-volume
persistentVolumeClaim:
claimName: ollama-webui-pvc

View file

@ -1,20 +1,20 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: ollama-webui-ingress name: open-webui-ingress
namespace: ollama-namespace namespace: open-webui
#annotations: #annotations:
# Use appropriate annotations for your Ingress controller, e.g., for NGINX: # Use appropriate annotations for your Ingress controller, e.g., for NGINX:
# nginx.ingress.kubernetes.io/rewrite-target: / # nginx.ingress.kubernetes.io/rewrite-target: /
spec: spec:
rules: rules:
- host: ollama.minikube.local - host: open-webui.minikube.local
http: http:
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: ollama-webui-service name: open-webui-service
port: port:
number: 8080 number: 8080

View file

@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
labels:
app: ollama-webui
name: ollama-webui-pvc
namespace: open-webui
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi

View file

@ -1,12 +1,12 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: ollama-webui-service name: open-webui-service
namespace: ollama-namespace namespace: open-webui
spec: spec:
type: NodePort # Use LoadBalancer if you're on a cloud that supports it type: NodePort # Use LoadBalancer if you're on a cloud that supports it
selector: selector:
app: ollama-webui app: open-webui
ports: ports:
- protocol: TCP - protocol: TCP
port: 8080 port: 8080

View file

@ -1,10 +1,11 @@
resources: resources:
- base/ollama-namespace.yaml - base/open-webui.yaml
- base/ollama-service.yaml - base/ollama-service.yaml
- base/ollama-statefulset.yaml - base/ollama-statefulset.yaml
- base/webui-deployment.yaml - base/webui-deployment.yaml
- base/webui-service.yaml - base/webui-service.yaml
- base/webui-ingress.yaml - base/webui-ingress.yaml
- base/webui-pvc.yaml
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization

View file

@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: ollama name: ollama
namespace: ollama-namespace namespace: open-webui
spec: spec:
selector: selector:
matchLabels: matchLabels:

3461
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ollama-webui", "name": "open-webui",
"version": "0.0.1", "version": "0.1.114",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
@ -13,7 +13,8 @@
"lint:types": "npm run check", "lint:types": "npm run check",
"lint:backend": "pylint backend/", "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": "yapf --recursive backend -p -i" "format:backend": "yapf --recursive backend -p -i",
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-auto": "^2.0.0",
@ -27,11 +28,13 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0", "eslint-plugin-svelte": "^2.30.0",
"i18next-parser": "^8.13.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5", "svelte": "^4.0.5",
"svelte-check": "^3.4.3", "svelte-check": "^3.4.3",
"svelte-confetti": "^1.3.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@ -41,14 +44,18 @@
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-node": "^1.3.1",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.19.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"katex": "^0.16.9", "katex": "^0.16.9",
"marked": "^9.1.0", "marked": "^9.1.0",
"svelte-french-toast": "^1.2.0", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"uuid": "^9.0.1" "uuid": "^9.0.1"
} }

View file

@ -182,7 +182,7 @@ else
export OLLAMA_DATA_DIR=$data_dir # Set OLLAMA_DATA_DIR environment variable export OLLAMA_DATA_DIR=$data_dir # Set OLLAMA_DATA_DIR environment variable
fi fi
if [[ -n $webui_port ]]; then if [[ -n $webui_port ]]; then
export OLLAMA_WEBUI_PORT=$webui_port # Set OLLAMA_WEBUI_PORT environment variable export OPEN_WEBUI_PORT=$webui_port # Set OPEN_WEBUI_PORT environment variable
fi fi
DEFAULT_COMPOSE_COMMAND+=" up -d" DEFAULT_COMPOSE_COMMAND+=" up -d"
DEFAULT_COMPOSE_COMMAND+=" --remove-orphans" DEFAULT_COMPOSE_COMMAND+=" --remove-orphans"

4
run.sh
View file

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
image_name="ollama-webui" image_name="open-webui"
container_name="ollama-webui" container_name="open-webui"
host_port=3000 host_port=3000
container_port=8080 container_port=8080

View file

@ -28,6 +28,25 @@ math {
@apply rounded-lg; @apply rounded-lg;
} }
ol > li {
counter-increment: list-number;
display: block;
margin-bottom: 0;
margin-top: 0;
min-height: 28px;
}
.prose ol > li::before {
content: counters(list-number, '.') '.';
padding-right: 0.5rem;
color: var(--tw-prose-counters);
font-weight: 400;
}
li p {
display: inline;
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
--tw-border-opacity: 1; --tw-border-opacity: 1;
background-color: rgba(217, 217, 227, 0.8); background-color: rgba(217, 217, 227, 0.8);
@ -37,8 +56,8 @@ math {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 0.45rem; height: 0.4rem;
width: 0.35rem; width: 0.4rem;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {

View file

@ -5,20 +5,38 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<script> <script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC // On page load or when changing themes, best to add inline in `head` to avoid FOUC
(() => {
if ( if (
localStorage.theme === 'light' || localStorage.theme === 'light' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: light)').matches) (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: light)').matches)
) { ) {
document.documentElement.classList.add('light'); document.documentElement.classList.add('light');
} else if (localStorage.theme) { } else if (localStorage.theme && localStorage.theme !== 'system') {
localStorage.theme.split(' ').forEach((e) => { localStorage.theme.split(' ').forEach((e) => {
document.documentElement.classList.add(e); document.documentElement.classList.add(e);
}); });
} else if (localStorage.theme && localStorage.theme === 'system') {
systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(systemTheme ? 'dark' : 'light');
} else { } else {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
if (localStorage.theme === 'system') {
if (e.matches) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
} else {
document.documentElement.classList.add('light');
document.documentElement.classList.remove('dark');
}
}
});
})();
</script> </script>
%sveltekit.head% %sveltekit.head%

View file

@ -261,3 +261,60 @@ export const toggleSignUpEnabledStatus = async (token: string) => {
return res; return res;
}; };
export const getJWTExpiresDuration = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/token/expires`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateJWTExpiresDuration = async (token: string, duration: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/token/expires/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
duration: duration
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -439,7 +439,7 @@ export const deleteAllChats = async (token: string) => {
return json; return json;
}) })
.catch((err) => { .catch((err) => {
error = err; error = err.detail;
console.log(err); console.log(err);
return null; return null;

View file

@ -5,7 +5,8 @@ export const createNewDoc = async (
collection_name: string, collection_name: string,
filename: string, filename: string,
name: string, name: string,
title: string title: string,
content: object | null = null
) => { ) => {
let error = null; let error = null;
@ -20,7 +21,8 @@ export const createNewDoc = async (
collection_name: collection_name, collection_name: collection_name,
filename: filename, filename: filename,
name: name, name: name,
title: title title: title,
...(content ? { content: JSON.stringify(content) } : {})
}) })
}) })
.then(async (res) => { .then(async (res) => {

View file

@ -0,0 +1,473 @@
import { IMAGES_API_BASE_URL } from '$lib/constants';
export const getImageGenerationConfig = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/config`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateImageGenerationConfig = async (
token: string = '',
engine: string,
enabled: boolean
) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/config/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
engine,
enabled
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getOpenAIKey = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/key`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.OPENAI_API_KEY;
};
export const updateOpenAIKey = async (token: string = '', key: string) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/key/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
key: key
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.OPENAI_API_KEY;
};
export const getAUTOMATIC1111Url = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/url`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.AUTOMATIC1111_BASE_URL;
};
export const updateAUTOMATIC1111Url = async (token: string = '', url: string) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/url/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
url: url
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.AUTOMATIC1111_BASE_URL;
};
export const getImageSize = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/size`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.IMAGE_SIZE;
};
export const updateImageSize = async (token: string = '', size: string) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/size/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
size: size
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.IMAGE_SIZE;
};
export const getImageSteps = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/steps`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.IMAGE_STEPS;
};
export const updateImageSteps = async (token: string = '', steps: number) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/steps/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({ steps })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.IMAGE_STEPS;
};
export const getImageGenerationModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getDefaultImageGenerationModel = async (token: string = '') => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/models/default`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.model;
};
export const updateDefaultImageGenerationModel = async (token: string = '', model: string) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/models/default/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
model: model
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res.model;
};
export const imageGenerations = async (token: string = '', prompt: string) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/generations`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
prompt: prompt
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -1,9 +1,9 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
export const getBackendConfig = async () => { export const getBackendConfig = async () => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/`, { const res = await fetch(`${WEBUI_BASE_URL}/api/config`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -19,5 +19,180 @@ export const getBackendConfig = async () => {
return null; return null;
}); });
if (error) {
throw error;
}
return res; return res;
}; };
export const getChangelog = async () => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/changelog`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getVersionUpdates = async () => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/version/updates`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelFilterConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/config/model/filter`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateModelFilterConfig = async (
token: string,
enabled: boolean,
models: string[]
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/config/model/filter`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
enabled: enabled,
models: models
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getWebhookUrl = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res.url;
};
export const updateWebhookUrl = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
if (error) {
throw error;
}
return res.url;
};

View file

@ -0,0 +1,150 @@
import { LITELLM_API_BASE_URL } from '$lib/constants';
export const getLiteLLMModels = async (token: string = '') => {
let error = null;
const res = await fetch(`${LITELLM_API_BASE_URL}/v1/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
const models = Array.isArray(res) ? res : res?.data ?? null;
return models
? models
.map((model) => ({
id: model.id,
name: model.name ?? model.id,
external: true,
source: 'litellm'
}))
.sort((a, b) => {
return a.name.localeCompare(b.name);
})
: models;
};
export const getLiteLLMModelInfo = async (token: string = '') => {
let error = null;
const res = await fetch(`${LITELLM_API_BASE_URL}/model/info`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
const models = Array.isArray(res) ? res : res?.data ?? null;
return models;
};
type AddLiteLLMModelForm = {
name: string;
model: string;
api_base: string;
api_key: string;
rpm: string;
max_tokens: string;
};
export const addLiteLLMModel = async (token: string = '', payload: AddLiteLLMModelForm) => {
let error = null;
const res = await fetch(`${LITELLM_API_BASE_URL}/model/new`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
model_name: payload.name,
litellm_params: {
model: payload.model,
...(payload.api_base === '' ? {} : { api_base: payload.api_base }),
...(payload.api_key === '' ? {} : { api_key: payload.api_key }),
...(isNaN(parseInt(payload.rpm)) ? {} : { rpm: parseInt(payload.rpm) }),
...(payload.max_tokens === '' ? {} : { max_tokens: payload.max_tokens })
}
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
return res;
};
export const deleteLiteLLMModel = async (token: string = '', id: string) => {
let error = null;
const res = await fetch(`${LITELLM_API_BASE_URL}/model/delete`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
id: id
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = `LiteLLM: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
return res;
};

View file

@ -1,9 +1,9 @@
import { OLLAMA_API_BASE_URL } from '$lib/constants'; import { OLLAMA_API_BASE_URL } from '$lib/constants';
export const getOllamaAPIUrl = async (token: string = '') => { export const getOllamaUrls = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/url`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/urls`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -29,13 +29,13 @@ export const getOllamaAPIUrl = async (token: string = '') => {
throw error; throw error;
} }
return res.OLLAMA_API_BASE_URL; return res.OLLAMA_BASE_URLS;
}; };
export const updateOllamaAPIUrl = async (token: string = '', url: string) => { export const updateOllamaUrls = async (token: string = '', urls: string[]) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/url/update`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/urls/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -43,7 +43,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
...(token && { authorization: `Bearer ${token}` }) ...(token && { authorization: `Bearer ${token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
url: url urls: urls
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -64,13 +64,13 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
throw error; throw error;
} }
return res.OLLAMA_API_BASE_URL; return res.OLLAMA_BASE_URLS;
}; };
export const getOllamaVersion = async (token: string = '') => { export const getOllamaVersion = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/version`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -102,7 +102,7 @@ export const getOllamaVersion = async (token: string = '') => {
export const getOllamaModels = async (token: string = '') => { export const getOllamaModels = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/tags`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -128,23 +128,36 @@ export const getOllamaModels = async (token: string = '') => {
throw error; throw error;
} }
return (res?.models ?? []).sort((a, b) => { return (res?.models ?? [])
.map((model) => ({ id: model.model, name: model.name ?? model.model, ...model }))
.sort((a, b) => {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
}; };
export const generateTitle = async (token: string = '', model: string, prompt: string) => { // TODO: migrate to backend
export const generateTitle = async (
token: string = '',
template: string,
model: string,
prompt: string
) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { template = template.replace(/{{prompt}}/g, prompt);
console.log(template);
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
model: model, model: model,
prompt: `Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title': ${prompt}`, prompt: template,
stream: false stream: false
}) })
}) })
@ -174,10 +187,11 @@ export const generatePrompt = async (token: string = '', model: string, conversa
conversation = '[no existing conversation]'; conversation = '[no existing conversation]';
} }
const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -205,15 +219,43 @@ export const generatePrompt = async (token: string = '', model: string, conversa
return res; return res;
}; };
export const generateTextCompletion = async (token: string = '', model: string, text: string) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
prompt: text,
stream: true
})
}).catch((err) => {
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const generateChatCompletion = async (token: string = '', body: object) => { export const generateChatCompletion = async (token: string = '', body: object) => {
let controller = new AbortController(); let controller = new AbortController();
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/chat`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/chat`, {
signal: controller.signal, signal: controller.signal,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
@ -253,10 +295,11 @@ export const cancelChatCompletion = async (token: string = '', requestId: string
export const createModel = async (token: string, tagName: string, content: string) => { export const createModel = async (token: string, tagName: string, content: string) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/create`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -275,19 +318,23 @@ export const createModel = async (token: string, tagName: string, content: strin
return res; return res;
}; };
export const deleteModel = async (token: string, tagName: string) => { export const deleteModel = async (token: string, tagName: string, urlIdx: string | null = null) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/delete`, { const res = await fetch(
`${OLLAMA_API_BASE_URL}/api/delete${urlIdx !== null ? `/${urlIdx}` : ''}`,
{
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
name: tagName name: tagName
}) })
}) }
)
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw await res.json(); if (!res.ok) throw await res.json();
return res.json(); return res.json();
@ -298,7 +345,12 @@ export const deleteModel = async (token: string, tagName: string) => {
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
error = err.error; error = err;
if ('detail' in err) {
error = err.detail;
}
return null; return null;
}); });
@ -309,13 +361,14 @@ export const deleteModel = async (token: string, tagName: string) => {
return res; return res;
}; };
export const pullModel = async (token: string, tagName: string) => { export const pullModel = async (token: string, tagName: string, urlIdx: string | null = null) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/pull`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull${urlIdx !== null ? `/${urlIdx}` : ''}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -1,9 +1,9 @@
import { OPENAI_API_BASE_URL } from '$lib/constants'; import { OPENAI_API_BASE_URL } from '$lib/constants';
export const getOpenAIUrl = async (token: string = '') => { export const getOpenAIUrls = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/url`, { const res = await fetch(`${OPENAI_API_BASE_URL}/urls`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -29,13 +29,13 @@ export const getOpenAIUrl = async (token: string = '') => {
throw error; throw error;
} }
return res.OPENAI_API_BASE_URL; return res.OPENAI_API_BASE_URLS;
}; };
export const updateOpenAIUrl = async (token: string = '', url: string) => { export const updateOpenAIUrls = async (token: string = '', urls: string[]) => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/url/update`, { const res = await fetch(`${OPENAI_API_BASE_URL}/urls/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -43,7 +43,7 @@ export const updateOpenAIUrl = async (token: string = '', url: string) => {
...(token && { authorization: `Bearer ${token}` }) ...(token && { authorization: `Bearer ${token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
url: url urls: urls
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -64,13 +64,13 @@ export const updateOpenAIUrl = async (token: string = '', url: string) => {
throw error; throw error;
} }
return res.OPENAI_API_BASE_URL; return res.OPENAI_API_BASE_URLS;
}; };
export const getOpenAIKey = async (token: string = '') => { export const getOpenAIKeys = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/key`, { const res = await fetch(`${OPENAI_API_BASE_URL}/keys`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -96,13 +96,13 @@ export const getOpenAIKey = async (token: string = '') => {
throw error; throw error;
} }
return res.OPENAI_API_KEY; return res.OPENAI_API_KEYS;
}; };
export const updateOpenAIKey = async (token: string = '', key: string) => { export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/key/update`, { const res = await fetch(`${OPENAI_API_BASE_URL}/keys/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -110,7 +110,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => {
...(token && { authorization: `Bearer ${token}` }) ...(token && { authorization: `Bearer ${token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
key: key keys: keys
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -131,7 +131,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => {
throw error; throw error;
} }
return res.OPENAI_API_KEY; return res.OPENAI_API_KEYS;
}; };
export const getOpenAIModels = async (token: string = '') => { export const getOpenAIModels = async (token: string = '') => {
@ -150,7 +150,6 @@ export const getOpenAIModels = async (token: string = '') => {
return res.json(); return res.json();
}) })
.catch((err) => { .catch((err) => {
console.log(err);
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`; error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return []; return [];
}); });
@ -163,7 +162,7 @@ export const getOpenAIModels = async (token: string = '') => {
return models return models
? models ? models
.map((model) => ({ name: model.id, external: true })) .map((model) => ({ id: model.id, name: model.name ?? model.id, external: true }))
.sort((a, b) => { .sort((a, b) => {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}) })
@ -200,17 +199,21 @@ export const getOpenAIModelsDirect = async (
const models = Array.isArray(res) ? res : res?.data ?? null; const models = Array.isArray(res) ? res : res?.data ?? null;
return models return models
.map((model) => ({ name: model.id, external: true })) .map((model) => ({ id: model.id, name: model.name ?? model.id, external: true }))
.filter((model) => (base_url.includes('openai') ? model.name.includes('gpt') : true)) .filter((model) => (base_url.includes('openai') ? model.name.includes('gpt') : true))
.sort((a, b) => { .sort((a, b) => {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
}; };
export const generateOpenAIChatCompletion = async (token: string = '', body: object) => { export const generateOpenAIChatCompletion = async (
token: string = '',
body: object,
url: string = OPENAI_API_BASE_URL
) => {
let error = null; let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/chat/completions`, { const res = await fetch(`${url}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,

View file

@ -1,5 +1,161 @@
import { RAG_API_BASE_URL } from '$lib/constants'; import { RAG_API_BASE_URL } from '$lib/constants';
export const getRAGConfig = async (token: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
type ChunkConfigForm = {
chunk_size: number;
chunk_overlap: number;
};
type RAGConfigForm = {
pdf_extract_images: boolean;
chunk: ChunkConfigForm;
};
export const updateRAGConfig = async (token: string, payload: RAGConfigForm) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/config/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...payload
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getRAGTemplate = async (token: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/template`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res?.template ?? '';
};
export const getQuerySettings = async (token: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/query/settings`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
type QuerySettings = {
k: number | null;
template: string | null;
};
export const updateQuerySettings = async (token: string, settings: QuerySettings) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/query/settings/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...settings
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const uploadDocToVectorDB = async (token: string, collection_name: string, file: File) => { export const uploadDocToVectorDB = async (token: string, collection_name: string, file: File) => {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append('file', file);
@ -68,7 +224,7 @@ export const queryDoc = async (
token: string, token: string,
collection_name: string, collection_name: string,
query: string, query: string,
k: number k: number | null = null
) => { ) => {
let error = null; let error = null;
@ -105,7 +261,7 @@ export const queryCollection = async (
token: string, token: string,
collection_names: string, collection_names: string,
query: string, query: string,
k: number k: number | null = null
) => { ) => {
let error = null; let error = null;
@ -138,6 +294,32 @@ export const queryCollection = async (
return res; return res;
}; };
export const scanDocs = async (token: string) => {
let error = null;
const res = await fetch(`${RAG_API_BASE_URL}/scan`, {
method: 'GET',
headers: {
Accept: 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const resetVectorDB = async (token: string) => { export const resetVectorDB = async (token: string) => {
let error = null; let error = null;

View file

@ -21,3 +21,35 @@ export const getGravatarUrl = async (email: string) => {
return res; return res;
}; };
export const downloadDatabase = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/db/download`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.blob();
})
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'webui.db';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch((err) => {
console.log(err);
error = err;
return null;
});
};

View file

@ -1,8 +1,13 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
</script>
<div class=" text-center text-6xl mb-3">📄</div> <div class=" text-center text-6xl mb-3">📄</div>
<div class="text-center dark:text-white text-2xl font-semibold z-50">Add Files</div> <div class="text-center dark:text-white text-2xl font-semibold z-50">{$i18n.t('Add Files')}</div>
<slot <slot
><div class=" mt-2 text-center text-sm dark:text-gray-200 w-full"> ><div class=" mt-2 text-center text-sm dark:text-gray-200 w-full">
Drop any files here to add to the conversation {$i18n.t('Drop any files here to add to the conversation')}
</div> </div>
</slot> </slot>

View file

@ -0,0 +1,118 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { Confetti } from 'svelte-confetti';
import { WEBUI_NAME, config } from '$lib/stores';
import { WEBUI_VERSION } from '$lib/constants';
import { getChangelog } from '$lib/apis';
import Modal from './common/Modal.svelte';
const i18n = getContext('i18n');
export let show = false;
let changelog = null;
onMount(async () => {
const res = await getChangelog();
changelog = res;
});
</script>
<Modal bind:show>
<div class="px-5 py-4 dark:text-gray-300">
<div class="flex justify-between items-start">
<div class="text-xl font-bold">
{$i18n.t('Whats New in')}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex items-center mt-1">
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
<div class="text-sm dark:text-gray-200">
v{WEBUI_VERSION}
</div>
</div>
</div>
<hr class=" dark:border-gray-800" />
<div class=" w-full p-4 px-5">
<div class=" overflow-y-scroll max-h-80">
<div class="mb-3">
{#if changelog}
{#each Object.keys(changelog) as version}
<div class=" mb-3 pr-2">
<div class="font-bold text-xl mb-1 dark:text-white">
v{version} - {changelog[version].date}
</div>
<hr class=" dark:border-gray-800 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="">
<div
class="font-bold uppercase text-xs {section === 'added'
? 'text-white bg-blue-600'
: section === 'fixed'
? 'text-white bg-green-600'
: section === 'changed'
? 'text-white bg-yellow-600'
: section === 'removed'
? 'text-white bg-red-600'
: ''} w-fit px-3 rounded-full my-2.5"
>
{section}
</div>
<div class="my-2.5 px-1.5">
{#each Object.keys(changelog[version][section]) as item}
<div class="text-sm mb-2">
<div class="font-semibold uppercase">
{changelog[version][section][item].title}
</div>
<div class="mb-2 mt-1">{changelog[version][section][item].content}</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/each}
{/if}
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
on:click={() => {
localStorage.version = $config.version;
show = false;
}}
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
>
<span class="relative">{$i18n.t("Okay, Let's Go!")}</span>
</button>
</div>
</div>
</Modal>

View file

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte'; import { onMount, getContext } from 'svelte';
import { updateUserById } from '$lib/apis/users'; import { updateUserById } from '$lib/apis/users';
import Modal from '../common/Modal.svelte'; import Modal from '../common/Modal.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let show = false; export let show = false;
@ -42,7 +43,7 @@
<Modal size="sm" bind:show> <Modal size="sm" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4"> <div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center">Edit User</div> <div class=" text-lg font-medium self-center">{$i18n.t('Edit User')}</div>
<button <button
class="self-center" class="self-center"
on:click={() => { on:click={() => {
@ -84,7 +85,8 @@
<div class=" self-center capitalize font-semibold">{selectedUser.name}</div> <div class=" self-center capitalize font-semibold">{selectedUser.name}</div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
Created at {dayjs(selectedUser.timestamp * 1000).format('MMMM DD, YYYY')} {$i18n.t('Created at')}
{dayjs(selectedUser.timestamp * 1000).format($i18n.t('MMMM DD, YYYY'))}
</div> </div>
</div> </div>
</div> </div>
@ -93,7 +95,7 @@
<div class=" flex flex-col space-y-1.5"> <div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">Email</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1"> <div class="flex-1">
<input <input
@ -108,7 +110,7 @@
</div> </div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">Name</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1"> <div class="flex-1">
<input <input
@ -122,7 +124,7 @@
</div> </div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">New Password</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
<div class="flex-1"> <div class="flex-1">
<input <input
@ -140,7 +142,7 @@
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded" class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit" type="submit"
> >
Save {$i18n.t('Save')}
</button> </button>
</div> </div>
</form> </form>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { downloadDatabase } from '$lib/apis/utils';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
export let saveHandler: Function;
onMount(async () => {
// permissions = await getUserPermissions(localStorage.token);
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
saveHandler();
}}
>
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Database')}</div>
<div class=" flex w-full justify-between">
<!-- <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div> -->
<button
class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
type="button"
on:click={() => {
// exportAllUserChats();
downloadDatabase(localStorage.token);
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM8.75 7.75a.75.75 0 0 0-1.5 0v2.69L6.03 9.22a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06l-1.22 1.22V7.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Download Database')}</div>
</button>
</div>
</div>
</div>
<!-- <div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit"
>
Save
</button>
</div> -->
</form>

View file

@ -1,15 +1,23 @@
<script lang="ts"> <script lang="ts">
import { getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { import {
getDefaultUserRole, getDefaultUserRole,
getJWTExpiresDuration,
getSignUpEnabledStatus, getSignUpEnabledStatus,
toggleSignUpEnabledStatus, toggleSignUpEnabledStatus,
updateDefaultUserRole updateDefaultUserRole,
updateJWTExpiresDuration
} from '$lib/apis/auths'; } from '$lib/apis/auths';
import { onMount } from 'svelte'; import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
export let saveHandler: Function; export let saveHandler: Function;
let signUpEnabled = true; let signUpEnabled = true;
let defaultUserRole = 'pending'; let defaultUserRole = 'pending';
let JWTExpiresIn = '';
let webhookUrl = '';
const toggleSignUpEnabled = async () => { const toggleSignUpEnabled = async () => {
signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token); signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token);
@ -19,25 +27,36 @@
defaultUserRole = await updateDefaultUserRole(localStorage.token, role); defaultUserRole = await updateDefaultUserRole(localStorage.token, role);
}; };
const updateJWTExpiresDurationHandler = async (duration) => {
JWTExpiresIn = await updateJWTExpiresDuration(localStorage.token, duration);
};
const updateWebhookUrlHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
};
onMount(async () => { onMount(async () => {
signUpEnabled = await getSignUpEnabledStatus(localStorage.token); signUpEnabled = await getSignUpEnabledStatus(localStorage.token);
defaultUserRole = await getDefaultUserRole(localStorage.token); defaultUserRole = await getDefaultUserRole(localStorage.token);
JWTExpiresIn = await getJWTExpiresDuration(localStorage.token);
webhookUrl = await getWebhookUrl(localStorage.token);
}); });
</script> </script>
<form <form
class="flex flex-col h-full justify-between space-y-3 text-sm" class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
// console.log('submit'); updateJWTExpiresDurationHandler(JWTExpiresIn);
updateWebhookUrlHandler();
saveHandler(); saveHandler();
}} }}
> >
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80"> <div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<div> <div>
<div class=" mb-2 text-sm font-medium">General Settings</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('General Settings')}</div>
<div class=" flex w-full justify-between"> <div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">Enable New Sign Ups</div> <div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<button <button
class="p-1 px-3 text-xs flex rounded transition" class="p-1 px-3 text-xs flex rounded transition"
@ -57,7 +76,7 @@
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z" d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/> />
</svg> </svg>
<span class="ml-2 self-center">Enabled</span> <span class="ml-2 self-center">{$i18n.t('Enabled')}</span>
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -72,28 +91,69 @@
/> />
</svg> </svg>
<span class="ml-2 self-center">Disabled</span> <span class="ml-2 self-center">{$i18n.t('Disabled')}</span>
{/if} {/if}
</button> </button>
</div> </div>
<div class=" flex w-full justify-between"> <div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">Default User Role</div> <div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select <select
class="w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right" class="dark:bg-gray-900 w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right"
bind:value={defaultUserRole} bind:value={defaultUserRole}
placeholder="Select a theme" placeholder="Select a theme"
on:change={(e) => { on:change={(e) => {
updateDefaultUserRoleHandler(e.target.value); updateDefaultUserRoleHandler(e.target.value);
}} }}
> >
<option value="pending">Pending</option> <option value="pending">{$i18n.t('pending')}</option>
<option value="user">User</option> <option value="user">{$i18n.t('user')}</option>
<option value="admin">Admin</option> <option value="admin">{$i18n.t('admin')}</option>
</select> </select>
</div> </div>
</div> </div>
<hr class=" dark:border-gray-700 my-3" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="text"
placeholder={`https://example.com/webhook`}
bind:value={webhookUrl}
/>
</div>
</div>
<hr class=" dark:border-gray-700 my-3" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={JWTExpiresIn}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
</div>
</div> </div>
</div> </div>
@ -102,7 +162,7 @@
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded" class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit" type="submit"
> >
Save {$i18n.t('Save')}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,10 +1,17 @@
<script lang="ts"> <script lang="ts">
import { getModelFilterConfig, updateModelFilterConfig } from '$lib/apis';
import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths'; import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
import { getUserPermissions, updateUserPermissions } from '$lib/apis/users'; import { getUserPermissions, updateUserPermissions } from '$lib/apis/users';
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
const i18n = getContext('i18n');
export let saveHandler: Function; export let saveHandler: Function;
let whitelistEnabled = false;
let whitelistModels = [''];
let permissions = { let permissions = {
chat: { chat: {
deletion: true deletion: true
@ -13,6 +20,13 @@
onMount(async () => { onMount(async () => {
permissions = await getUserPermissions(localStorage.token); permissions = await getUserPermissions(localStorage.token);
const res = await getModelFilterConfig(localStorage.token);
if (res) {
whitelistEnabled = res.enabled;
whitelistModels = res.models.length > 0 ? res.models : [''];
}
}); });
</script> </script>
@ -21,15 +35,17 @@
on:submit|preventDefault={async () => { on:submit|preventDefault={async () => {
// console.log('submit'); // console.log('submit');
await updateUserPermissions(localStorage.token, permissions); await updateUserPermissions(localStorage.token, permissions);
await updateModelFilterConfig(localStorage.token, whitelistEnabled, whitelistModels);
saveHandler(); saveHandler();
}} }}
> >
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80"> <div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<div> <div>
<div class=" mb-2 text-sm font-medium">User Permissions</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('User Permissions')}</div>
<div class=" flex w-full justify-between"> <div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">Allow Chat Deletion</div> <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div>
<button <button
class="p-1 px-3 text-xs flex rounded transition" class="p-1 px-3 text-xs flex rounded transition"
@ -49,7 +65,7 @@
d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z" d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
/> />
</svg> </svg>
<span class="ml-2 self-center">Allow</span> <span class="ml-2 self-center">{$i18n.t('Allow')}</span>
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -64,11 +80,112 @@
/> />
</svg> </svg>
<span class="ml-2 self-center">Don't Allow</span> <span class="ml-2 self-center">{$i18n.t("Don't Allow")}</span>
{/if} {/if}
</button> </button>
</div> </div>
</div> </div>
<hr class=" dark:border-gray-700 my-2" />
<div class="mt-2 space-y-3 pr-1.5">
<div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">{$i18n.t('Manage Models')}</div>
</div>
</div>
<div class=" space-y-3">
<div>
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Model Whitelisting')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
whitelistEnabled = !whitelistEnabled;
}}>{whitelistEnabled ? $i18n.t('On') : $i18n.t('Off')}</button
>
</div>
</div>
{#if whitelistEnabled}
<div>
<div class=" space-y-1.5">
{#each whitelistModels as modelId, modelIdx}
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={modelId}
placeholder="Select a model"
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</div>
{#if modelIdx === 0}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
if (whitelistModels.at(-1) !== '') {
whitelistModels = [...whitelistModels, ''];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-900 dark:text-white rounded-lg transition"
type="button"
on:click={() => {
whitelistModels.splice(modelIdx, 1);
whitelistModels = whitelistModels;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
<div class="flex justify-end items-center text-xs mt-1.5 text-right">
<div class=" text-xs font-medium">
{whitelistModels.length}
{$i18n.t('Model(s) Whitelisted')}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div> </div>
<div class="flex justify-end pt-3 text-sm font-medium"> <div class="flex justify-end pt-3 text-sm font-medium">
@ -76,7 +193,7 @@
class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded" class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
type="submit" type="submit"
> >
Save {$i18n.t('Save')}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,9 +1,13 @@
<script> <script>
import { getContext } from 'svelte';
import Modal from '../common/Modal.svelte'; import Modal from '../common/Modal.svelte';
import Database from './Settings/Database.svelte';
import General from './Settings/General.svelte'; import General from './Settings/General.svelte';
import Users from './Settings/Users.svelte'; import Users from './Settings/Users.svelte';
const i18n = getContext('i18n');
export let show = false; export let show = false;
let selectedTab = 'general'; let selectedTab = 'general';
@ -12,7 +16,7 @@
<Modal bind:show> <Modal bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4"> <div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center">Admin Settings</div> <div class=" text-lg font-medium self-center">{$i18n.t('Admin Settings')}</div>
<button <button
class="self-center" class="self-center"
on:click={() => { on:click={() => {
@ -60,7 +64,7 @@
/> />
</svg> </svg>
</div> </div>
<div class=" self-center">General</div> <div class=" self-center">{$i18n.t('General')}</div>
</button> </button>
<button <button
@ -84,7 +88,35 @@
/> />
</svg> </svg>
</div> </div>
<div class=" self-center">Users</div> <div class=" self-center">{$i18n.t('Users')}</div>
</button>
<button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'db'
? 'bg-gray-200 dark:bg-gray-700'
: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
on:click={() => {
selectedTab = 'db';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M8 7c3.314 0 6-1.343 6-3s-2.686-3-6-3-6 1.343-6 3 2.686 3 6 3Z" />
<path
d="M8 8.5c1.84 0 3.579-.37 4.914-1.037A6.33 6.33 0 0 0 14 6.78V8c0 1.657-2.686 3-6 3S2 9.657 2 8V6.78c.346.273.72.5 1.087.683C4.42 8.131 6.16 8.5 8 8.5Z"
/>
<path
d="M8 12.5c1.84 0 3.579-.37 4.914-1.037.366-.183.74-.41 1.086-.684V12c0 1.657-2.686 3-6 3s-6-1.343-6-3v-1.22c.346.273.72.5 1.087.683C4.42 12.131 6.16 12.5 8 12.5Z"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Database')}</div>
</button> </button>
</div> </div>
<div class="flex-1 md:min-h-[380px]"> <div class="flex-1 md:min-h-[380px]">
@ -100,6 +132,12 @@
show = false; show = false;
}} }}
/> />
{:else if selectedTab === 'db'}
<Database
saveHandler={() => {
show = false;
}}
/>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
import { onMount, tick } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { settings } from '$lib/stores'; import { settings } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
@ -12,13 +12,16 @@
import Documents from './MessageInput/Documents.svelte'; import Documents from './MessageInput/Documents.svelte';
import Models from './MessageInput/Models.svelte'; import Models from './MessageInput/Models.svelte';
import { transcribeAudio } from '$lib/apis/audio'; import { transcribeAudio } from '$lib/apis/audio';
import Tooltip from '../common/Tooltip.svelte';
const i18n = getContext('i18n');
export let submitPrompt: Function; export let submitPrompt: Function;
export let stopResponse: Function; export let stopResponse: Function;
export let suggestionPrompts = []; export let suggestionPrompts = [];
export let autoScroll = true; export let autoScroll = true;
let chatTextAreaElement: HTMLTextAreaElement;
let filesInputElement; let filesInputElement;
let promptsElement; let promptsElement;
@ -42,11 +45,9 @@
let speechRecognition; let speechRecognition;
$: if (prompt) { $: if (prompt) {
const chatInput = document.getElementById('chat-textarea'); if (chatTextAreaElement) {
chatTextAreaElement.style.height = '';
if (chatInput) { chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
chatInput.style.height = '';
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
} }
} }
@ -85,9 +86,7 @@
if (res) { if (res) {
prompt = res.text; prompt = res.text;
await tick(); await tick();
chatTextAreaElement?.focus();
const inputElement = document.getElementById('chat-textarea');
inputElement?.focus();
if (prompt !== '' && $settings?.speechAutoSend === true) { if (prompt !== '' && $settings?.speechAutoSend === true) {
submitPrompt(prompt, user); submitPrompt(prompt, user);
@ -190,8 +189,7 @@
prompt = `${prompt}${transcript}`; prompt = `${prompt}${transcript}`;
await tick(); await tick();
const inputElement = document.getElementById('chat-textarea'); chatTextAreaElement?.focus();
inputElement?.focus();
// Restart the inactivity timeout // Restart the inactivity timeout
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
@ -213,11 +211,11 @@
// Event triggered when an error occurs // Event triggered when an error occurs
speechRecognition.onerror = function (event) { speechRecognition.onerror = function (event) {
console.log(event); console.log(event);
toast.error(`Speech recognition error: ${event.error}`); toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
isRecording = false; isRecording = false;
}; };
} else { } else {
toast.error('SpeechRecognition API is not supported in this browser.'); toast.error($i18n.t('SpeechRecognition API is not supported in this browser.'));
} }
} }
} }
@ -293,6 +291,8 @@
}; };
onMount(() => { onMount(() => {
window.setTimeout(() => chatTextAreaElement?.focus(), 0);
const dropZone = document.querySelector('body'); const dropZone = document.querySelector('body');
const onDragOver = (e) => { const onDragOver = (e) => {
@ -335,12 +335,15 @@
uploadDoc(file); uploadDoc(file);
} else { } else {
toast.error( toast.error(
`Unknown File Type '${file['type']}', but accepting and treating as plain text` $i18n.t(
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
{ file_type: file['type'] }
)
); );
uploadDoc(file); uploadDoc(file);
} }
} else { } else {
toast.error(`File not found.`); toast.error($i18n.t(`File not found.`));
} }
} }
@ -361,12 +364,12 @@
{#if dragged} {#if dragged}
<div <div
class="fixed w-full h-full flex z-50 touch-none pointer-events-none" class="fixed lg:w-[calc(100%-260px)] w-full h-full flex z-50 touch-none pointer-events-none"
id="dropzone" id="dropzone"
role="region" role="region"
aria-label="Drag and Drop Container" aria-label="Drag and Drop Container"
> >
<div class="absolute rounded-xl w-full h-full backdrop-blur bg-gray-800/40 flex justify-center"> <div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
<div class="m-auto pt-64 flex flex-col justify-center"> <div class="m-auto pt-64 flex flex-col justify-center">
<div class="max-w-md"> <div class="max-w-md">
<AddFilesPlaceholder /> <AddFilesPlaceholder />
@ -479,18 +482,21 @@
filesInputElement.value = ''; filesInputElement.value = '';
} else { } else {
toast.error( toast.error(
`Unknown File Type '${file['type']}', but accepting and treating as plain text` $i18n.t(
`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
{ file_type: file['type'] }
)
); );
uploadDoc(file); uploadDoc(file);
filesInputElement.value = ''; filesInputElement.value = '';
} }
} else { } else {
toast.error(`File not found.`); toast.error($i18n.t(`File not found.`));
} }
}} }}
/> />
<form <form
class=" flex flex-col relative w-full rounded-xl border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-gray-100" class=" flex flex-col relative w-full rounded-3xl px-1.5 border border-gray-100 dark:border-gray-850 bg-white dark:bg-gray-900 dark:text-gray-100"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
submitPrompt(prompt, user); submitPrompt(prompt, user);
}} }}
@ -572,7 +578,7 @@
{file.name} {file.name}
</div> </div>
<div class=" text-gray-500 text-sm">Document</div> <div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
</div> </div>
</div> </div>
{:else if file.type === 'collection'} {:else if file.type === 'collection'}
@ -600,7 +606,7 @@
{file?.title ?? `#${file.name}`} {file?.title ?? `#${file.name}`}
</div> </div>
<div class=" text-gray-500 text-sm">Collection</div> <div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -633,9 +639,10 @@
<div class=" flex"> <div class=" flex">
{#if fileUploadEnabled} {#if fileUploadEnabled}
<div class=" self-end mb-2 ml-1.5"> <div class=" self-end mb-2 ml-1">
<Tooltip content={$i18n.t('Upload files')}>
<button <button
class=" text-gray-600 dark:text-gray-200 transition rounded-lg p-1 ml-1" class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
type="button" type="button"
on:click={() => { on:click={() => {
filesInputElement.click(); filesInputElement.click();
@ -643,30 +650,30 @@
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 16 16"
fill="currentColor" fill="currentColor"
class="w-5 h-5" class="w-[1.2rem] h-[1.2rem]"
> >
<path <path
fill-rule="evenodd" d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z"
clip-rule="evenodd"
/> />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
{/if} {/if}
<textarea <textarea
id="chat-textarea" id="chat-textarea"
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-2 {fileUploadEnabled bind:this={chatTextAreaElement}
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
? '' ? ''
: ' pl-4'} rounded-xl resize-none h-[48px]" : ' pl-4'} rounded-xl resize-none h-[48px]"
placeholder={chatInputPlaceholder !== '' placeholder={chatInputPlaceholder !== ''
? chatInputPlaceholder ? chatInputPlaceholder
: isRecording : isRecording
? 'Listening...' ? $i18n.t('Listening...')
: 'Send a message'} : $i18n.t('Send a Message')}
bind:value={prompt} bind:value={prompt}
on:keypress={(e) => { on:keypress={(e) => {
if (e.keyCode == 13 && !e.shiftKey) { if (e.keyCode == 13 && !e.shiftKey) {
@ -803,12 +810,13 @@
}} }}
/> />
<div class="self-end mb-2 flex space-x-0.5 mr-2"> <div class="self-end mb-2 flex space-x-1 mr-1">
{#if messages.length == 0 || messages.at(-1).done == true} {#if messages.length == 0 || messages.at(-1).done == true}
<Tooltip content={$i18n.t('Record voice')}>
{#if speechRecognitionEnabled} {#if speechRecognitionEnabled}
<button <button
id="voice-input-button" id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 transition rounded-lg p-1.5 mr-0.5 self-center" class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
type="button" type="button"
on:click={() => { on:click={() => {
speechRecognitionHandler(); speechRecognitionHandler();
@ -849,7 +857,12 @@
cx="12" cx="12"
cy="12" cy="12"
r="2.5" r="2.5"
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="2.5" /></svg /><circle
class="spinner_qM83 spinner_ZTLf"
cx="20"
cy="12"
r="2.5"
/></svg
> >
{:else} {:else}
<svg <svg
@ -866,10 +879,13 @@
{/if} {/if}
</button> </button>
{/if} {/if}
</Tooltip>
<Tooltip content={$i18n.t('Send message')}>
<button <button
class="{prompt !== '' class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-100 dark:text-gray-900 dark:bg-gray-800 disabled'} transition rounded-lg p-1 mr-0.5 w-7 h-7 self-center" : 'text-white bg-gray-100 dark:text-gray-900 dark:bg-gray-800 disabled'} transition rounded-full p-1.5 self-center"
type="submit" type="submit"
disabled={prompt === ''} disabled={prompt === ''}
> >
@ -877,7 +893,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="currentColor" fill="currentColor"
class="w-4.5 h-4.5 mx-auto" class="w-5 h-5"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
@ -886,9 +902,10 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
{:else} {:else}
<button <button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-lg p-1.5" class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={stopResponse} on:click={stopResponse}
> >
<svg <svg
@ -910,7 +927,7 @@
</form> </form>
<div class="mt-1.5 text-xs text-gray-500 text-center"> <div class="mt-1.5 text-xs text-gray-500 text-center">
LLMs can make mistakes. Verify important information. {$i18n.t('LLMs can make mistakes. Verify important information.')}
</div> </div>
</div> </div>
</div> </div>

View file

@ -3,8 +3,10 @@
import { documents } from '$lib/stores'; import { documents } from '$lib/stores';
import { removeFirstHashWord, isValidHttpUrl } from '$lib/utils'; import { removeFirstHashWord, isValidHttpUrl } from '$lib/utils';
import { tick } from 'svelte'; import { tick, getContext } from 'svelte';
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
export let prompt = ''; export let prompt = '';
@ -89,16 +91,16 @@
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full rounded-lg border border-gray-100 dark:border-gray-700"> <div class="flex w-full px-2">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-lg text-center"> <div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center">
<div class=" text-lg font-semibold mt-2">#</div> <div class=" text-lg font-semibold mt-2">#</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-lg"> <div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class=" overflow-y-auto bg-white p-2 rounded-tr-lg space-y-0.5"> <div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
{#each filteredItems as doc, docIdx} {#each filteredItems as doc, docIdx}
<button <button
class=" px-3 py-1.5 rounded-lg w-full text-left {docIdx === selectedIdx class=" px-3 py-1.5 rounded-xl w-full text-left {docIdx === selectedIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-100 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
@ -117,7 +119,7 @@
{doc?.title ?? `#${doc.name}`} {doc?.title ?? `#${doc.name}`}
</div> </div>
<div class=" text-xs text-gray-600 line-clamp-1">Collection</div> <div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Collection')}</div>
{:else} {:else}
<div class=" font-medium text-black line-clamp-1"> <div class=" font-medium text-black line-clamp-1">
#{doc.name} ({doc.filename}) #{doc.name} ({doc.filename})
@ -132,7 +134,7 @@
{#if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<button <button
class="px-3 py-1.5 rounded-lg w-full text-left bg-gray-100 selected-command-option-button" class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-100 selected-command-option-button"
type="button" type="button"
on:click={() => { on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1); const url = prompt.split(' ')?.at(0)?.substring(1);
@ -140,7 +142,9 @@
confirmSelectWeb(url); confirmSelectWeb(url);
} else { } else {
toast.error( toast.error(
$i18n.t(
'Oops! Looks like the URL is invalid. Please double-check and try again.' 'Oops! Looks like the URL is invalid. Please double-check and try again.'
)
); );
} }
}} }}
@ -149,7 +153,7 @@
{prompt.split(' ')?.at(0)?.substring(1)} {prompt.split(' ')?.at(0)?.substring(1)}
</div> </div>
<div class=" text-xs text-gray-600 line-clamp-1">Web</div> <div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>
</button> </button>
{/if} {/if}
</div> </div>

View file

@ -2,8 +2,10 @@
import { generatePrompt } from '$lib/apis/ollama'; import { generatePrompt } from '$lib/apis/ollama';
import { models } from '$lib/stores'; import { models } from '$lib/stores';
import { splitStream } from '$lib/utils'; import { splitStream } from '$lib/utils';
import { tick } from 'svelte'; import { tick, getContext } from 'svelte';
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
export let prompt = ''; export let prompt = '';
export let user = null; export let user = null;
@ -41,7 +43,7 @@
user = JSON.parse(JSON.stringify(model.name)); user = JSON.parse(JSON.stringify(model.name));
await tick(); await tick();
chatInputPlaceholder = `'${model.name}' is thinking...`; chatInputPlaceholder = $i18n.t('{{modelName}} is thinking...', { modelName: model.name });
const chatInputElement = document.getElementById('chat-textarea'); const chatInputElement = document.getElementById('chat-textarea');
@ -79,6 +81,9 @@
throw data; throw data;
} }
if ('id' in data) {
console.log(data);
} else {
if (data.done == false) { if (data.done == false) {
if (prompt == '' && data.response == '\n') { if (prompt == '' && data.response == '\n') {
continue; continue;
@ -91,6 +96,7 @@
} }
} }
} }
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
if ('detail' in error) { if ('detail' in error) {
@ -109,7 +115,9 @@
toast.error(error.error); toast.error(error.error);
} }
} else { } else {
toast.error(`Uh-oh! There was an issue connecting to Ollama.`); toast.error(
$i18n.t('Uh-oh! There was an issue connecting to {{provider}}.', { provider: 'llama' })
);
} }
} }
@ -121,16 +129,16 @@
{#if filteredModels.length > 0} {#if filteredModels.length > 0}
<div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full rounded-lg border border-gray-100 dark:border-gray-700"> <div class="flex w-full px-2">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-lg text-center"> <div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center">
<div class=" text-lg font-semibold mt-2">@</div> <div class=" text-lg font-semibold mt-2">@</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-lg"> <div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class=" overflow-y-auto bg-white p-2 rounded-tr-lg space-y-0.5"> <div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
{#each filteredModels as model, modelIdx} {#each filteredModels as model, modelIdx}
<button <button
class=" px-3 py-1.5 rounded-lg w-full text-left {modelIdx === selectedIdx class=" px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-100 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"

View file

@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import { prompts } from '$lib/stores'; import { prompts } from '$lib/stores';
import { findWordIndices } from '$lib/utils'; import { findWordIndices } from '$lib/utils';
import { tick } from 'svelte'; import { tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
export let prompt = ''; export let prompt = '';
let selectedCommandIdx = 0; let selectedCommandIdx = 0;
@ -24,7 +27,18 @@
}; };
const confirmCommand = async (command) => { const confirmCommand = async (command) => {
prompt = command.content; let text = command.content;
if (command.content.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
text = command.content.replaceAll('{{CLIPBOARD}}', clipboardText);
}
prompt = text;
const chatInputElement = document.getElementById('chat-textarea'); const chatInputElement = document.getElementById('chat-textarea');
@ -48,16 +62,16 @@
{#if filteredPromptCommands.length > 0} {#if filteredPromptCommands.length > 0}
<div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="md:px-2 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full rounded-lg border border-gray-100 dark:border-gray-700"> <div class="flex w-full px-2">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-lg text-center"> <div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center">
<div class=" text-lg font-semibold mt-2">/</div> <div class=" text-lg font-semibold mt-2">/</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-lg"> <div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class=" overflow-y-auto bg-white p-2 rounded-tr-lg space-y-0.5"> <div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
{#each filteredPromptCommands as command, commandIdx} {#each filteredPromptCommands as command, commandIdx}
<button <button
class=" px-3 py-1.5 rounded-lg w-full text-left {commandIdx === selectedCommandIdx class=" px-3 py-1.5 rounded-xl w-full text-left {commandIdx === selectedCommandIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-100 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
@ -81,7 +95,7 @@
</div> </div>
<div <div
class=" px-2 pb-1 text-xs text-gray-600 bg-white rounded-br-lg flex items-center space-x-1" class=" px-2 pb-1 text-xs text-gray-600 bg-white rounded-br-xl flex items-center space-x-1"
> >
<div> <div>
<svg <svg
@ -101,8 +115,9 @@
</div> </div>
<div class="line-clamp-1"> <div class="line-clamp-1">
Tip: Update multiple variable slots consecutively by pressing the tab key in the chat {$i18n.t(
input after each replacement. 'Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.'
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -10,13 +10,14 @@
: suggestionPrompts.sort(() => Math.random() - 0.5).slice(0, 4); : suggestionPrompts.sort(() => Math.random() - 0.5).slice(0, 4);
</script> </script>
<div class=" flex flex-wrap-reverse mb-3 md:p-1 text-left w-full"> <div class=" mb-3 md:p-1 text-left w-full">
<div class=" flex flex-wrap-reverse px-2 text-left">
{#each prompts as prompt, promptIdx} {#each prompts as prompt, promptIdx}
<div <div
class="{promptIdx > 1 ? 'hidden sm:inline-flex' : ''} basis-full sm:basis-1/2 p-[5px] px-2" class="{promptIdx > 1 ? 'hidden sm:inline-flex' : ''} basis-full sm:basis-1/2 p-[5px] px-1"
> >
<button <button
class=" flex-1 flex justify-between w-full h-full px-4 py-2.5 bg-white hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-700 outline outline-1 outline-gray-200 dark:outline-gray-800 rounded-lg transition group" class=" flex-1 flex justify-between w-full h-full px-4 py-2.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 rounded-2xl transition group"
on:click={() => { on:click={() => {
submitPrompt(prompt.content); submitPrompt(prompt.content);
}} }}
@ -33,7 +34,7 @@
</div> </div>
<div <div
class="self-center p-1 rounded-lg text-white group-hover:bg-gray-100 group-hover:text-gray-800 dark:group-hover:bg-gray-800 dark:group-hover:text-gray-100 dark:text-gray-900 transition" class="self-center p-1 rounded-lg text-gray-50 group-hover:text-gray-800 dark:text-gray-850 dark:group-hover:text-gray-100 transition"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -52,3 +53,4 @@
</div> </div>
{/each} {/each}
</div> </div>
</div>

View file

@ -2,15 +2,18 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { chats, config, modelfiles, settings, user } from '$lib/stores'; import { chats, config, modelfiles, settings, user } from '$lib/stores';
import { tick } from 'svelte'; import { tick, getContext } from 'svelte';
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
import { getChatList, updateChatById } from '$lib/apis/chats'; import { getChatList, updateChatById } from '$lib/apis/chats';
import UserMessage from './Messages/UserMessage.svelte'; import UserMessage from './Messages/UserMessage.svelte';
import ResponseMessage from './Messages/ResponseMessage.svelte'; import ResponseMessage from './Messages/ResponseMessage.svelte';
import Placeholder from './Messages/Placeholder.svelte'; import Placeholder from './Messages/Placeholder.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import { imageGenerations } from '$lib/apis/images';
const i18n = getContext('i18n');
export let chatId = ''; export let chatId = '';
export let sendPrompt: Function; export let sendPrompt: Function;
@ -66,7 +69,7 @@
navigator.clipboard.writeText(text).then( navigator.clipboard.writeText(text).then(
function () { function () {
console.log('Async: Copying to clipboard was successful!'); console.log('Async: Copying to clipboard was successful!');
toast.success('Copying to clipboard was successful!'); toast.success($i18n.t('Copying to clipboard was successful!'));
}, },
function (err) { function (err) {
console.error('Async: Could not copy text: ', err); console.error('Async: Could not copy text: ', err);
@ -221,6 +224,81 @@
scrollToBottom(); scrollToBottom();
}, 100); }, 100);
}; };
const messageDeleteHandler = async (messageId) => {
const messageToDelete = history.messages[messageId];
const messageParentId = messageToDelete.parentId;
const messageChildrenIds = messageToDelete.childrenIds ?? [];
const hasSibling = messageChildrenIds.some(
(childId) => history.messages[childId]?.childrenIds?.length > 0
);
messageChildrenIds.forEach((childId) => {
const child = history.messages[childId];
if (child && child.childrenIds) {
if (child.childrenIds.length === 0 && !hasSibling) {
// if last prompt/response pair
history.messages[messageParentId].childrenIds = [];
history.currentId = messageParentId;
} else {
child.childrenIds.forEach((grandChildId) => {
if (history.messages[grandChildId]) {
history.messages[grandChildId].parentId = messageParentId;
history.messages[messageParentId].childrenIds.push(grandChildId);
}
});
}
}
// remove response
history.messages[messageParentId].childrenIds = history.messages[
messageParentId
].childrenIds.filter((id) => id !== childId);
});
// remove prompt
history.messages[messageParentId].childrenIds = history.messages[
messageParentId
].childrenIds.filter((id) => id !== messageId);
await updateChatById(localStorage.token, chatId, {
messages: messages,
history: history
});
};
// const messageDeleteHandler = async (messageId) => {
// const message = history.messages[messageId];
// const parentId = message.parentId;
// const childrenIds = message.childrenIds ?? [];
// const grandchildrenIds = [];
// // Iterate through childrenIds to find grandchildrenIds
// for (const childId of childrenIds) {
// const childMessage = history.messages[childId];
// const grandChildrenIds = childMessage.childrenIds ?? [];
// for (const grandchildId of grandchildrenIds) {
// const childMessage = history.messages[grandchildId];
// childMessage.parentId = parentId;
// }
// grandchildrenIds.push(...grandChildrenIds);
// }
// history.messages[parentId].childrenIds.push(...grandchildrenIds);
// history.messages[parentId].childrenIds = history.messages[parentId].childrenIds.filter(
// (id) => id !== messageId
// );
// // Select latest message
// let currentMessageId = grandchildrenIds.at(-1);
// if (currentMessageId) {
// let messageChildrenIds = history.messages[currentMessageId].childrenIds;
// while (messageChildrenIds.length !== 0) {
// currentMessageId = messageChildrenIds.at(-1);
// messageChildrenIds = history.messages[currentMessageId].childrenIds;
// }
// history.currentId = currentMessageId;
// }
// await updateChatById(localStorage.token, chatId, { messages, history });
// };
</script> </script>
{#if messages.length == 0} {#if messages.length == 0}
@ -237,8 +315,10 @@
> >
{#if message.role === 'user'} {#if message.role === 'user'}
<UserMessage <UserMessage
on:delete={() => messageDeleteHandler(message.id)}
user={$user} user={$user}
{message} {message}
isFirstMessage={messageIdx === 0}
siblings={message.parentId !== null siblings={message.parentId !== null
? history.messages[message.parentId]?.childrenIds ?? [] ? history.messages[message.parentId]?.childrenIds ?? []
: Object.values(history.messages) : Object.values(history.messages)
@ -249,52 +329,6 @@
{showNextMessage} {showNextMessage}
{copyToClipboard} {copyToClipboard}
/> />
{#if messages.length - 1 === messageIdx && processing !== ''}
<div class="flex my-2.5 ml-12 items-center w-fit space-x-2.5">
<div class=" dark:text-blue-100">
<svg
class=" w-4 h-4 translate-y-[0.5px]"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
r="2.5"
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="2.5" /></svg
>
</div>
<div class=" text-sm font-medium">
{processing}
</div>
</div>
{/if}
{:else} {:else}
<ResponseMessage <ResponseMessage
{message} {message}
@ -308,6 +342,16 @@
{copyToClipboard} {copyToClipboard}
{continueGeneration} {continueGeneration}
{regenerateResponse} {regenerateResponse}
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} {/if}
</div> </div>

View file

@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { WEBUI_BASE_URL } from '$lib/constants';
import { user } from '$lib/stores';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
export let models = []; export let models = [];
export let modelfiles = []; export let modelfiles = [];
@ -27,14 +31,16 @@
> >
{#if model in modelfiles} {#if model in modelfiles}
<img <img
src={modelfiles[model]?.imageUrl ?? './favicon.png'} src={modelfiles[model]?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`}
alt="modelfile" alt="modelfile"
class=" w-14 rounded-full border-[1px] border-gray-200 dark:border-none" class=" w-14 rounded-full border-[1px] border-gray-200 dark:border-none"
draggable="false" draggable="false"
/> />
{:else} {:else}
<img <img
src={models.length === 1 ? '/favicon.png' : '/favicon.png'} src={models.length === 1
? `${WEBUI_BASE_URL}/static/favicon.png`
: `${WEBUI_BASE_URL}/static/favicon.png`}
class=" w-14 rounded-full border-[1px] border-gray-200 dark:border-none" class=" w-14 rounded-full border-[1px] border-gray-200 dark:border-none"
alt="logo" alt="logo"
draggable="false" draggable="false"
@ -44,7 +50,7 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class=" mt-2 text-2xl text-gray-800 dark:text-gray-100 font-semibold"> <div class=" mt-2 mb-5 text-2xl text-gray-800 dark:text-gray-100 font-semibold">
{#if modelfile} {#if modelfile}
<span class=" capitalize"> <span class=" capitalize">
{modelfile.title} {modelfile.title}
@ -60,7 +66,9 @@
</div> </div>
{/if} {/if}
{:else} {:else}
How can I help you today? <div class=" line-clamp-1">{$i18n.t('Hello, {{name}}', { name: $user.name })}</div>
<div>{$i18n.t('How can I help you today?')}</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,21 +1,31 @@
<script lang="ts"> <script lang="ts">
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { marked } from 'marked'; import { marked } from 'marked';
import { settings } from '$lib/stores';
import tippy from 'tippy.js'; import tippy from 'tippy.js';
import auto_render from 'katex/dist/contrib/auto-render.mjs'; import auto_render from 'katex/dist/contrib/auto-render.mjs';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { onMount, tick } from 'svelte'; import { fade } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
import { onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { config, settings } from '$lib/stores';
import { synthesizeOpenAISpeech } from '$lib/apis/openai';
import { imageGenerations } from '$lib/apis/images';
import { extractSentences } from '$lib/utils';
import Name from './Name.svelte'; import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte'; import ProfileImage from './ProfileImage.svelte';
import Skeleton from './Skeleton.svelte'; import Skeleton from './Skeleton.svelte';
import CodeBlock from './CodeBlock.svelte'; import CodeBlock from './CodeBlock.svelte';
import Image from '$lib/components/common/Image.svelte';
import { synthesizeOpenAISpeech } from '$lib/apis/openai'; import { WEBUI_BASE_URL } from '$lib/constants';
import { extractSentences } from '$lib/utils'; import Tooltip from '$lib/components/common/Tooltip.svelte';
export let modelfiles = []; export let modelfiles = [];
export let message; export let message;
@ -34,7 +44,7 @@
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
let editTextAreaElement: HTMLTextAreaElement;
let tooltipInstance = null; let tooltipInstance = null;
let sentencesAudio = {}; let sentencesAudio = {};
@ -42,6 +52,7 @@
let speakingIdx = null; let speakingIdx = null;
let loadingSpeech = false; let loadingSpeech = false;
let generatingImage = false;
$: tokens = marked.lexer(message.content); $: tokens = marked.lexer(message.content);
@ -72,13 +83,20 @@
if (message.info) { if (message.info) {
tooltipInstance = tippy(`#info-${message.id}`, { tooltipInstance = tippy(`#info-${message.id}`, {
content: `<span class="text-xs" id="tooltip-${message.id}">token/s: ${ content: `<span class="text-xs" id="tooltip-${message.id}">response_token/s: ${
`${ `${
Math.round( Math.round(
((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100 ((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
) / 100 ) / 100
} tokens` ?? 'N/A' } tokens` ?? 'N/A'
}<br/> }<br/>
prompt_token/s: ${
Math.round(
((message.info.prompt_eval_count ?? 0) /
(message.info.prompt_eval_duration / 1000000000)) *
100
) / 100 ?? 'N/A'
} tokens<br/>
total_duration: ${ total_duration: ${
Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ?? Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ??
'N/A' 'N/A'
@ -109,10 +127,11 @@
// customised options // customised options
// • auto-render specific keys, e.g.: // • auto-render specific keys, e.g.:
delimiters: [ delimiters: [
{ left: '$$', right: '$$', display: true }, { left: '$$', right: '$$', display: false },
// { left: '$', right: '$', display: false }, { left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: true }, { left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true } { left: '\\[', right: '\\]', display: false },
{ left: '[ ', right: ' ]', display: false }
], ],
// • rendering keys, e.g.: // • rendering keys, e.g.:
throwOnError: false throwOnError: false
@ -232,10 +251,9 @@
editedContent = message.content; editedContent = message.content;
await tick(); await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
editElement.style.height = ''; editTextAreaElement.style.height = '';
editElement.style.height = `${editElement.scrollHeight}px`; editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`;
}; };
const editMessageConfirmHandler = async () => { const editMessageConfirmHandler = async () => {
@ -259,6 +277,25 @@
renderStyling(); renderStyling();
}; };
const generateImage = async (message) => {
generatingImage = true;
const res = await imageGenerations(localStorage.token, message.content).catch((error) => {
toast.error(error);
});
console.log(res);
if (res) {
message.files = res.map((image) => ({
type: 'image',
url: `${image.url}`
}));
dispatch('save', message);
}
generatingImage = false;
};
onMount(async () => { onMount(async () => {
await tick(); await tick();
renderStyling(); renderStyling();
@ -267,7 +304,9 @@
{#key message.id} {#key message.id}
<div class=" flex w-full message-{message.id}"> <div class=" flex w-full message-{message.id}">
<ProfileImage src={modelfiles[message.model]?.imageUrl ?? '/favicon.png'} /> <ProfileImage
src={modelfiles[message.model]?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`}
/>
<div class="w-full overflow-hidden"> <div class="w-full overflow-hidden">
<Name> <Name>
@ -279,7 +318,7 @@
{#if message.timestamp} {#if message.timestamp}
<span class=" invisible group-hover:visible text-gray-400 text-xs font-medium"> <span class=" invisible group-hover:visible text-gray-400 text-xs font-medium">
{dayjs(message.timestamp * 1000).format('DD/MM/YYYY HH:mm')} {dayjs(message.timestamp * 1000).format($i18n.t('DD/MM/YYYY HH:mm'))}
</span> </span>
{/if} {/if}
</Name> </Name>
@ -287,17 +326,31 @@
{#if message.content === ''} {#if message.content === ''}
<Skeleton /> <Skeleton />
{:else} {:else}
{#if message.files}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} />
{/if}
</div>
{/each}
</div>
{/if}
<div <div
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-li:-mb-4 whitespace-pre-line" class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-ol:p-0 prose-li:-mb-4 whitespace-pre-line"
> >
<div> <div>
{#if edit === true} {#if edit === true}
<div class=" w-full"> <div class=" w-full">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-none w-full resize-none" class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent} bind:value={editedContent}
on:input={(e) => { on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`; e.target.style.height = `${e.target.scrollHeight}px`;
}} }}
/> />
@ -309,7 +362,7 @@
editMessageConfirmHandler(); editMessageConfirmHandler();
}} }}
> >
Save {$i18n.t('Save')}
</button> </button>
<button <button
@ -318,7 +371,7 @@
cancelEditMessage(); cancelEditMessage();
}} }}
> >
Cancel {$i18n.t('Cancel')}
</button> </button>
</div> </div>
</div> </div>
@ -366,10 +419,10 @@
{#if message.done} {#if message.done}
<div <div
class=" flex justify-start space-x-1 -mt-1 overflow-x-auto buttons text-gray-700 dark:text-gray-500" class=" flex justify-start space-x-1 overflow-x-auto buttons text-gray-700 dark:text-gray-500"
> >
{#if siblings.length > 1} {#if siblings.length > 1}
<div class="flex self-center min-w-fit"> <div class="flex self-center min-w-fit -mt-1">
<button <button
class="self-center dark:hover:text-white hover:text-black transition" class="self-center dark:hover:text-white hover:text-black transition"
on:click={() => { on:click={() => {
@ -416,6 +469,7 @@
</div> </div>
{/if} {/if}
<Tooltip content="Edit" placement="bottom">
<button <button
class="{isLastMessage class="{isLastMessage
? 'visible' ? 'visible'
@ -428,7 +482,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -439,7 +493,9 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
<Tooltip content="Copy" placement="bottom">
<button <button
class="{isLastMessage class="{isLastMessage
? 'visible' ? 'visible'
@ -452,7 +508,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -463,7 +519,9 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
<Tooltip content="Good Response" placement="bottom">
<button <button
class="{isLastMessage class="{isLastMessage
? 'visible' ? 'visible'
@ -488,6 +546,9 @@
/></svg /></svg
> >
</button> </button>
</Tooltip>
<Tooltip content="Bad Response" placement="bottom">
<button <button
class="{isLastMessage class="{isLastMessage
? 'visible' ? 'visible'
@ -512,7 +573,9 @@
/></svg /></svg
> >
</button> </button>
</Tooltip>
<Tooltip content="Read Aloud" placement="bottom">
<button <button
id="speak-button-{message.id}" id="speak-button-{message.id}"
class="{isLastMessage class="{isLastMessage
@ -552,14 +615,19 @@
cx="12" cx="12"
cy="12" cy="12"
r="3" r="3"
/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" /></svg /><circle
class="spinner_S1WN spinner_JApP"
cx="20"
cy="12"
r="3"
/></svg
> >
{:else if speaking} {:else if speaking}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -574,7 +642,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -586,8 +654,77 @@
</svg> </svg>
{/if} {/if}
</button> </button>
</Tooltip>
{#if $config.images}
<Tooltip content="Generate Image" placement="bottom">
<button
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
on:click={() => {
if (!generatingImage) {
generateImage(message);
}
}}
>
{#if generatingImage}
<svg
class=" w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_S1WN {
animation: spinner_MGfb 0.8s linear infinite;
animation-delay: -0.8s;
}
.spinner_Km9P {
animation-delay: -0.65s;
}
.spinner_JApP {
animation-delay: -0.5s;
}
@keyframes spinner_MGfb {
93.75%,
100% {
opacity: 0.2;
}
}
</style><circle class="spinner_S1WN" cx="4" cy="12" r="3" /><circle
class="spinner_S1WN spinner_Km9P"
cx="12"
cy="12"
r="3"
/><circle
class="spinner_S1WN spinner_JApP"
cx="20"
cy="12"
r="3"
/></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
/>
</svg>
{/if}
</button>
</Tooltip>
{/if}
{#if message.info} {#if message.info}
<Tooltip content="Generation Info" placement="bottom">
<button <button
class=" {isLastMessage class=" {isLastMessage
? 'visible' ? 'visible'
@ -601,7 +738,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -612,9 +749,11 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
{/if} {/if}
{#if isLastMessage} {#if isLastMessage}
<Tooltip content="Continue Response" placement="bottom">
<button <button
type="button" type="button"
class="{isLastMessage class="{isLastMessage
@ -628,7 +767,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -644,7 +783,9 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
<Tooltip content="Regenerate" placement="bottom">
<button <button
type="button" type="button"
class="{isLastMessage class="{isLastMessage
@ -656,7 +797,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -667,6 +808,7 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
{/if} {/if}
</div> </div>
{/if} {/if}

View file

@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { tick } from 'svelte'; import { tick, createEventDispatcher, getContext } from 'svelte';
import Name from './Name.svelte'; import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte'; import ProfileImage from './ProfileImage.svelte';
import { modelfiles, settings } from '$lib/stores'; import { modelfiles, settings } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let user; export let user;
export let message; export let message;
export let siblings; export let siblings;
export let isFirstMessage: boolean;
export let confirmEditMessage: Function; export let confirmEditMessage: Function;
export let showPreviousMessage: Function; export let showPreviousMessage: Function;
@ -17,18 +23,17 @@
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
let messageEditTextAreaElement: HTMLTextAreaElement;
const editMessageHandler = async () => { const editMessageHandler = async () => {
edit = true; edit = true;
editedContent = message.content; editedContent = message.content;
await tick(); await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
editElement.style.height = ''; messageEditTextAreaElement.style.height = '';
editElement.style.height = `${editElement.scrollHeight}px`; messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
editElement?.focus(); messageEditTextAreaElement?.focus();
}; };
const editMessageConfirmHandler = async () => { const editMessageConfirmHandler = async () => {
@ -42,6 +47,10 @@
edit = false; edit = false;
editedContent = ''; editedContent = '';
}; };
const deleteMessageHandler = async () => {
dispatch('delete', message.id);
};
</script> </script>
<div class=" flex w-full"> <div class=" flex w-full">
@ -58,17 +67,18 @@
{#if $modelfiles.map((modelfile) => modelfile.tagName).includes(message.user)} {#if $modelfiles.map((modelfile) => modelfile.tagName).includes(message.user)}
{$modelfiles.find((modelfile) => modelfile.tagName === message.user)?.title} {$modelfiles.find((modelfile) => modelfile.tagName === message.user)?.title}
{:else} {:else}
You <span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span> {$i18n.t('You')}
<span class=" text-gray-500 text-sm font-medium">{message?.user ?? ''}</span>
{/if} {/if}
{:else if $settings.showUsername} {:else if $settings.showUsername}
{user.name} {user.name}
{:else} {:else}
You {$i18n.t('You')}
{/if} {/if}
{#if message.timestamp} {#if message.timestamp}
<span class=" invisible group-hover:visible text-gray-400 text-xs font-medium"> <span class=" invisible group-hover:visible text-gray-400 text-xs font-medium">
{dayjs(message.timestamp * 1000).format('DD/MM/YYYY HH:mm')} {dayjs(message.timestamp * 1000).format($i18n.t('DD/MM/YYYY HH:mm'))}
</span> </span>
{/if} {/if}
</Name> </Name>
@ -116,7 +126,7 @@
{file.name} {file.name}
</div> </div>
<div class=" text-gray-500 text-sm">Document</div> <div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
</div> </div>
</button> </button>
{:else if file.type === 'collection'} {:else if file.type === 'collection'}
@ -145,7 +155,7 @@
{file?.title ?? `#${file.name}`} {file?.title ?? `#${file.name}`}
</div> </div>
<div class=" text-gray-500 text-sm">Collection</div> <div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
</div> </div>
</button> </button>
{/if} {/if}
@ -158,9 +168,11 @@
<div class=" w-full"> <div class=" w-full">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none" class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent} bind:value={editedContent}
on:input={(e) => { on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`; e.target.style.height = `${e.target.scrollHeight}px`;
}} }}
/> />
@ -172,7 +184,7 @@
editMessageConfirmHandler(); editMessageConfirmHandler();
}} }}
> >
Save & Submit {$i18n.t('Save & Submit')}
</button> </button>
<button <button
@ -181,7 +193,7 @@
cancelEditMessage(); cancelEditMessage();
}} }}
> >
Cancel {$i18n.t('Cancel')}
</button> </button>
</div> </div>
</div> </div>
@ -189,11 +201,11 @@
<div class="w-full"> <div class="w-full">
<pre id="user-message">{message.content}</pre> <pre id="user-message">{message.content}</pre>
<div class=" flex justify-start space-x-1"> <div class=" flex justify-start space-x-1 text-gray-700 dark:text-gray-500">
{#if siblings.length > 1} {#if siblings.length > 1}
<div class="flex self-center"> <div class="flex self-center -mt-1">
<button <button
class="self-center" class="self-center dark:hover:text-white hover:text-black transition"
on:click={() => { on:click={() => {
showPreviousMessage(message); showPreviousMessage(message);
}} }}
@ -212,12 +224,12 @@
</svg> </svg>
</button> </button>
<div class="text-xs font-bold self-center"> <div class="text-xs font-bold self-center dark:text-gray-100">
{siblings.indexOf(message.id) + 1} / {siblings.length} {siblings.indexOf(message.id) + 1} / {siblings.length}
</div> </div>
<button <button
class="self-center" class="self-center dark:hover:text-white hover:text-black transition"
on:click={() => { on:click={() => {
showNextMessage(message); showNextMessage(message);
}} }}
@ -238,8 +250,9 @@
</div> </div>
{/if} {/if}
<Tooltip content="Edit" placement="bottom">
<button <button
class="invisible group-hover:visible p-1 rounded dark:hover:text-white transition edit-user-message-button" class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition edit-user-message-button"
on:click={() => { on:click={() => {
editMessageHandler(); editMessageHandler();
}} }}
@ -248,7 +261,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -259,9 +272,11 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
<Tooltip content="Copy" placement="bottom">
<button <button
class="invisible group-hover:visible p-1 rounded dark:hover:text-white transition" class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
on:click={() => { on:click={() => {
copyToClipboard(message.content); copyToClipboard(message.content);
}} }}
@ -270,7 +285,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="2"
stroke="currentColor" stroke="currentColor"
class="w-4 h-4" class="w-4 h-4"
> >
@ -281,6 +296,33 @@
/> />
</svg> </svg>
</button> </button>
</Tooltip>
{#if !isFirstMessage}
<Tooltip content="Delete" placement="bottom">
<button
class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
on:click={() => {
deleteMessageHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { setDefaultModels } from '$lib/apis/configs'; import { setDefaultModels } from '$lib/apis/configs';
import { models, showSettings, settings, user } from '$lib/stores'; import { models, showSettings, settings, user } from '$lib/stores';
import { onMount, tick } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import toast from 'svelte-french-toast'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
export let selectedModels = ['']; export let selectedModels = [''];
export let disabled = false; export let disabled = false;
@ -10,7 +12,7 @@
const saveDefaultModel = async () => { const saveDefaultModel = async () => {
const hasEmptyModel = selectedModels.filter((it) => it === ''); const hasEmptyModel = selectedModels.filter((it) => it === '');
if (hasEmptyModel.length) { if (hasEmptyModel.length) {
toast.error('Choose a model before saving...'); toast.error($i18n.t('Choose a model before saving...'));
return; return;
} }
settings.set({ ...$settings, models: selectedModels }); settings.set({ ...$settings, models: selectedModels });
@ -20,12 +22,12 @@
console.log('setting default models globally'); console.log('setting default models globally');
await setDefaultModels(localStorage.token, selectedModels.join(',')); await setDefaultModels(localStorage.token, selectedModels.join(','));
} }
toast.success('Default model updated'); toast.success($i18n.t('Default model updated'));
}; };
$: if (selectedModels.length > 0 && $models.length > 0) { $: if (selectedModels.length > 0 && $models.length > 0) {
selectedModels = selectedModels.map((model) => selectedModels = selectedModels.map((model) =>
$models.map((m) => m.name).includes(model) ? model : '' $models.map((m) => m.id).includes(model) ? model : ''
); );
} }
</script> </script>
@ -39,13 +41,15 @@
bind:value={selectedModel} bind:value={selectedModel}
{disabled} {disabled}
> >
<option class=" text-gray-700" value="" selected disabled>Select a model</option> <option class=" text-gray-700" value="" selected disabled
>{$i18n.t('Select a model')}</option
>
{#each $models as model} {#each $models as model}
{#if model.name === 'hr'} {#if model.name === 'hr'}
<hr /> <hr />
{:else} {:else}
<option value={model.name} class="text-gray-700 text-lg" <option value={model.id} class="text-gray-700 text-lg"
>{model.name + >{model.name +
`${model.size ? ` (${(model.size / 1024 ** 3).toFixed(1)}GB)` : ''}`}</option `${model.size ? ` (${(model.size / 1024 ** 3).toFixed(1)}GB)` : ''}`}</option
> >
@ -133,5 +137,5 @@
</div> </div>
<div class="text-left mt-1.5 text-xs text-gray-500"> <div class="text-left mt-1.5 text-xs text-gray-500">
<button on:click={saveDefaultModel}> Set as default</button> <button on:click={saveDefaultModel}> {$i18n.t('Set as default')}</button>
</div> </div>

View file

@ -1,38 +1,104 @@
<script lang="ts"> <script lang="ts">
import { getVersionUpdates } from '$lib/apis';
import { getOllamaVersion } from '$lib/apis/ollama'; import { getOllamaVersion } from '$lib/apis/ollama';
import { WEBUI_NAME, WEB_UI_VERSION } from '$lib/constants'; import { WEBUI_VERSION } from '$lib/constants';
import { config } from '$lib/stores'; import { WEBUI_NAME, config, showChangelog } from '$lib/stores';
import { onMount } from 'svelte'; import { compareVersion } from '$lib/utils';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
let ollamaVersion = ''; let ollamaVersion = '';
let updateAvailable = null;
let version = {
current: '',
latest: ''
};
const checkForVersionUpdates = async () => {
updateAvailable = null;
version = await getVersionUpdates(localStorage.token).catch((error) => {
return {
current: WEBUI_VERSION,
latest: WEBUI_VERSION
};
});
console.log(version);
updateAvailable = compareVersion(version.latest, version.current);
console.log(updateAvailable);
};
onMount(async () => { onMount(async () => {
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => { ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
return ''; return '';
}); });
checkForVersionUpdates();
}); });
</script> </script>
<div class="flex flex-col h-full justify-between space-y-3 text-sm mb-6"> <div class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
<div class=" space-y-3"> <div class=" space-y-3">
<div> <div>
<div class=" mb-2.5 text-sm font-medium">{WEBUI_NAME} Version</div> <div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center">
<div class="flex w-full"> <div>
<div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> {$WEBUI_NAME}
{$config && $config.version ? $config.version : WEB_UI_VERSION} {$i18n.t('Version')}
</div> </div>
</div> </div>
<div class="flex w-full justify-between items-center">
<div class="flex flex-col text-xs text-gray-700 dark:text-gray-200">
<div>
v{WEBUI_VERSION}
<a
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
target="_blank"
>
{updateAvailable === null
? $i18n.t('Checking for updates...')
: updateAvailable
? `(v${version.latest} ${$i18n.t('available!')})`
: $i18n.t('(latest)')}
</a>
</div>
<button
class=" underline flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500"
on:click={() => {
showChangelog.set(true);
}}
>
<div>{$i18n.t("See what's new")}</div>
</button>
</div>
<button
class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
on:click={() => {
checkForVersionUpdates();
}}
>
{$i18n.t('Check for updates')}
</button>
</div>
</div> </div>
{#if ollamaVersion}
<hr class=" dark:border-gray-700" /> <hr class=" dark:border-gray-700" />
<div> <div>
<div class=" mb-2.5 text-sm font-medium">Ollama Version</div> <div class=" mb-2.5 text-sm font-medium">{$i18n.t('Ollama Version')}</div>
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> <div class="flex-1 text-xs text-gray-700 dark:text-gray-200">
{ollamaVersion ?? 'N/A'} {ollamaVersion ?? 'N/A'}
</div> </div>
</div> </div>
</div> </div>
{/if}
<hr class=" dark:border-gray-700" /> <hr class=" dark:border-gray-700" />
@ -44,16 +110,24 @@
/> />
</a> </a>
<a href="https://github.com/ollama-webui/ollama-webui" target="_blank"> <a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a href="https://github.com/open-webui/open-webui" target="_blank">
<img <img
alt="Github Repo" alt="Github Repo"
src="https://img.shields.io/github/stars/ollama-webui/ollama-webui?style=social&label=Star us on Github" src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/> />
</a> </a>
</div> </div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Created by <a {$i18n.t('Created by')}
<a
class=" text-gray-500 dark:text-gray-300 font-medium" class=" text-gray-500 dark:text-gray-300 font-medium"
href="https://github.com/tjbck" href="https://github.com/tjbck"
target="_blank">Timothy J. Baek</a target="_blank">Timothy J. Baek</a

Some files were not shown because too many files have changed in this diff Show more