diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 0e62be3d90..0c93903147 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -56,19 +56,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker images (default latest tag) + - name: Get version number from package.json + id: get_version + run: | + VERSION=$(jq -r '.version' package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Extract metadata for Docker images id: meta uses: docker/metadata-action@v5 with: images: ${{ env.FULL_IMAGE_NAME }} tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.get_version.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} type=ref,event=branch type=ref,event=tag type=sha,prefix=git- type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - flavor: | - latest=${{ github.ref == 'refs/heads/main' }} - name: Extract metadata for Docker cache id: cache-meta @@ -82,7 +88,7 @@ jobs: prefix=cache-${{ matrix.platform }}- latest=false - - name: Build Docker image (latest) + - name: Build Docker image uses: docker/build-push-action@v5 id: build with: @@ -90,7 +96,8 @@ jobs: push: true platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + tags: ${{ steps.meta.outputs.tags }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push=true cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }} cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max build-args: | @@ -153,21 +160,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker images (cuda tag) + - name: Get version number from package.json + id: get_version + run: | + VERSION=$(jq -r '.version' package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Extract metadata for Docker images id: meta uses: docker/metadata-action@v5 with: images: ${{ env.FULL_IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=tag - type=sha,prefix=git- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda - flavor: | - latest=${{ github.ref == 'refs/heads/main' }} - suffix=-cuda,onlatest=true + type=raw,value=latest-cuda,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.get_version.outputs.version }}-cuda,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=branch,suffix=-cuda + type=ref,event=tag,suffix=-cuda + type=sha,prefix=git-,suffix=-cuda + type=semver,pattern={{version}},suffix=-cuda + type=semver,pattern={{major}}.{{minor}},suffix=-cuda - name: Extract metadata for Docker cache id: cache-meta @@ -189,7 +200,8 @@ jobs: push: true platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + tags: ${{ steps.meta.outputs.tags }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push=true cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }} cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max build-args: | @@ -253,21 +265,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker images (ollama tag) + - name: Get version number from package.json + id: get_version + run: | + VERSION=$(jq -r '.version' package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Extract metadata for Docker images id: meta uses: docker/metadata-action@v5 with: images: ${{ env.FULL_IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=tag - type=sha,prefix=git- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama - flavor: | - latest=${{ github.ref == 'refs/heads/main' }} - suffix=-ollama,onlatest=true + type=raw,value=latest-ollama,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.get_version.outputs.version }}-ollama,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=branch,suffix=-ollama + type=ref,event=tag,suffix=-ollama + type=sha,prefix=git-,suffix=-ollama + type=semver,pattern={{version}},suffix=-ollama + type=semver,pattern={{major}}.{{minor}},suffix=-ollama - name: Extract metadata for Docker cache id: cache-meta @@ -289,7 +305,8 @@ jobs: push: true platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + tags: ${{ steps.meta.outputs.tags }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push=true cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }} cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max build-args: | @@ -309,7 +326,6 @@ jobs: path: /tmp/digests/* if-no-files-found: error retention-days: 1 - merge-main-images: runs-on: ubuntu-latest needs: [ build-main-image ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b86b26001..1a39cf2df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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.3.16] - 2024-08-27 + +### Added + +- **🚀 Config DB Migration**: Migrated configuration handling from config.json to the database, enabling high-availability setups and load balancing across multiple Open WebUI instances. +- **🔗 Call Mode Activation via URL**: Added a 'call=true' URL search parameter enabling direct shortcuts to activate call mode, enhancing user interaction on mobile devices. +- **✨ TTS Content Control**: Added functionality to control how message content is segmented for Text-to-Speech (TTS) generation requests, allowing for more flexible speech output options. +- **😄 Show Knowledge Search Status**: Enhanced model usage transparency by displaying status when working with knowledge-augmented models, helping users understand the system's state during queries. +- **👆 Click-to-Copy for Codespan**: Enhanced interactive experience in the WebUI by allowing users to click to copy content from code spans directly. +- **🚫 API User Blocking via Model Filter**: Introduced the ability to block API users based on customized model filters, enhancing security and control over API access. +- **🎬 Call Overlay Styling**: Adjusted call overlay styling on large screens to not cover the entire interface, but only the chat control area, for a more unobtrusive interaction experience. + +### Fixed + +- **🔧 LaTeX Rendering Issue**: Addressed an issue that affected the correct rendering of LaTeX. +- **📁 File Leak Prevention**: Resolved the issue of uploaded files mistakenly being accessible across user chats. +- **🔧 Pipe Functions with '**files**' Param**: Fixed issues with '**files**' parameter not functioning correctly in pipe functions. +- **📝 Markdown Processing for RAG**: Fixed issues with processing Markdown in files. +- **🚫 Duplicate System Prompts**: Fixed bugs causing system prompts to duplicate. + +### Changed + +- **🔋 Wakelock Permission**: Optimized the activation of wakelock to only engage during call mode, conserving device resources and improving battery performance during idle periods. +- **🔍 Content-Type for Ollama Chats**: Added 'application/x-ndjson' content-type to '/api/chat' endpoint responses to match raw Ollama responses. +- **✋ Disable Signups Conditionally**: Implemented conditional logic to disable sign-ups when 'ENABLE_LOGIN_FORM' is set to false. + ## [0.3.15] - 2024-08-21 ### Added diff --git a/backend/apps/audio/main.py b/backend/apps/audio/main.py index d66a9fa11e..46be15364f 100644 --- a/backend/apps/audio/main.py +++ b/backend/apps/audio/main.py @@ -37,6 +37,7 @@ from config import ( AUDIO_TTS_ENGINE, AUDIO_TTS_MODEL, AUDIO_TTS_VOICE, + AUDIO_TTS_SPLIT_ON, AppConfig, CORS_ALLOW_ORIGIN, ) @@ -72,6 +73,7 @@ app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE app.state.config.TTS_MODEL = AUDIO_TTS_MODEL app.state.config.TTS_VOICE = AUDIO_TTS_VOICE app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY +app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON # setting device type for whisper model whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu" @@ -88,6 +90,7 @@ class TTSConfigForm(BaseModel): ENGINE: str MODEL: str VOICE: str + SPLIT_ON: str class STTConfigForm(BaseModel): @@ -139,6 +142,7 @@ async def get_audio_config(user=Depends(get_admin_user)): "ENGINE": app.state.config.TTS_ENGINE, "MODEL": app.state.config.TTS_MODEL, "VOICE": app.state.config.TTS_VOICE, + "SPLIT_ON": app.state.config.TTS_SPLIT_ON, }, "stt": { "OPENAI_API_BASE_URL": app.state.config.STT_OPENAI_API_BASE_URL, @@ -159,6 +163,7 @@ async def update_audio_config( app.state.config.TTS_ENGINE = form_data.tts.ENGINE app.state.config.TTS_MODEL = form_data.tts.MODEL app.state.config.TTS_VOICE = form_data.tts.VOICE + app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY @@ -173,6 +178,7 @@ async def update_audio_config( "ENGINE": app.state.config.TTS_ENGINE, "MODEL": app.state.config.TTS_MODEL, "VOICE": app.state.config.TTS_VOICE, + "SPLIT_ON": app.state.config.TTS_SPLIT_ON, }, "stt": { "OPENAI_API_BASE_URL": app.state.config.STT_OPENAI_API_BASE_URL, diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index 25ed2c5176..ed75431063 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -15,6 +15,7 @@ import json import logging import re import requests +import asyncio from utils.utils import ( get_verified_user, @@ -533,7 +534,9 @@ async def image_generations( if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt - r = requests.post( + # Use asyncio.to_thread for the requests.post call + r = await asyncio.to_thread( + requests.post, url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img", json=data, headers={"authorization": get_automatic1111_api_auth()}, @@ -553,7 +556,6 @@ async def image_generations( json.dump({**data, "info": res["info"]}, f) return images - except Exception as e: error = e if r != None: diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index d3931b1ab9..db677e84cb 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -148,7 +148,9 @@ async def cleanup_response( await session.close() -async def post_streaming_url(url: str, payload: Union[str, bytes], stream: bool = True): +async def post_streaming_url( + url: str, payload: Union[str, bytes], stream: bool = True, content_type=None +): r = None try: session = aiohttp.ClientSession( @@ -162,10 +164,13 @@ async def post_streaming_url(url: str, payload: Union[str, bytes], stream: bool r.raise_for_status() if stream: + headers = dict(r.headers) + if content_type: + headers["Content-Type"] = content_type return StreamingResponse( r.content, status_code=r.status, - headers=dict(r.headers), + headers=headers, background=BackgroundTask( cleanup_response, response=r, session=session ), @@ -737,6 +742,14 @@ async def generate_chat_completion( del payload["metadata"] model_id = form_data.model + + if app.state.config.ENABLE_MODEL_FILTER: + if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST: + raise HTTPException( + status_code=403, + detail="Model not found", + ) + model_info = Models.get_model_by_id(model_id) if model_info: @@ -761,7 +774,9 @@ async def generate_chat_completion( log.info(f"url: {url}") log.debug(payload) - return await post_streaming_url(f"{url}/api/chat", json.dumps(payload)) + return await post_streaming_url( + f"{url}/api/chat", json.dumps(payload), content_type="application/x-ndjson" + ) # TODO: we should update this part once Ollama supports other types @@ -797,6 +812,14 @@ async def generate_openai_chat_completion( del payload["metadata"] model_id = completion_form.model + + if app.state.config.ENABLE_MODEL_FILTER: + if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST: + raise HTTPException( + status_code=403, + detail="Model not found", + ) + model_info = Models.get_model_by_id(model_id) if model_info: diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index 7b2fbc6794..c3cee8dcca 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -95,6 +95,8 @@ from config import ( TIKA_SERVER_URL, RAG_TOP_K, RAG_RELEVANCE_THRESHOLD, + RAG_FILE_MAX_SIZE, + RAG_FILE_MAX_COUNT, RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, @@ -143,6 +145,8 @@ app.state.config = AppConfig() app.state.config.TOP_K = RAG_TOP_K app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD +app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE +app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( @@ -393,6 +397,10 @@ async def get_rag_config(user=Depends(get_admin_user)): return { "status": True, "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES, + "file": { + "max_size": app.state.config.FILE_MAX_SIZE, + "max_count": app.state.config.FILE_MAX_COUNT, + }, "content_extraction": { "engine": app.state.config.CONTENT_EXTRACTION_ENGINE, "tika_server_url": app.state.config.TIKA_SERVER_URL, @@ -426,6 +434,11 @@ async def get_rag_config(user=Depends(get_admin_user)): } +class FileConfig(BaseModel): + max_size: Optional[int] = None + max_count: Optional[int] = None + + class ContentExtractionConfig(BaseModel): engine: str = "" tika_server_url: Optional[str] = None @@ -464,6 +477,7 @@ class WebConfig(BaseModel): class ConfigUpdateForm(BaseModel): pdf_extract_images: Optional[bool] = None + file: Optional[FileConfig] = None content_extraction: Optional[ContentExtractionConfig] = None chunk: Optional[ChunkParamUpdateForm] = None youtube: Optional[YoutubeLoaderConfig] = None @@ -478,6 +492,10 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ else app.state.config.PDF_EXTRACT_IMAGES ) + if form_data.file is not None: + app.state.config.FILE_MAX_SIZE = form_data.file.max_size + app.state.config.FILE_MAX_COUNT = form_data.file.max_count + if form_data.content_extraction is not None: log.info(f"Updating text settings: {form_data.content_extraction}") app.state.config.CONTENT_EXTRACTION_ENGINE = form_data.content_extraction.engine @@ -519,6 +537,10 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ return { "status": True, "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES, + "file": { + "max_size": app.state.config.FILE_MAX_SIZE, + "max_count": app.state.config.FILE_MAX_COUNT, + }, "content_extraction": { "engine": app.state.config.CONTENT_EXTRACTION_ENGINE, "tika_server_url": app.state.config.TIKA_SERVER_URL, @@ -590,6 +612,7 @@ async def update_query_settings( app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( form_data.hybrid if form_data.hybrid else False ) + return { "status": True, "template": app.state.config.RAG_TEMPLATE, @@ -1373,12 +1396,12 @@ def scan_docs_dir(user=Depends(get_admin_user)): return True -@app.get("/reset/db") +@app.post("/reset/db") def reset_vector_db(user=Depends(get_admin_user)): CHROMA_CLIENT.reset() -@app.get("/reset/uploads") +@app.post("/reset/uploads") def reset_upload_dir(user=Depends(get_admin_user)) -> bool: folder = f"{UPLOAD_DIR}" try: @@ -1402,7 +1425,7 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: return True -@app.get("/reset") +@app.post("/reset") def reset(user=Depends(get_admin_user)) -> bool: folder = f"{UPLOAD_DIR}" for filename in os.listdir(folder): diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index 034f71292c..82bead0126 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -149,16 +149,20 @@ def query_collection( ): results = [] for collection_name in collection_names: - try: - result = query_doc( - collection_name=collection_name, - query=query, - k=k, - embedding_function=embedding_function, - ) - results.append(result) - except Exception: + if collection_name: + try: + result = query_doc( + collection_name=collection_name, + query=query, + k=k, + embedding_function=embedding_function, + ) + results.append(result) + except Exception: + pass + else: pass + return merge_and_sort_query_results(results, k=k) @@ -257,7 +261,7 @@ def get_rag_context( collection_names = ( file["collection_names"] if file["type"] == "collection" - else [file["collection_name"]] + else [file["collection_name"]] if file["collection_name"] else [] ) collection_names = set(collection_names).difference(extracted_collections) diff --git a/backend/apps/webui/internal/db.py b/backend/apps/webui/internal/db.py index fbe287e185..db8df5ee58 100644 --- a/backend/apps/webui/internal/db.py +++ b/backend/apps/webui/internal/db.py @@ -3,18 +3,19 @@ import logging import json from contextlib import contextmanager -from peewee_migrate import Router -from apps.webui.internal.wrappers import register_connection from typing import Optional, Any from typing_extensions import Self from sqlalchemy import create_engine, types, Dialect +from sqlalchemy.sql.type_api import _T from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.sql.type_api import _T -from config import SRC_LOG_LEVELS, DATA_DIR, DATABASE_URL, BACKEND_DIR + +from peewee_migrate import Router +from apps.webui.internal.wrappers import register_connection +from env import SRC_LOG_LEVELS, BACKEND_DIR, DATABASE_URL log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["DB"]) @@ -42,34 +43,21 @@ class JSONField(types.TypeDecorator): return json.loads(value) -# 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") - log.info("Database migrated from Ollama-WebUI successfully.") -else: - pass - - # Workaround to handle the peewee migration # This is required to ensure the peewee migration is handled before the alembic migration def handle_peewee_migration(DATABASE_URL): + # db = None try: - # Replace the postgresql:// with postgres:// and %40 with @ in the DATABASE_URL - db = register_connection( - DATABASE_URL.replace("postgresql://", "postgres://").replace("%40", "@") - ) + # Replace the postgresql:// with postgres:// to handle the peewee migration + db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://")) migrate_dir = BACKEND_DIR / "apps" / "webui" / "internal" / "migrations" router = Router(db, logger=log, migrate_dir=migrate_dir) router.run() db.close() - # check if db connection has been closed - except Exception as e: log.error(f"Failed to initialize the database connection: {e}") raise - finally: # Properly closing the database connection if db and not db.is_closed(): @@ -98,7 +86,6 @@ Base = declarative_base() Session = scoped_session(SessionLocal) -# Dependency def get_session(): db = SessionLocal() try: diff --git a/backend/apps/webui/internal/wrappers.py b/backend/apps/webui/internal/wrappers.py index 2b5551ce2b..19523064af 100644 --- a/backend/apps/webui/internal/wrappers.py +++ b/backend/apps/webui/internal/wrappers.py @@ -6,7 +6,7 @@ import logging from playhouse.db_url import connect, parse from playhouse.shortcuts import ReconnectMixin -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["DB"]) @@ -43,7 +43,7 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase): def register_connection(db_url): - db = connect(db_url) + db = connect(db_url, unquote_password=True) if isinstance(db, PostgresqlDatabase): # Enable autoconnect for SQLite databases, managed by Peewee db.autoconnect = True @@ -51,16 +51,10 @@ def register_connection(db_url): log.info("Connected to PostgreSQL database") # Get the connection details - connection = parse(db_url) + connection = parse(db_url, unquote_password=True) # Use our custom database class that supports reconnection - db = ReconnectingPostgresqlDatabase( - connection["database"], - user=connection["user"], - password=connection["password"], - host=connection["host"], - port=connection["port"], - ) + db = ReconnectingPostgresqlDatabase(**connection) db.connect(reuse_if_open=True) elif isinstance(db, SqliteDatabase): # Enable autoconnect for SQLite databases, managed by Peewee diff --git a/backend/apps/webui/main.py b/backend/apps/webui/main.py index bede4b4f83..00963def64 100644 --- a/backend/apps/webui/main.py +++ b/backend/apps/webui/main.py @@ -56,12 +56,15 @@ from apps.socket.main import get_event_call, get_event_emitter import inspect import json +import logging from typing import Iterator, Generator, AsyncGenerator from pydantic import BaseModel app = FastAPI() +log = logging.getLogger(__name__) + app.state.config = AppConfig() app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP @@ -243,43 +246,37 @@ def get_pipe_id(form_data: dict) -> str: return pipe_id -def get_function_params(function_module, form_data, user, extra_params={}): +def get_function_params(function_module, form_data, user, extra_params=None): + if extra_params is None: + extra_params = {} + pipe_id = get_pipe_id(form_data) + # Get the signature of the function sig = inspect.signature(function_module.pipe) - params = {"body": form_data} - - for key, value in extra_params.items(): - if key in sig.parameters: - params[key] = value - - if "__user__" in sig.parameters: - __user__ = { - "id": user.id, - "email": user.email, - "name": user.name, - "role": user.role, - } + params = {"body": form_data} | { + k: v for k, v in extra_params.items() if k in sig.parameters + } + if "__user__" in params and hasattr(function_module, "UserValves"): + user_valves = Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id) try: - if hasattr(function_module, "UserValves"): - __user__["valves"] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id) - ) + params["__user__"]["valves"] = function_module.UserValves(**user_valves) except Exception as e: - print(e) + log.exception(e) + params["__user__"]["valves"] = function_module.UserValves() - params["__user__"] = __user__ return params async def generate_function_chat_completion(form_data, user): model_id = form_data.get("model") model_info = Models.get_model_by_id(model_id) + metadata = form_data.pop("metadata", {}) + files = metadata.get("files", []) tool_ids = metadata.get("tool_ids", []) - # Check if tool_ids is None if tool_ids is None: tool_ids = [] @@ -298,16 +295,25 @@ async def generate_function_chat_completion(form_data, user): "__event_emitter__": __event_emitter__, "__event_call__": __event_call__, "__task__": __task__, - } - tools_params = { - **extra_params, - "__model__": app.state.MODELS[form_data["model"]], - "__messages__": form_data["messages"], "__files__": files, + "__user__": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + }, } - - tools = get_tools(app, tool_ids, user, tools_params) - extra_params["__tools__"] = tools + extra_params["__tools__"] = get_tools( + app, + tool_ids, + user, + { + **extra_params, + "__model__": app.state.MODELS[form_data["model"]], + "__messages__": form_data["messages"], + "__files__": files, + }, + ) if model_info: if model_info.base_model_id: diff --git a/backend/apps/webui/models/auths.py b/backend/apps/webui/models/auths.py index 3cbe8c8875..601c7c9a4c 100644 --- a/backend/apps/webui/models/auths.py +++ b/backend/apps/webui/models/auths.py @@ -4,12 +4,12 @@ import uuid import logging from sqlalchemy import String, Column, Boolean, Text -from apps.webui.models.users import UserModel, Users from utils.utils import verify_password +from apps.webui.models.users import UserModel, Users from apps.webui.internal.db import Base, get_db -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) diff --git a/backend/apps/webui/models/chats.py b/backend/apps/webui/models/chats.py index be77595eca..164be06466 100644 --- a/backend/apps/webui/models/chats.py +++ b/backend/apps/webui/models/chats.py @@ -249,22 +249,25 @@ class ChatTable: self, user_id: str, include_archived: bool = False, - skip: int = 0, - limit: int = -1, + skip: Optional[int] = None, + limit: Optional[int] = None, ) -> list[ChatTitleIdResponse]: with get_db() as db: query = db.query(Chat).filter_by(user_id=user_id) if not include_archived: query = query.filter_by(archived=False) - all_chats = ( - query.order_by(Chat.updated_at.desc()) - # limit cols - .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) - .limit(limit) - .offset(skip) - .all() + query = query.order_by(Chat.updated_at.desc()).with_entities( + Chat.id, Chat.title, Chat.updated_at, Chat.created_at ) + + if limit: + query = query.limit(limit) + if skip: + query = query.offset(skip) + + all_chats = query.all() + # result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. return [ ChatTitleIdResponse.model_validate( diff --git a/backend/apps/webui/models/documents.py b/backend/apps/webui/models/documents.py index 4157c2c95f..15dd636630 100644 --- a/backend/apps/webui/models/documents.py +++ b/backend/apps/webui/models/documents.py @@ -9,7 +9,7 @@ from apps.webui.internal.db import Base, get_db import json -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) diff --git a/backend/apps/webui/models/files.py b/backend/apps/webui/models/files.py index 2de5c33b59..1b71751244 100644 --- a/backend/apps/webui/models/files.py +++ b/backend/apps/webui/models/files.py @@ -9,7 +9,7 @@ from apps.webui.internal.db import JSONField, Base, get_db import json -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -98,6 +98,13 @@ class FilesTable: return [FileModel.model_validate(file) for file in db.query(File).all()] + def get_files_by_user_id(self, user_id: str) -> list[FileModel]: + with get_db() as db: + return [ + FileModel.model_validate(file) + for file in db.query(File).filter_by(user_id=user_id).all() + ] + def delete_file_by_id(self, id: str) -> bool: with get_db() as db: diff --git a/backend/apps/webui/models/functions.py b/backend/apps/webui/models/functions.py index 3afdc1ea91..10d8111485 100644 --- a/backend/apps/webui/models/functions.py +++ b/backend/apps/webui/models/functions.py @@ -12,7 +12,7 @@ import json import copy -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) diff --git a/backend/apps/webui/models/models.py b/backend/apps/webui/models/models.py index 616beb2a9b..0a36da9878 100644 --- a/backend/apps/webui/models/models.py +++ b/backend/apps/webui/models/models.py @@ -6,7 +6,7 @@ from sqlalchemy import Column, BigInteger, Text from apps.webui.internal.db import Base, JSONField, get_db -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS import time diff --git a/backend/apps/webui/models/tags.py b/backend/apps/webui/models/tags.py index 7ce06cb60b..605cca2e79 100644 --- a/backend/apps/webui/models/tags.py +++ b/backend/apps/webui/models/tags.py @@ -10,7 +10,7 @@ from sqlalchemy import String, Column, BigInteger, Text from apps.webui.internal.db import Base, get_db -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) diff --git a/backend/apps/webui/models/tools.py b/backend/apps/webui/models/tools.py index c8c56fb974..2f4c532b86 100644 --- a/backend/apps/webui/models/tools.py +++ b/backend/apps/webui/models/tools.py @@ -11,7 +11,7 @@ import json import copy -from config import SRC_LOG_LEVELS +from env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) diff --git a/backend/apps/webui/routers/auths.py b/backend/apps/webui/routers/auths.py index c1f46293d1..8909b1e059 100644 --- a/backend/apps/webui/routers/auths.py +++ b/backend/apps/webui/routers/auths.py @@ -195,7 +195,11 @@ async def signin(request: Request, response: Response, form_data: SigninForm): @router.post("/signup", response_model=SigninResponse) async def signup(request: Request, response: Response, form_data: SignupForm): - if not request.app.state.config.ENABLE_SIGNUP and WEBUI_AUTH: + if ( + not request.app.state.config.ENABLE_SIGNUP + and request.app.state.config.ENABLE_LOGIN_FORM + and WEBUI_AUTH + ): raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED ) @@ -228,7 +232,6 @@ async def signup(request: Request, response: Response, form_data: SignupForm): data={"id": user.id}, expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), ) - # response.set_cookie(key='token', value=token, httponly=True) # Set the cookie token response.set_cookie( diff --git a/backend/apps/webui/routers/files.py b/backend/apps/webui/routers/files.py index ba571fc713..48ca366d88 100644 --- a/backend/apps/webui/routers/files.py +++ b/backend/apps/webui/routers/files.py @@ -106,7 +106,10 @@ def upload_file(file: UploadFile = File(...), user=Depends(get_verified_user)): @router.get("/", response_model=list[FileModel]) async def list_files(user=Depends(get_verified_user)): - files = Files.get_files() + if user.role == "admin": + files = Files.get_files() + else: + files = Files.get_files_by_user_id(user.id) return files @@ -156,7 +159,7 @@ async def delete_all_files(user=Depends(get_admin_user)): async def get_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file: + if file and (file.user_id == user.id or user.role == "admin"): return file else: raise HTTPException( @@ -174,7 +177,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file: + if file and (file.user_id == user.id or user.role == "admin"): file_path = Path(file.meta["path"]) # Check if the file already exists in the cache @@ -197,7 +200,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file: + if file and (file.user_id == user.id or user.role == "admin"): file_path = Path(file.meta["path"]) # Check if the file already exists in the cache @@ -224,8 +227,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): @router.delete("/{id}") async def delete_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - - if file: + if file and (file.user_id == user.id or user.role == "admin"): result = Files.delete_file_by_id(id) if result: return {"message": "File deleted successfully"} diff --git a/backend/apps/webui/routers/memories.py b/backend/apps/webui/routers/memories.py index a7b5474f0a..ae0a9efcb2 100644 --- a/backend/apps/webui/routers/memories.py +++ b/backend/apps/webui/routers/memories.py @@ -68,6 +68,76 @@ async def add_memory( return memory +############################ +# QueryMemory +############################ + + +class QueryMemoryForm(BaseModel): + content: str + k: Optional[int] = 1 + + +@router.post("/query") +async def query_memory( + request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) +): + query_embedding = request.app.state.EMBEDDING_FUNCTION(form_data.content) + collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") + + results = collection.query( + query_embeddings=[query_embedding], + n_results=form_data.k, # how many results to return + ) + + return results + + +############################ +# ResetMemoryFromVectorDB +############################ +@router.post("/reset", response_model=bool) +async def reset_memory_from_vector_db( + request: Request, user=Depends(get_verified_user) +): + CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") + collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") + + memories = Memories.get_memories_by_user_id(user.id) + for memory in memories: + memory_embedding = request.app.state.EMBEDDING_FUNCTION(memory.content) + collection.upsert( + documents=[memory.content], + ids=[memory.id], + embeddings=[memory_embedding], + ) + return True + + +############################ +# DeleteMemoriesByUserId +############################ + + +@router.delete("/delete/user", response_model=bool) +async def delete_memory_by_user_id(user=Depends(get_verified_user)): + result = Memories.delete_memories_by_user_id(user.id) + + if result: + try: + CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") + except Exception as e: + log.error(e) + return True + + return False + + +############################ +# UpdateMemoryById +############################ + + @router.post("/{memory_id}/update", response_model=Optional[MemoryModel]) async def update_memory_by_id( memory_id: str, @@ -96,71 +166,6 @@ async def update_memory_by_id( return memory -############################ -# QueryMemory -############################ - - -class QueryMemoryForm(BaseModel): - content: str - k: Optional[int] = 1 - - -@router.post("/query") -async def query_memory( - request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) -): - query_embedding = request.app.state.EMBEDDING_FUNCTION(form_data.content) - collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") - - results = collection.query( - query_embeddings=[query_embedding], - n_results=form_data.k, # how many results to return - ) - - return results - - -############################ -# ResetMemoryFromVectorDB -############################ -@router.get("/reset", response_model=bool) -async def reset_memory_from_vector_db( - request: Request, user=Depends(get_verified_user) -): - CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") - collection = CHROMA_CLIENT.get_or_create_collection(name=f"user-memory-{user.id}") - - memories = Memories.get_memories_by_user_id(user.id) - for memory in memories: - memory_embedding = request.app.state.EMBEDDING_FUNCTION(memory.content) - collection.upsert( - documents=[memory.content], - ids=[memory.id], - embeddings=[memory_embedding], - ) - return True - - -############################ -# DeleteMemoriesByUserId -############################ - - -@router.delete("/user", response_model=bool) -async def delete_memory_by_user_id(user=Depends(get_verified_user)): - result = Memories.delete_memories_by_user_id(user.id) - - if result: - try: - CHROMA_CLIENT.delete_collection(f"user-memory-{user.id}") - except Exception as e: - log.error(e) - return True - - return False - - ############################ # DeleteMemoryById ############################ diff --git a/backend/apps/webui/utils.py b/backend/apps/webui/utils.py index bf5ebedeb7..a556b8e8c1 100644 --- a/backend/apps/webui/utils.py +++ b/backend/apps/webui/utils.py @@ -4,6 +4,9 @@ import re import sys import subprocess + +from apps.webui.models.tools import Tools +from apps.webui.models.functions import Functions from config import TOOLS_DIR, FUNCTIONS_DIR @@ -49,6 +52,15 @@ def extract_frontmatter(file_path): def load_toolkit_module_by_id(toolkit_id): toolkit_path = os.path.join(TOOLS_DIR, f"{toolkit_id}.py") + + if not os.path.exists(toolkit_path): + tool = Tools.get_tool_by_id(toolkit_id) + if tool: + with open(toolkit_path, "w") as file: + file.write(tool.content) + else: + raise Exception(f"Toolkit not found: {toolkit_id}") + spec = util.spec_from_file_location(toolkit_id, toolkit_path) module = util.module_from_spec(spec) frontmatter = extract_frontmatter(toolkit_path) @@ -71,6 +83,14 @@ def load_toolkit_module_by_id(toolkit_id): def load_function_module_by_id(function_id): function_path = os.path.join(FUNCTIONS_DIR, f"{function_id}.py") + if not os.path.exists(function_path): + function = Functions.get_function_by_id(function_id) + if function: + with open(function_path, "w") as file: + file.write(function.content) + else: + raise Exception(f"Function not found: {function_id}") + spec = util.spec_from_file_location(function_id, function_path) module = util.module_from_spec(spec) frontmatter = extract_frontmatter(function_path) diff --git a/backend/config.py b/backend/config.py index d7caadca4c..9cdcbe474e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,13 +1,17 @@ +from sqlalchemy import create_engine, Column, Integer, DateTime, JSON, func +from contextlib import contextmanager + + import os import sys import logging import importlib.metadata import pkgutil from urllib.parse import urlparse +from datetime import datetime import chromadb from chromadb import Settings -from bs4 import BeautifulSoup from typing import TypeVar, Generic from pydantic import BaseModel from typing import Optional @@ -16,68 +20,39 @@ from pathlib import Path import json import yaml -import markdown import requests import shutil + +from apps.webui.internal.db import Base, get_db + from constants import ERROR_MESSAGES -#################################### -# Load .env file -#################################### - -BACKEND_DIR = Path(__file__).parent # the path containing this file -BASE_DIR = BACKEND_DIR.parent # the path containing the backend/ - -print(BASE_DIR) - -try: - from dotenv import load_dotenv, find_dotenv - - load_dotenv(find_dotenv(str(BASE_DIR / ".env"))) -except ImportError: - print("dotenv not installed, skipping...") - - -#################################### -# LOGGING -#################################### - -log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] - -GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() -if GLOBAL_LOG_LEVEL in log_levels: - logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) -else: - GLOBAL_LOG_LEVEL = "INFO" - -log = logging.getLogger(__name__) -log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") - -log_sources = [ - "AUDIO", - "COMFYUI", - "CONFIG", - "DB", - "IMAGES", - "MAIN", - "MODELS", - "OLLAMA", - "OPENAI", - "RAG", - "WEBHOOK", -] - -SRC_LOG_LEVELS = {} - -for source in log_sources: - log_env_var = source + "_LOG_LEVEL" - SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() - if SRC_LOG_LEVELS[source] not in log_levels: - SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL - log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") - -log.setLevel(SRC_LOG_LEVELS["CONFIG"]) +from env import ( + ENV, + VERSION, + SAFE_MODE, + GLOBAL_LOG_LEVEL, + SRC_LOG_LEVELS, + BASE_DIR, + DATA_DIR, + BACKEND_DIR, + FRONTEND_BUILD_DIR, + WEBUI_NAME, + WEBUI_URL, + WEBUI_FAVICON_URL, + WEBUI_BUILD_HASH, + CONFIG_DATA, + DATABASE_URL, + CHANGELOG, + WEBUI_AUTH, + WEBUI_AUTH_TRUSTED_EMAIL_HEADER, + WEBUI_AUTH_TRUSTED_NAME_HEADER, + WEBUI_SECRET_KEY, + WEBUI_SESSION_COOKIE_SAME_SITE, + WEBUI_SESSION_COOKIE_SECURE, + log, +) class EndpointFilter(logging.Filter): @@ -88,135 +63,62 @@ class EndpointFilter(logging.Filter): # Filter out /endpoint logging.getLogger("uvicorn.access").addFilter(EndpointFilter()) - -WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") -if WEBUI_NAME != "Open WebUI": - WEBUI_NAME += " (Open WebUI)" - -WEBUI_URL = os.environ.get("WEBUI_URL", "http://localhost:3000") - -WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" - - -#################################### -# ENV (dev,test,prod) -#################################### - -ENV = os.environ.get("ENV", "dev") - -try: - PACKAGE_DATA = json.loads((BASE_DIR / "package.json").read_text()) -except Exception: - try: - PACKAGE_DATA = {"version": importlib.metadata.version("open-webui")} - except importlib.metadata.PackageNotFoundError: - 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: - changelog_path = BASE_DIR / "CHANGELOG.md" - with open(str(changelog_path.absolute()), "r", encoding="utf8") as file: - changelog_content = file.read() - -except Exception: - changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode() - - -# 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 - -#################################### -# SAFE_MODE -#################################### - -SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true" - -#################################### -# WEBUI_BUILD_HASH -#################################### - -WEBUI_BUILD_HASH = os.environ.get("WEBUI_BUILD_HASH", "dev-build") - -#################################### -# DATA/FRONTEND BUILD DIR -#################################### - -DATA_DIR = Path(os.getenv("DATA_DIR", BACKEND_DIR / "data")).resolve() -FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() - -RESET_CONFIG_ON_START = ( - os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" -) -if RESET_CONFIG_ON_START: - try: - os.remove(f"{DATA_DIR}/config.json") - with open(f"{DATA_DIR}/config.json", "w") as f: - f.write("{}") - except Exception: - pass - -try: - CONFIG_DATA = json.loads((DATA_DIR / "config.json").read_text()) -except Exception: - CONFIG_DATA = {} - - #################################### # Config helpers #################################### +# Function to run the alembic migrations +def run_migrations(): + print("Running migrations") + try: + from alembic.config import Config + from alembic import command + + alembic_cfg = Config("alembic.ini") + command.upgrade(alembic_cfg, "head") + except Exception as e: + print(f"Error: {e}") + + +run_migrations() + + +class Config(Base): + __tablename__ = "config" + + id = Column(Integer, primary_key=True) + data = Column(JSON, nullable=False) + version = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=True, onupdate=func.now()) + + +def load_json_config(): + with open(f"{DATA_DIR}/config.json", "r") as file: + return json.load(file) + + +def save_to_db(data): + with get_db() as db: + existing_config = db.query(Config).first() + if not existing_config: + new_config = Config(data=data, version=0) + db.add(new_config) + else: + existing_config.data = data + existing_config.updated_at = datetime.now() + db.add(existing_config) + db.commit() + + +# When initializing, check if config.json exists and migrate it to the database +if os.path.exists(f"{DATA_DIR}/config.json"): + data = load_json_config() + save_to_db(data) + os.rename(f"{DATA_DIR}/config.json", f"{DATA_DIR}/old_config.json") + + def save_config(): try: with open(f"{DATA_DIR}/config.json", "w") as f: @@ -225,6 +127,68 @@ def save_config(): log.exception(e) +DEFAULT_CONFIG = { + "version": 0, + "ui": { + "default_locale": "", + "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.", + }, + { + "title": [ + "Explain options trading", + "if I'm familiar with buying and selling stocks", + ], + "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", + }, + { + "title": ["Overcome procrastination", "give me tips"], + "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", + }, + { + "title": [ + "Grammar check", + "rewrite it for better readability ", + ], + "content": 'Check the following sentence for grammar and clarity: "[sentence]". Rewrite it for better readability while maintaining its original meaning.', + }, + ], + }, +} + + +def get_config(): + with get_db() as db: + config_entry = db.query(Config).order_by(Config.id.desc()).first() + return config_entry.data if config_entry else DEFAULT_CONFIG + + +CONFIG_DATA = get_config() + + def get_config_value(config_path: str): path_parts = config_path.split(".") cur_config = CONFIG_DATA @@ -246,7 +210,7 @@ class PersistentConfig(Generic[T]): self.env_value = env_value self.config_value = get_config_value(config_path) if self.config_value is not None: - log.info(f"'{env_name}' loaded from config.json") + log.info(f"'{env_name}' loaded from the latest database entry") self.value = self.config_value else: self.value = env_value @@ -268,19 +232,15 @@ class PersistentConfig(Generic[T]): return super().__getattribute__(item) def save(self): - # Don't save if the value is the same as the env value and the config value - if self.env_value == self.value: - if self.config_value == self.value: - return - log.info(f"Saving '{self.env_name}' to config.json") + log.info(f"Saving '{self.env_name}' to the database") path_parts = self.config_path.split(".") - config = CONFIG_DATA + sub_config = CONFIG_DATA for key in path_parts[:-1]: - if key not in config: - config[key] = {} - config = config[key] - config[path_parts[-1]] = self.value - save_config() + if key not in sub_config: + sub_config[key] = {} + sub_config = sub_config[key] + sub_config[path_parts[-1]] = self.value + save_to_db(CONFIG_DATA) self.config_value = self.value @@ -305,11 +265,6 @@ class AppConfig: # WEBUI_AUTH (Required for security) #################################### -WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" -WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( - "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None -) -WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) JWT_EXPIRES_IN = PersistentConfig( "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1") ) @@ -999,30 +954,6 @@ TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig( ) -#################################### -# WEBUI_SECRET_KEY -#################################### - -WEBUI_SECRET_KEY = os.environ.get( - "WEBUI_SECRET_KEY", - os.environ.get( - "WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t" - ), # DEPRECATED: remove at next major version -) - -WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get( - "WEBUI_SESSION_COOKIE_SAME_SITE", - os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax"), -) - -WEBUI_SESSION_COOKIE_SECURE = os.environ.get( - "WEBUI_SESSION_COOKIE_SECURE", - os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true", -) - -if WEBUI_AUTH and WEBUI_SECRET_KEY == "": - raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) - #################################### # RAG document content extraction #################################### @@ -1074,6 +1005,26 @@ ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true", ) +RAG_FILE_MAX_COUNT = PersistentConfig( + "RAG_FILE_MAX_COUNT", + "rag.file.max_count", + ( + int(os.environ.get("RAG_FILE_MAX_COUNT")) + if os.environ.get("RAG_FILE_MAX_COUNT") + else None + ), +) + +RAG_FILE_MAX_SIZE = PersistentConfig( + "RAG_FILE_MAX_SIZE", + "rag.file.max_size", + ( + int(os.environ.get("RAG_FILE_MAX_SIZE")) + if os.environ.get("RAG_FILE_MAX_SIZE") + else None + ), +) + ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", "rag.enable_web_loader_ssl_verification", @@ -1554,13 +1505,8 @@ AUDIO_TTS_VOICE = PersistentConfig( os.getenv("AUDIO_TTS_VOICE", "alloy"), # OpenAI default voice ) - -#################################### -# Database -#################################### - -DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db") - -# Replace the postgres:// with postgresql:// -if "postgres://" in DATABASE_URL: - DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://") +AUDIO_TTS_SPLIT_ON = PersistentConfig( + "AUDIO_TTS_SPLIT_ON", + "audio.tts.split_on", + os.getenv("AUDIO_TTS_SPLIT_ON", "punctuation"), +) diff --git a/backend/data/config.json b/backend/data/config.json deleted file mode 100644 index 7c7acde917..0000000000 --- a/backend/data/config.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": 0, - "ui": { - "default_locale": "", - "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." - }, - { - "title": ["Explain options trading", "if I'm familiar with buying and selling stocks"], - "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks." - }, - { - "title": ["Overcome procrastination", "give me tips"], - "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?" - }, - { - "title": ["Grammar check", "rewrite it for better readability "], - "content": "Check the following sentence for grammar and clarity: \"[sentence]\". Rewrite it for better readability while maintaining its original meaning." - } - ] - } -} diff --git a/backend/env.py b/backend/env.py new file mode 100644 index 0000000000..689dc1b6d5 --- /dev/null +++ b/backend/env.py @@ -0,0 +1,252 @@ +from pathlib import Path +import os +import logging +import sys +import json + + +import importlib.metadata +import pkgutil +from urllib.parse import urlparse +from datetime import datetime + + +import markdown +from bs4 import BeautifulSoup + +from constants import ERROR_MESSAGES + +#################################### +# Load .env file +#################################### + +BACKEND_DIR = Path(__file__).parent # the path containing this file +BASE_DIR = BACKEND_DIR.parent # the path containing the backend/ + +print(BASE_DIR) + +try: + from dotenv import load_dotenv, find_dotenv + + load_dotenv(find_dotenv(str(BASE_DIR / ".env"))) +except ImportError: + print("dotenv not installed, skipping...") + + +#################################### +# LOGGING +#################################### + +log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] + +GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() +if GLOBAL_LOG_LEVEL in log_levels: + logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) +else: + GLOBAL_LOG_LEVEL = "INFO" + +log = logging.getLogger(__name__) +log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") + +log_sources = [ + "AUDIO", + "COMFYUI", + "CONFIG", + "DB", + "IMAGES", + "MAIN", + "MODELS", + "OLLAMA", + "OPENAI", + "RAG", + "WEBHOOK", +] + +SRC_LOG_LEVELS = {} + +for source in log_sources: + log_env_var = source + "_LOG_LEVEL" + SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() + if SRC_LOG_LEVELS[source] not in log_levels: + SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL + log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") + +log.setLevel(SRC_LOG_LEVELS["CONFIG"]) + + +WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") +if WEBUI_NAME != "Open WebUI": + WEBUI_NAME += " (Open WebUI)" + +WEBUI_URL = os.environ.get("WEBUI_URL", "http://localhost:3000") + +WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" + + +#################################### +# ENV (dev,test,prod) +#################################### + +ENV = os.environ.get("ENV", "dev") + +try: + PACKAGE_DATA = json.loads((BASE_DIR / "package.json").read_text()) +except Exception: + try: + PACKAGE_DATA = {"version": importlib.metadata.version("open-webui")} + except importlib.metadata.PackageNotFoundError: + 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: + changelog_path = BASE_DIR / "CHANGELOG.md" + with open(str(changelog_path.absolute()), "r", encoding="utf8") as file: + changelog_content = file.read() + +except Exception: + changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode() + + +# 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 + +#################################### +# SAFE_MODE +#################################### + +SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true" + +#################################### +# WEBUI_BUILD_HASH +#################################### + +WEBUI_BUILD_HASH = os.environ.get("WEBUI_BUILD_HASH", "dev-build") + +#################################### +# DATA/FRONTEND BUILD DIR +#################################### + +DATA_DIR = Path(os.getenv("DATA_DIR", BACKEND_DIR / "data")).resolve() +FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() + +RESET_CONFIG_ON_START = ( + os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" +) +if RESET_CONFIG_ON_START: + try: + os.remove(f"{DATA_DIR}/config.json") + with open(f"{DATA_DIR}/config.json", "w") as f: + f.write("{}") + except Exception: + pass + +try: + CONFIG_DATA = json.loads((DATA_DIR / "config.json").read_text()) +except Exception: + CONFIG_DATA = {} + + +#################################### +# Database +#################################### + +# 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") + log.info("Database migrated from Ollama-WebUI successfully.") +else: + pass + +DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db") + +# Replace the postgres:// with postgresql:// +if "postgres://" in DATABASE_URL: + DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://") + + +#################################### +# WEBUI_AUTH (Required for security) +#################################### + +WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" +WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( + "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None +) +WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) + + +#################################### +# WEBUI_SECRET_KEY +#################################### + +WEBUI_SECRET_KEY = os.environ.get( + "WEBUI_SECRET_KEY", + os.environ.get( + "WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t" + ), # DEPRECATED: remove at next major version +) + +WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get( + "WEBUI_SESSION_COOKIE_SAME_SITE", + os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax"), +) + +WEBUI_SESSION_COOKIE_SECURE = os.environ.get( + "WEBUI_SESSION_COOKIE_SECURE", + os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true", +) + +if WEBUI_AUTH and WEBUI_SECRET_KEY == "": + raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) diff --git a/backend/main.py b/backend/main.py index c59f631b3c..4b91cdc84b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,6 @@ import base64 import uuid from contextlib import asynccontextmanager - from authlib.integrations.starlette_client import OAuth from authlib.oidc.core import UserInfo import json @@ -87,6 +86,7 @@ from utils.misc import ( from apps.rag.utils import get_rag_context, rag_template from config import ( + run_migrations, WEBUI_NAME, WEBUI_URL, WEBUI_AUTH, @@ -165,17 +165,6 @@ https://github.com/open-webui/open-webui ) -def run_migrations(): - try: - from alembic.config import Config - from alembic import command - - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - except Exception as e: - print(f"Error: {e}") - - @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() @@ -299,24 +288,26 @@ async def chat_completion_filter_functions_handler(body, model, extra_params): # Get the signature of the function sig = inspect.signature(inlet) - params = {"body": body} + params = {"body": body} | { + k: v + for k, v in { + **extra_params, + "__model__": model, + "__id__": filter_id, + }.items() + if k in sig.parameters + } - # Extra parameters to be passed to the function - custom_params = {**extra_params, "__model__": model, "__id__": filter_id} - if hasattr(function_module, "UserValves") and "__user__" in sig.parameters: + if "__user__" in params and hasattr(function_module, "UserValves"): try: - uid = custom_params["__user__"]["id"] - custom_params["__user__"]["valves"] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id(filter_id, uid) + params["__user__"]["valves"] = function_module.UserValves( + **Functions.get_user_valves_by_id_and_user_id( + filter_id, params["__user__"]["id"] + ) ) except Exception as e: print(e) - # Add extra params in contained in function signature - for key, value in custom_params.items(): - if key in sig.parameters: - params[key] = value - if inspect.iscoroutinefunction(inlet): body = await inlet(**params) else: @@ -372,7 +363,9 @@ async def chat_completion_tools_handler( ) -> tuple[dict, dict]: # If tool_ids field is present, call the functions metadata = body.get("metadata", {}) + tool_ids = metadata.get("tool_ids", None) + log.debug(f"{tool_ids=}") if not tool_ids: return body, {} @@ -381,16 +374,17 @@ async def chat_completion_tools_handler( citations = [] task_model_id = get_task_model_id(body["model"]) - - log.debug(f"{tool_ids=}") - - custom_params = { - **extra_params, - "__model__": app.state.MODELS[task_model_id], - "__messages__": body["messages"], - "__files__": metadata.get("files", []), - } - tools = get_tools(webui_app, tool_ids, user, custom_params) + tools = get_tools( + webui_app, + tool_ids, + user, + { + **extra_params, + "__model__": app.state.MODELS[task_model_id], + "__messages__": body["messages"], + "__files__": metadata.get("files", []), + }, + ) log.info(f"{tools=}") specs = [tool["spec"] for tool in tools.values()] @@ -530,17 +524,15 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware): } body["metadata"] = metadata - __user__ = { - "id": user.id, - "email": user.email, - "name": user.name, - "role": user.role, - } - extra_params = { - "__user__": __user__, "__event_emitter__": get_event_emitter(metadata), "__event_call__": get_event_call(metadata), + "__user__": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + }, } # Initialize data_items to store additional data to be sent to the client @@ -989,11 +981,20 @@ async def get_models(user=Depends(get_verified_user)): @app.post("/api/chat/completions") async def generate_chat_completions(form_data: dict, user=Depends(get_verified_user)): model_id = form_data["model"] + if model_id not in app.state.MODELS: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Model not found", ) + + if app.state.config.ENABLE_MODEL_FILTER: + if user.role == "user" and model_id not in app.state.config.MODEL_FILTER_LIST: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Model not found", + ) + model = app.state.MODELS[model_id] if model.get("pipe"): return await generate_function_chat_completion(form_data, user=user) @@ -1932,11 +1933,16 @@ async def get_app_config(request: Request): "tts": { "engine": audio_app.state.config.TTS_ENGINE, "voice": audio_app.state.config.TTS_VOICE, + "split_on": audio_app.state.config.TTS_SPLIT_ON, }, "stt": { "engine": audio_app.state.config.STT_ENGINE, }, }, + "file": { + "max_size": rag_app.state.config.FILE_MAX_SIZE, + "max_count": rag_app.state.config.FILE_MAX_COUNT, + }, "permissions": {**webui_app.state.config.USER_PERMISSIONS}, } if user is not None diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 8046abff3a..b3b3407fa8 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -18,7 +18,7 @@ from apps.webui.models.users import User from apps.webui.models.files import File from apps.webui.models.functions import Function -from config import DATABASE_URL +from env import DATABASE_URL # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/migrations/versions/ca81bd47c050_add_config_table.py b/backend/migrations/versions/ca81bd47c050_add_config_table.py new file mode 100644 index 0000000000..b9f708240b --- /dev/null +++ b/backend/migrations/versions/ca81bd47c050_add_config_table.py @@ -0,0 +1,43 @@ +"""Add config table + +Revision ID: ca81bd47c050 +Revises: 7e5b5dc7342b +Create Date: 2024-08-25 15:26:35.241684 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import apps.webui.internal.db + + +# revision identifiers, used by Alembic. +revision: str = "ca81bd47c050" +down_revision: Union[str, None] = "7e5b5dc7342b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.create_table( + "config", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column("version", sa.Integer, nullable=False), + sa.Column( + "created_at", sa.DateTime(), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + ) + + +def downgrade(): + op.drop_table("config") diff --git a/backend/requirements.txt b/backend/requirements.txt index 04b3261916..c597947e4c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -34,8 +34,8 @@ anthropic google-generativeai==0.7.2 tiktoken -langchain==0.2.12 -langchain-community==0.2.10 +langchain==0.2.14 +langchain-community==0.2.12 langchain-chroma==0.1.2 fake-useragent==1.5.1 @@ -44,8 +44,9 @@ sentence-transformers==3.0.1 pypdf==4.3.1 docx2txt==0.8 python-pptx==1.0.0 -unstructured==0.15.5 -Markdown==3.6 +unstructured==0.15.7 +nltk==3.9.1 +Markdown==3.7 pypandoc==1.13 pandas==2.2.2 openpyxl==3.1.5 @@ -66,7 +67,7 @@ PyJWT[crypto]==2.9.0 authlib==1.3.1 black==24.8.0 -langfuse==2.43.3 +langfuse==2.44.0 youtube-transcript-api==0.6.2 pytube==15.0.0 diff --git a/backend/utils/misc.py b/backend/utils/misc.py index 2eed58f41e..df35732c05 100644 --- a/backend/utils/misc.py +++ b/backend/utils/misc.py @@ -82,7 +82,7 @@ def add_or_update_system_message(content: str, messages: list[dict]): """ if messages and messages[0].get("role") == "system": - messages[0]["content"] += f"{content}\n{messages[0]['content']}" + messages[0]["content"] = f"{content}\n{messages[0]['content']}" else: # Insert at the beginning messages.insert(0, {"role": "system", "content": content}) diff --git a/backend/utils/utils.py b/backend/utils/utils.py index 288db1fb54..4c15ea237a 100644 --- a/backend/utils/utils.py +++ b/backend/utils/utils.py @@ -6,16 +6,16 @@ from apps.webui.models.users import Users from typing import Union, Optional from constants import ERROR_MESSAGES from passlib.context import CryptContext -from datetime import datetime, timedelta +from datetime import datetime, timedelta, UTC import jwt import uuid import logging -import config +from env import WEBUI_SECRET_KEY logging.getLogger("passlib").setLevel(logging.ERROR) -SESSION_SECRET = config.WEBUI_SECRET_KEY +SESSION_SECRET = WEBUI_SECRET_KEY ALGORITHM = "HS256" ############## @@ -40,7 +40,7 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st payload = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(UTC) + expires_delta payload.update({"exp": expire}) encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM) diff --git a/package-lock.json b/package-lock.json index d612dddcb8..c32a7adf80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.3.15", + "version": "0.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.3.15", + "version": "0.3.16", "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", @@ -6576,12 +6576,12 @@ ] }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { diff --git a/package.json b/package.json index 7252d8829a..175830ef31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.3.15", + "version": "0.3.16", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 61c3e5417c..98f9ccda03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,8 @@ dependencies = [ "google-generativeai==0.7.2", "tiktoken", - "langchain==0.2.12", - "langchain-community==0.2.10", + "langchain==0.2.14", + "langchain-community==0.2.12", "langchain-chroma==0.1.2", "fake-useragent==1.5.1", @@ -51,8 +51,9 @@ dependencies = [ "pypdf==4.3.1", "docx2txt==0.8", "python-pptx==1.0.0", - "unstructured==0.15.5", - "Markdown==3.6", + "unstructured==0.15.7", + "nltk==3.9.1", + "Markdown==3.7", "pypandoc==1.13", "pandas==2.2.2", "openpyxl==3.1.5", @@ -73,7 +74,7 @@ dependencies = [ "authlib==1.3.1", "black==24.8.0", - "langfuse==2.43.3", + "langfuse==2.44.0", "youtube-transcript-api==0.6.2", "pytube==15.0.0", diff --git a/requirements-dev.lock b/requirements-dev.lock deleted file mode 100644 index 01dcaa2c3c..0000000000 --- a/requirements-dev.lock +++ /dev/null @@ -1,750 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: [] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - --e file:. -aiohappyeyeballs==2.3.5 - # via aiohttp -aiohttp==3.10.2 - # via langchain - # via langchain-community - # via open-webui -aiosignal==1.3.1 - # via aiohttp -alembic==1.13.2 - # via open-webui -annotated-types==0.6.0 - # via pydantic -anthropic==0.32.0 - # via open-webui -anyio==4.4.0 - # via anthropic - # via httpx - # via langfuse - # via openai - # via starlette - # via watchfiles -apscheduler==3.10.4 - # via open-webui -argon2-cffi==23.1.0 - # via open-webui -argon2-cffi-bindings==21.2.0 - # via argon2-cffi -asgiref==3.8.1 - # via opentelemetry-instrumentation-asgi -attrs==23.2.0 - # via aiohttp - # via pytest-docker -authlib==1.3.1 - # via open-webui -av==11.0.0 - # via faster-whisper -backoff==2.2.1 - # via langfuse - # via posthog - # via unstructured -bcrypt==4.2.0 - # via chromadb - # via open-webui - # via passlib -beautifulsoup4==4.12.3 - # via extract-msg - # via unstructured -bidict==0.23.1 - # via python-socketio -black==24.8.0 - # via open-webui -blinker==1.8.2 - # via flask -boto3==1.35.0 - # via open-webui -botocore==1.35.2 - # via boto3 - # via s3transfer -build==1.2.1 - # via chromadb -cachetools==5.3.3 - # via google-auth -certifi==2024.2.2 - # via httpcore - # via httpx - # via kubernetes - # via requests - # via unstructured-client -cffi==1.16.0 - # via argon2-cffi-bindings - # via cryptography -chardet==5.2.0 - # via unstructured -charset-normalizer==3.3.2 - # via requests - # via unstructured-client -chroma-hnswlib==0.7.6 - # via chromadb -chromadb==0.5.5 - # via langchain-chroma - # via open-webui -click==8.1.7 - # via black - # via duckduckgo-search - # via flask - # via nltk - # via peewee-migrate - # via typer - # via uvicorn -colorclass==2.2.2 - # via oletools -coloredlogs==15.0.1 - # via onnxruntime -compressed-rtf==1.0.6 - # via extract-msg -cryptography==42.0.7 - # via authlib - # via msoffcrypto-tool - # via pyjwt -ctranslate2==4.2.1 - # via faster-whisper -dataclasses-json==0.6.6 - # via langchain-community - # via unstructured - # via unstructured-client -deepdiff==7.0.1 - # via unstructured-client -defusedxml==0.7.1 - # via fpdf2 -deprecated==1.2.14 - # via opentelemetry-api - # via opentelemetry-exporter-otlp-proto-grpc -distro==1.9.0 - # via anthropic - # via openai -dnspython==2.6.1 - # via email-validator - # via pymongo -docker==7.1.0 - # via open-webui -docx2txt==0.8 - # via open-webui -duckduckgo-search==6.2.6 - # via open-webui -easygui==0.98.3 - # via oletools -ebcdic==1.1.1 - # via extract-msg -ecdsa==0.19.0 - # via python-jose -email-validator==2.1.1 - # via fastapi -emoji==2.11.1 - # via unstructured -et-xmlfile==1.1.0 - # via openpyxl -extract-msg==0.48.5 - # via open-webui -fake-useragent==1.5.1 - # via open-webui -fastapi==0.111.0 - # via chromadb - # via langchain-chroma - # via open-webui -fastapi-cli==0.0.4 - # via fastapi -faster-whisper==1.0.3 - # via open-webui -filelock==3.14.0 - # via huggingface-hub - # via torch - # via transformers -filetype==1.2.0 - # via unstructured -flask==3.0.3 - # via flask-cors - # via open-webui -flask-cors==4.0.1 - # via open-webui -flatbuffers==24.3.25 - # via onnxruntime -fonttools==4.51.0 - # via fpdf2 -fpdf2==2.7.9 - # via open-webui -frozenlist==1.4.1 - # via aiohttp - # via aiosignal -fsspec==2024.3.1 - # via huggingface-hub - # via torch -google-ai-generativelanguage==0.6.6 - # via google-generativeai -google-api-core==2.19.0 - # via google-ai-generativelanguage - # via google-api-python-client - # via google-generativeai -google-api-python-client==2.129.0 - # via google-generativeai -google-auth==2.29.0 - # via google-ai-generativelanguage - # via google-api-core - # via google-api-python-client - # via google-auth-httplib2 - # via google-generativeai - # via kubernetes -google-auth-httplib2==0.2.0 - # via google-api-python-client -google-generativeai==0.7.2 - # via open-webui -googleapis-common-protos==1.63.0 - # via google-api-core - # via grpcio-status - # via opentelemetry-exporter-otlp-proto-grpc -grpcio==1.63.0 - # via chromadb - # via google-api-core - # via grpcio-status - # via opentelemetry-exporter-otlp-proto-grpc -grpcio-status==1.62.2 - # via google-api-core -h11==0.14.0 - # via httpcore - # via uvicorn - # via wsproto -httpcore==1.0.5 - # via httpx -httplib2==0.22.0 - # via google-api-python-client - # via google-auth-httplib2 -httptools==0.6.1 - # via uvicorn -httpx==0.27.0 - # via anthropic - # via chromadb - # via fastapi - # via langfuse - # via openai -huggingface-hub==0.23.0 - # via faster-whisper - # via sentence-transformers - # via tokenizers - # via transformers -humanfriendly==10.0 - # via coloredlogs -idna==3.7 - # via anyio - # via email-validator - # via httpx - # via langfuse - # via requests - # via unstructured-client - # via yarl -importlib-metadata==7.0.0 - # via opentelemetry-api -importlib-resources==6.4.0 - # via chromadb -iniconfig==2.0.0 - # via pytest -itsdangerous==2.2.0 - # via flask -jinja2==3.1.4 - # via fastapi - # via flask - # via torch -jiter==0.5.0 - # via anthropic -jmespath==1.0.1 - # via boto3 - # via botocore -joblib==1.4.2 - # via nltk - # via scikit-learn -jsonpatch==1.33 - # via langchain-core -jsonpath-python==1.0.6 - # via unstructured-client -jsonpointer==2.4 - # via jsonpatch -kubernetes==29.0.0 - # via chromadb -langchain==0.2.12 - # via langchain-community - # via open-webui -langchain-chroma==0.1.2 - # via open-webui -langchain-community==0.2.10 - # via open-webui -langchain-core==0.2.28 - # via langchain - # via langchain-chroma - # via langchain-community - # via langchain-text-splitters -langchain-text-splitters==0.2.0 - # via langchain -langdetect==1.0.9 - # via unstructured -langfuse==2.43.3 - # via open-webui -langsmith==0.1.96 - # via langchain - # via langchain-community - # via langchain-core -lark==1.1.8 - # via rtfde -lxml==5.2.2 - # via python-pptx - # via unstructured -mako==1.3.5 - # via alembic -markdown==3.6 - # via open-webui -markdown-it-py==3.0.0 - # via rich -markupsafe==2.1.5 - # via jinja2 - # via mako - # via werkzeug -marshmallow==3.21.2 - # via dataclasses-json - # via unstructured-client -mdurl==0.1.2 - # via markdown-it-py -mmh3==4.1.0 - # via chromadb -monotonic==1.6 - # via posthog -mpmath==1.3.0 - # via sympy -msoffcrypto-tool==5.4.1 - # via oletools -multidict==6.0.5 - # via aiohttp - # via yarl -mypy-extensions==1.0.0 - # via black - # via typing-inspect - # via unstructured-client -networkx==3.3 - # via torch -nltk==3.8.1 - # via unstructured -numpy==1.26.4 - # via chroma-hnswlib - # via chromadb - # via ctranslate2 - # via langchain - # via langchain-chroma - # via langchain-community - # via onnxruntime - # via opencv-python - # via opencv-python-headless - # via pandas - # via rank-bm25 - # via rapidocr-onnxruntime - # via scikit-learn - # via scipy - # via sentence-transformers - # via shapely - # via transformers - # via unstructured -oauthlib==3.2.2 - # via kubernetes - # via requests-oauthlib -olefile==0.47 - # via extract-msg - # via msoffcrypto-tool - # via oletools -oletools==0.60.1 - # via pcodedmp - # via rtfde -onnxruntime==1.17.3 - # via chromadb - # via faster-whisper - # via rapidocr-onnxruntime -openai==1.38.0 - # via open-webui -opencv-python==4.9.0.80 - # via rapidocr-onnxruntime -opencv-python-headless==4.10.0.84 - # via open-webui -openpyxl==3.1.5 - # via open-webui -opentelemetry-api==1.24.0 - # via chromadb - # via opentelemetry-exporter-otlp-proto-grpc - # via opentelemetry-instrumentation - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi - # via opentelemetry-sdk -opentelemetry-exporter-otlp-proto-common==1.24.0 - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-exporter-otlp-proto-grpc==1.24.0 - # via chromadb -opentelemetry-instrumentation==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi -opentelemetry-instrumentation-asgi==0.45b0 - # via opentelemetry-instrumentation-fastapi -opentelemetry-instrumentation-fastapi==0.45b0 - # via chromadb -opentelemetry-proto==1.24.0 - # via opentelemetry-exporter-otlp-proto-common - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-sdk==1.24.0 - # via chromadb - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-semantic-conventions==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi - # via opentelemetry-sdk -opentelemetry-util-http==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi -ordered-set==4.1.0 - # via deepdiff -orjson==3.10.3 - # via chromadb - # via fastapi - # via langsmith -overrides==7.7.0 - # via chromadb -packaging==23.2 - # via black - # via build - # via huggingface-hub - # via langchain-core - # via langfuse - # via marshmallow - # via onnxruntime - # via pytest - # via transformers - # via unstructured-client -pandas==2.2.2 - # via open-webui -passlib==1.7.4 - # via open-webui -pathspec==0.12.1 - # via black -pcodedmp==1.2.6 - # via oletools -peewee==3.17.6 - # via open-webui - # via peewee-migrate -peewee-migrate==1.12.2 - # via open-webui -pillow==10.3.0 - # via fpdf2 - # via python-pptx - # via rapidocr-onnxruntime - # via sentence-transformers -platformdirs==4.2.1 - # via black -pluggy==1.5.0 - # via pytest -posthog==3.5.0 - # via chromadb -primp==0.5.5 - # via duckduckgo-search -proto-plus==1.23.0 - # via google-ai-generativelanguage - # via google-api-core -protobuf==4.25.3 - # via google-ai-generativelanguage - # via google-api-core - # via google-generativeai - # via googleapis-common-protos - # via grpcio-status - # via onnxruntime - # via opentelemetry-proto - # via proto-plus -psutil==6.0.0 - # via open-webui - # via unstructured -psycopg2-binary==2.9.9 - # via open-webui -pyasn1==0.6.0 - # via pyasn1-modules - # via python-jose - # via rsa -pyasn1-modules==0.4.0 - # via google-auth -pyclipper==1.3.0.post5 - # via rapidocr-onnxruntime -pycparser==2.22 - # via cffi -pydantic==2.8.2 - # via anthropic - # via chromadb - # via fastapi - # via google-generativeai - # via langchain - # via langchain-core - # via langfuse - # via langsmith - # via open-webui - # via openai -pydantic-core==2.20.1 - # via pydantic -pydub==0.25.1 - # via open-webui -pygments==2.18.0 - # via rich -pyjwt==2.9.0 - # via open-webui -pymongo==4.8.0 - # via open-webui -pymysql==1.1.1 - # via open-webui -pypandoc==1.13 - # via open-webui -pyparsing==2.4.7 - # via httplib2 - # via oletools -pypdf==4.3.1 - # via open-webui - # via unstructured-client -pypika==0.48.9 - # via chromadb -pyproject-hooks==1.1.0 - # via build -pytest==8.2.2 - # via open-webui - # via pytest-docker -pytest-docker==3.1.1 - # via open-webui -python-dateutil==2.9.0.post0 - # via botocore - # via kubernetes - # via pandas - # via posthog - # via unstructured-client -python-dotenv==1.0.1 - # via uvicorn -python-engineio==4.9.0 - # via python-socketio -python-iso639==2024.4.27 - # via unstructured -python-jose==3.3.0 - # via open-webui -python-magic==0.4.27 - # via unstructured -python-multipart==0.0.9 - # via fastapi - # via open-webui -python-pptx==1.0.0 - # via open-webui -python-socketio==5.11.3 - # via open-webui -pytube==15.0.0 - # via open-webui -pytz==2024.1 - # via apscheduler - # via pandas -pyxlsb==1.0.10 - # via open-webui -pyyaml==6.0.1 - # via chromadb - # via ctranslate2 - # via huggingface-hub - # via kubernetes - # via langchain - # via langchain-community - # via langchain-core - # via rapidocr-onnxruntime - # via transformers - # via uvicorn -rank-bm25==0.2.2 - # via open-webui -rapidfuzz==3.9.0 - # via unstructured -rapidocr-onnxruntime==1.3.24 - # via open-webui -red-black-tree-mod==1.20 - # via extract-msg -redis==5.0.8 - # via open-webui -regex==2024.5.10 - # via nltk - # via tiktoken - # via transformers -requests==2.32.3 - # via docker - # via google-api-core - # via huggingface-hub - # via kubernetes - # via langchain - # via langchain-community - # via langsmith - # via open-webui - # via posthog - # via requests-oauthlib - # via tiktoken - # via transformers - # via unstructured - # via unstructured-client - # via youtube-transcript-api -requests-oauthlib==2.0.0 - # via kubernetes -rich==13.7.1 - # via typer -rsa==4.9 - # via google-auth - # via python-jose -rtfde==0.1.1 - # via extract-msg -s3transfer==0.10.1 - # via boto3 -safetensors==0.4.3 - # via transformers -scikit-learn==1.4.2 - # via sentence-transformers -scipy==1.13.0 - # via scikit-learn - # via sentence-transformers -sentence-transformers==3.0.1 - # via open-webui -setuptools==69.5.1 - # via ctranslate2 - # via opentelemetry-instrumentation -shapely==2.0.5 - # via rapidocr-onnxruntime -shellingham==1.5.4 - # via typer -simple-websocket==1.0.0 - # via python-engineio -six==1.16.0 - # via apscheduler - # via ecdsa - # via kubernetes - # via langdetect - # via posthog - # via python-dateutil - # via rapidocr-onnxruntime - # via unstructured-client -sniffio==1.3.1 - # via anthropic - # via anyio - # via httpx - # via openai -soupsieve==2.5 - # via beautifulsoup4 -sqlalchemy==2.0.32 - # via alembic - # via langchain - # via langchain-community - # via open-webui -starlette==0.37.2 - # via fastapi -sympy==1.12 - # via onnxruntime - # via torch -tabulate==0.9.0 - # via unstructured -tenacity==8.3.0 - # via chromadb - # via langchain - # via langchain-community - # via langchain-core -threadpoolctl==3.5.0 - # via scikit-learn -tiktoken==0.7.0 - # via open-webui -tokenizers==0.15.2 - # via anthropic - # via chromadb - # via faster-whisper - # via transformers -torch==2.3.0 - # via sentence-transformers -tqdm==4.66.4 - # via chromadb - # via google-generativeai - # via huggingface-hub - # via nltk - # via openai - # via sentence-transformers - # via transformers - # via unstructured -transformers==4.39.3 - # via sentence-transformers -typer==0.12.3 - # via chromadb - # via fastapi-cli -typing-extensions==4.11.0 - # via alembic - # via anthropic - # via chromadb - # via fastapi - # via google-generativeai - # via huggingface-hub - # via langchain-core - # via openai - # via opentelemetry-sdk - # via pydantic - # via pydantic-core - # via python-pptx - # via sqlalchemy - # via torch - # via typer - # via typing-inspect - # via unstructured - # via unstructured-client -typing-inspect==0.9.0 - # via dataclasses-json - # via unstructured-client -tzdata==2024.1 - # via pandas -tzlocal==5.2 - # via apscheduler - # via extract-msg -ujson==5.10.0 - # via fastapi -unstructured==0.15.5 - # via open-webui -unstructured-client==0.22.0 - # via unstructured -uritemplate==4.1.1 - # via google-api-python-client -urllib3==2.2.1 - # via botocore - # via docker - # via kubernetes - # via requests - # via unstructured-client -uvicorn==0.30.6 - # via chromadb - # via fastapi - # via open-webui -uvloop==0.19.0 - # via uvicorn -validators==0.33.0 - # via open-webui -watchfiles==0.21.0 - # via uvicorn -websocket-client==1.8.0 - # via kubernetes -websockets==12.0 - # via uvicorn -werkzeug==3.0.3 - # via flask -wrapt==1.16.0 - # via deprecated - # via langfuse - # via opentelemetry-instrumentation - # via unstructured -wsproto==1.2.0 - # via simple-websocket -xlrd==2.0.1 - # via open-webui -xlsxwriter==3.2.0 - # via python-pptx -yarl==1.9.4 - # via aiohttp -youtube-transcript-api==0.6.2 - # via open-webui -zipp==3.18.1 - # via importlib-metadata diff --git a/requirements.lock b/requirements.lock deleted file mode 100644 index 01dcaa2c3c..0000000000 --- a/requirements.lock +++ /dev/null @@ -1,750 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: [] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - --e file:. -aiohappyeyeballs==2.3.5 - # via aiohttp -aiohttp==3.10.2 - # via langchain - # via langchain-community - # via open-webui -aiosignal==1.3.1 - # via aiohttp -alembic==1.13.2 - # via open-webui -annotated-types==0.6.0 - # via pydantic -anthropic==0.32.0 - # via open-webui -anyio==4.4.0 - # via anthropic - # via httpx - # via langfuse - # via openai - # via starlette - # via watchfiles -apscheduler==3.10.4 - # via open-webui -argon2-cffi==23.1.0 - # via open-webui -argon2-cffi-bindings==21.2.0 - # via argon2-cffi -asgiref==3.8.1 - # via opentelemetry-instrumentation-asgi -attrs==23.2.0 - # via aiohttp - # via pytest-docker -authlib==1.3.1 - # via open-webui -av==11.0.0 - # via faster-whisper -backoff==2.2.1 - # via langfuse - # via posthog - # via unstructured -bcrypt==4.2.0 - # via chromadb - # via open-webui - # via passlib -beautifulsoup4==4.12.3 - # via extract-msg - # via unstructured -bidict==0.23.1 - # via python-socketio -black==24.8.0 - # via open-webui -blinker==1.8.2 - # via flask -boto3==1.35.0 - # via open-webui -botocore==1.35.2 - # via boto3 - # via s3transfer -build==1.2.1 - # via chromadb -cachetools==5.3.3 - # via google-auth -certifi==2024.2.2 - # via httpcore - # via httpx - # via kubernetes - # via requests - # via unstructured-client -cffi==1.16.0 - # via argon2-cffi-bindings - # via cryptography -chardet==5.2.0 - # via unstructured -charset-normalizer==3.3.2 - # via requests - # via unstructured-client -chroma-hnswlib==0.7.6 - # via chromadb -chromadb==0.5.5 - # via langchain-chroma - # via open-webui -click==8.1.7 - # via black - # via duckduckgo-search - # via flask - # via nltk - # via peewee-migrate - # via typer - # via uvicorn -colorclass==2.2.2 - # via oletools -coloredlogs==15.0.1 - # via onnxruntime -compressed-rtf==1.0.6 - # via extract-msg -cryptography==42.0.7 - # via authlib - # via msoffcrypto-tool - # via pyjwt -ctranslate2==4.2.1 - # via faster-whisper -dataclasses-json==0.6.6 - # via langchain-community - # via unstructured - # via unstructured-client -deepdiff==7.0.1 - # via unstructured-client -defusedxml==0.7.1 - # via fpdf2 -deprecated==1.2.14 - # via opentelemetry-api - # via opentelemetry-exporter-otlp-proto-grpc -distro==1.9.0 - # via anthropic - # via openai -dnspython==2.6.1 - # via email-validator - # via pymongo -docker==7.1.0 - # via open-webui -docx2txt==0.8 - # via open-webui -duckduckgo-search==6.2.6 - # via open-webui -easygui==0.98.3 - # via oletools -ebcdic==1.1.1 - # via extract-msg -ecdsa==0.19.0 - # via python-jose -email-validator==2.1.1 - # via fastapi -emoji==2.11.1 - # via unstructured -et-xmlfile==1.1.0 - # via openpyxl -extract-msg==0.48.5 - # via open-webui -fake-useragent==1.5.1 - # via open-webui -fastapi==0.111.0 - # via chromadb - # via langchain-chroma - # via open-webui -fastapi-cli==0.0.4 - # via fastapi -faster-whisper==1.0.3 - # via open-webui -filelock==3.14.0 - # via huggingface-hub - # via torch - # via transformers -filetype==1.2.0 - # via unstructured -flask==3.0.3 - # via flask-cors - # via open-webui -flask-cors==4.0.1 - # via open-webui -flatbuffers==24.3.25 - # via onnxruntime -fonttools==4.51.0 - # via fpdf2 -fpdf2==2.7.9 - # via open-webui -frozenlist==1.4.1 - # via aiohttp - # via aiosignal -fsspec==2024.3.1 - # via huggingface-hub - # via torch -google-ai-generativelanguage==0.6.6 - # via google-generativeai -google-api-core==2.19.0 - # via google-ai-generativelanguage - # via google-api-python-client - # via google-generativeai -google-api-python-client==2.129.0 - # via google-generativeai -google-auth==2.29.0 - # via google-ai-generativelanguage - # via google-api-core - # via google-api-python-client - # via google-auth-httplib2 - # via google-generativeai - # via kubernetes -google-auth-httplib2==0.2.0 - # via google-api-python-client -google-generativeai==0.7.2 - # via open-webui -googleapis-common-protos==1.63.0 - # via google-api-core - # via grpcio-status - # via opentelemetry-exporter-otlp-proto-grpc -grpcio==1.63.0 - # via chromadb - # via google-api-core - # via grpcio-status - # via opentelemetry-exporter-otlp-proto-grpc -grpcio-status==1.62.2 - # via google-api-core -h11==0.14.0 - # via httpcore - # via uvicorn - # via wsproto -httpcore==1.0.5 - # via httpx -httplib2==0.22.0 - # via google-api-python-client - # via google-auth-httplib2 -httptools==0.6.1 - # via uvicorn -httpx==0.27.0 - # via anthropic - # via chromadb - # via fastapi - # via langfuse - # via openai -huggingface-hub==0.23.0 - # via faster-whisper - # via sentence-transformers - # via tokenizers - # via transformers -humanfriendly==10.0 - # via coloredlogs -idna==3.7 - # via anyio - # via email-validator - # via httpx - # via langfuse - # via requests - # via unstructured-client - # via yarl -importlib-metadata==7.0.0 - # via opentelemetry-api -importlib-resources==6.4.0 - # via chromadb -iniconfig==2.0.0 - # via pytest -itsdangerous==2.2.0 - # via flask -jinja2==3.1.4 - # via fastapi - # via flask - # via torch -jiter==0.5.0 - # via anthropic -jmespath==1.0.1 - # via boto3 - # via botocore -joblib==1.4.2 - # via nltk - # via scikit-learn -jsonpatch==1.33 - # via langchain-core -jsonpath-python==1.0.6 - # via unstructured-client -jsonpointer==2.4 - # via jsonpatch -kubernetes==29.0.0 - # via chromadb -langchain==0.2.12 - # via langchain-community - # via open-webui -langchain-chroma==0.1.2 - # via open-webui -langchain-community==0.2.10 - # via open-webui -langchain-core==0.2.28 - # via langchain - # via langchain-chroma - # via langchain-community - # via langchain-text-splitters -langchain-text-splitters==0.2.0 - # via langchain -langdetect==1.0.9 - # via unstructured -langfuse==2.43.3 - # via open-webui -langsmith==0.1.96 - # via langchain - # via langchain-community - # via langchain-core -lark==1.1.8 - # via rtfde -lxml==5.2.2 - # via python-pptx - # via unstructured -mako==1.3.5 - # via alembic -markdown==3.6 - # via open-webui -markdown-it-py==3.0.0 - # via rich -markupsafe==2.1.5 - # via jinja2 - # via mako - # via werkzeug -marshmallow==3.21.2 - # via dataclasses-json - # via unstructured-client -mdurl==0.1.2 - # via markdown-it-py -mmh3==4.1.0 - # via chromadb -monotonic==1.6 - # via posthog -mpmath==1.3.0 - # via sympy -msoffcrypto-tool==5.4.1 - # via oletools -multidict==6.0.5 - # via aiohttp - # via yarl -mypy-extensions==1.0.0 - # via black - # via typing-inspect - # via unstructured-client -networkx==3.3 - # via torch -nltk==3.8.1 - # via unstructured -numpy==1.26.4 - # via chroma-hnswlib - # via chromadb - # via ctranslate2 - # via langchain - # via langchain-chroma - # via langchain-community - # via onnxruntime - # via opencv-python - # via opencv-python-headless - # via pandas - # via rank-bm25 - # via rapidocr-onnxruntime - # via scikit-learn - # via scipy - # via sentence-transformers - # via shapely - # via transformers - # via unstructured -oauthlib==3.2.2 - # via kubernetes - # via requests-oauthlib -olefile==0.47 - # via extract-msg - # via msoffcrypto-tool - # via oletools -oletools==0.60.1 - # via pcodedmp - # via rtfde -onnxruntime==1.17.3 - # via chromadb - # via faster-whisper - # via rapidocr-onnxruntime -openai==1.38.0 - # via open-webui -opencv-python==4.9.0.80 - # via rapidocr-onnxruntime -opencv-python-headless==4.10.0.84 - # via open-webui -openpyxl==3.1.5 - # via open-webui -opentelemetry-api==1.24.0 - # via chromadb - # via opentelemetry-exporter-otlp-proto-grpc - # via opentelemetry-instrumentation - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi - # via opentelemetry-sdk -opentelemetry-exporter-otlp-proto-common==1.24.0 - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-exporter-otlp-proto-grpc==1.24.0 - # via chromadb -opentelemetry-instrumentation==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi -opentelemetry-instrumentation-asgi==0.45b0 - # via opentelemetry-instrumentation-fastapi -opentelemetry-instrumentation-fastapi==0.45b0 - # via chromadb -opentelemetry-proto==1.24.0 - # via opentelemetry-exporter-otlp-proto-common - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-sdk==1.24.0 - # via chromadb - # via opentelemetry-exporter-otlp-proto-grpc -opentelemetry-semantic-conventions==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi - # via opentelemetry-sdk -opentelemetry-util-http==0.45b0 - # via opentelemetry-instrumentation-asgi - # via opentelemetry-instrumentation-fastapi -ordered-set==4.1.0 - # via deepdiff -orjson==3.10.3 - # via chromadb - # via fastapi - # via langsmith -overrides==7.7.0 - # via chromadb -packaging==23.2 - # via black - # via build - # via huggingface-hub - # via langchain-core - # via langfuse - # via marshmallow - # via onnxruntime - # via pytest - # via transformers - # via unstructured-client -pandas==2.2.2 - # via open-webui -passlib==1.7.4 - # via open-webui -pathspec==0.12.1 - # via black -pcodedmp==1.2.6 - # via oletools -peewee==3.17.6 - # via open-webui - # via peewee-migrate -peewee-migrate==1.12.2 - # via open-webui -pillow==10.3.0 - # via fpdf2 - # via python-pptx - # via rapidocr-onnxruntime - # via sentence-transformers -platformdirs==4.2.1 - # via black -pluggy==1.5.0 - # via pytest -posthog==3.5.0 - # via chromadb -primp==0.5.5 - # via duckduckgo-search -proto-plus==1.23.0 - # via google-ai-generativelanguage - # via google-api-core -protobuf==4.25.3 - # via google-ai-generativelanguage - # via google-api-core - # via google-generativeai - # via googleapis-common-protos - # via grpcio-status - # via onnxruntime - # via opentelemetry-proto - # via proto-plus -psutil==6.0.0 - # via open-webui - # via unstructured -psycopg2-binary==2.9.9 - # via open-webui -pyasn1==0.6.0 - # via pyasn1-modules - # via python-jose - # via rsa -pyasn1-modules==0.4.0 - # via google-auth -pyclipper==1.3.0.post5 - # via rapidocr-onnxruntime -pycparser==2.22 - # via cffi -pydantic==2.8.2 - # via anthropic - # via chromadb - # via fastapi - # via google-generativeai - # via langchain - # via langchain-core - # via langfuse - # via langsmith - # via open-webui - # via openai -pydantic-core==2.20.1 - # via pydantic -pydub==0.25.1 - # via open-webui -pygments==2.18.0 - # via rich -pyjwt==2.9.0 - # via open-webui -pymongo==4.8.0 - # via open-webui -pymysql==1.1.1 - # via open-webui -pypandoc==1.13 - # via open-webui -pyparsing==2.4.7 - # via httplib2 - # via oletools -pypdf==4.3.1 - # via open-webui - # via unstructured-client -pypika==0.48.9 - # via chromadb -pyproject-hooks==1.1.0 - # via build -pytest==8.2.2 - # via open-webui - # via pytest-docker -pytest-docker==3.1.1 - # via open-webui -python-dateutil==2.9.0.post0 - # via botocore - # via kubernetes - # via pandas - # via posthog - # via unstructured-client -python-dotenv==1.0.1 - # via uvicorn -python-engineio==4.9.0 - # via python-socketio -python-iso639==2024.4.27 - # via unstructured -python-jose==3.3.0 - # via open-webui -python-magic==0.4.27 - # via unstructured -python-multipart==0.0.9 - # via fastapi - # via open-webui -python-pptx==1.0.0 - # via open-webui -python-socketio==5.11.3 - # via open-webui -pytube==15.0.0 - # via open-webui -pytz==2024.1 - # via apscheduler - # via pandas -pyxlsb==1.0.10 - # via open-webui -pyyaml==6.0.1 - # via chromadb - # via ctranslate2 - # via huggingface-hub - # via kubernetes - # via langchain - # via langchain-community - # via langchain-core - # via rapidocr-onnxruntime - # via transformers - # via uvicorn -rank-bm25==0.2.2 - # via open-webui -rapidfuzz==3.9.0 - # via unstructured -rapidocr-onnxruntime==1.3.24 - # via open-webui -red-black-tree-mod==1.20 - # via extract-msg -redis==5.0.8 - # via open-webui -regex==2024.5.10 - # via nltk - # via tiktoken - # via transformers -requests==2.32.3 - # via docker - # via google-api-core - # via huggingface-hub - # via kubernetes - # via langchain - # via langchain-community - # via langsmith - # via open-webui - # via posthog - # via requests-oauthlib - # via tiktoken - # via transformers - # via unstructured - # via unstructured-client - # via youtube-transcript-api -requests-oauthlib==2.0.0 - # via kubernetes -rich==13.7.1 - # via typer -rsa==4.9 - # via google-auth - # via python-jose -rtfde==0.1.1 - # via extract-msg -s3transfer==0.10.1 - # via boto3 -safetensors==0.4.3 - # via transformers -scikit-learn==1.4.2 - # via sentence-transformers -scipy==1.13.0 - # via scikit-learn - # via sentence-transformers -sentence-transformers==3.0.1 - # via open-webui -setuptools==69.5.1 - # via ctranslate2 - # via opentelemetry-instrumentation -shapely==2.0.5 - # via rapidocr-onnxruntime -shellingham==1.5.4 - # via typer -simple-websocket==1.0.0 - # via python-engineio -six==1.16.0 - # via apscheduler - # via ecdsa - # via kubernetes - # via langdetect - # via posthog - # via python-dateutil - # via rapidocr-onnxruntime - # via unstructured-client -sniffio==1.3.1 - # via anthropic - # via anyio - # via httpx - # via openai -soupsieve==2.5 - # via beautifulsoup4 -sqlalchemy==2.0.32 - # via alembic - # via langchain - # via langchain-community - # via open-webui -starlette==0.37.2 - # via fastapi -sympy==1.12 - # via onnxruntime - # via torch -tabulate==0.9.0 - # via unstructured -tenacity==8.3.0 - # via chromadb - # via langchain - # via langchain-community - # via langchain-core -threadpoolctl==3.5.0 - # via scikit-learn -tiktoken==0.7.0 - # via open-webui -tokenizers==0.15.2 - # via anthropic - # via chromadb - # via faster-whisper - # via transformers -torch==2.3.0 - # via sentence-transformers -tqdm==4.66.4 - # via chromadb - # via google-generativeai - # via huggingface-hub - # via nltk - # via openai - # via sentence-transformers - # via transformers - # via unstructured -transformers==4.39.3 - # via sentence-transformers -typer==0.12.3 - # via chromadb - # via fastapi-cli -typing-extensions==4.11.0 - # via alembic - # via anthropic - # via chromadb - # via fastapi - # via google-generativeai - # via huggingface-hub - # via langchain-core - # via openai - # via opentelemetry-sdk - # via pydantic - # via pydantic-core - # via python-pptx - # via sqlalchemy - # via torch - # via typer - # via typing-inspect - # via unstructured - # via unstructured-client -typing-inspect==0.9.0 - # via dataclasses-json - # via unstructured-client -tzdata==2024.1 - # via pandas -tzlocal==5.2 - # via apscheduler - # via extract-msg -ujson==5.10.0 - # via fastapi -unstructured==0.15.5 - # via open-webui -unstructured-client==0.22.0 - # via unstructured -uritemplate==4.1.1 - # via google-api-python-client -urllib3==2.2.1 - # via botocore - # via docker - # via kubernetes - # via requests - # via unstructured-client -uvicorn==0.30.6 - # via chromadb - # via fastapi - # via open-webui -uvloop==0.19.0 - # via uvicorn -validators==0.33.0 - # via open-webui -watchfiles==0.21.0 - # via uvicorn -websocket-client==1.8.0 - # via kubernetes -websockets==12.0 - # via uvicorn -werkzeug==3.0.3 - # via flask -wrapt==1.16.0 - # via deprecated - # via langfuse - # via opentelemetry-instrumentation - # via unstructured -wsproto==1.2.0 - # via simple-websocket -xlrd==2.0.1 - # via open-webui -xlsxwriter==3.2.0 - # via python-pptx -yarl==1.9.4 - # via aiohttp -youtube-transcript-api==0.6.2 - # via open-webui -zipp==3.18.1 - # via importlib-metadata diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index af09af9907..5cd6ab949c 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -132,7 +132,11 @@ export const synthesizeOpenAISpeech = async ( return res; }; -export const getModels = async (token: string = '') => { +interface AvailableModelsResponse { + models: { name: string; id: string }[] | { id: string }[]; +} + +export const getModels = async (token: string = ''): Promise => { let error = null; const res = await fetch(`${AUDIO_API_BASE_URL}/models`, { diff --git a/src/lib/apis/memories/index.ts b/src/lib/apis/memories/index.ts index c3c122adf8..3fd83ca9e0 100644 --- a/src/lib/apis/memories/index.ts +++ b/src/lib/apis/memories/index.ts @@ -156,7 +156,7 @@ export const deleteMemoryById = async (token: string, id: string) => { export const deleteMemoriesByUserId = async (token: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/memories/user`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/memories/delete/user`, { method: 'DELETE', headers: { Accept: 'application/json', diff --git a/src/lib/apis/rag/index.ts b/src/lib/apis/rag/index.ts index 5c0a47b357..3c0dba4b55 100644 --- a/src/lib/apis/rag/index.ts +++ b/src/lib/apis/rag/index.ts @@ -400,7 +400,7 @@ export const resetUploadDir = async (token: string) => { let error = null; const res = await fetch(`${RAG_API_BASE_URL}/reset/uploads`, { - method: 'GET', + method: 'POST', headers: { Accept: 'application/json', authorization: `Bearer ${token}` @@ -426,7 +426,7 @@ export const resetVectorDB = async (token: string) => { let error = null; const res = await fetch(`${RAG_API_BASE_URL}/reset/db`, { - method: 'GET', + method: 'POST', headers: { Accept: 'application/json', authorization: `Bearer ${token}` diff --git a/src/lib/components/admin/Settings.svelte b/src/lib/components/admin/Settings.svelte index e242ab632a..e02b794197 100644 --- a/src/lib/components/admin/Settings.svelte +++ b/src/lib/components/admin/Settings.svelte @@ -359,8 +359,11 @@ {:else if selectedTab === 'documents'} { + on:save={async () => { toast.success($i18n.t('Settings saved successfully!')); + + await tick(); + await config.set(await getBackendConfig()); }} /> {:else if selectedTab === 'web'} diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index 7c33005682..1c114c9dd2 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -10,31 +10,36 @@ getModels as _getModels, getVoices as _getVoices } from '$lib/apis/audio'; - import { user, settings, config } from '$lib/stores'; + import { config } from '$lib/stores'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; - const i18n = getContext('i18n'); + import { TTS_RESPONSE_SPLIT } from '$lib/types'; - export let saveHandler: Function; + import type { Writable } from 'svelte/store'; + import type { i18n as i18nType } from 'i18next'; + + const i18n = getContext>('i18n'); + + export let saveHandler: () => void; // Audio - let TTS_OPENAI_API_BASE_URL = ''; let TTS_OPENAI_API_KEY = ''; let TTS_API_KEY = ''; let TTS_ENGINE = ''; let TTS_MODEL = ''; let TTS_VOICE = ''; + let TTS_SPLIT_ON: TTS_RESPONSE_SPLIT = TTS_RESPONSE_SPLIT.PUNCTUATION; let STT_OPENAI_API_BASE_URL = ''; let STT_OPENAI_API_KEY = ''; let STT_ENGINE = ''; let STT_MODEL = ''; - let voices = []; - let models = []; - let nonLocalVoices = false; + // eslint-disable-next-line no-undef + let voices: SpeechSynthesisVoice[] = []; + let models: Awaited>['models'] = []; const getModels = async () => { if (TTS_ENGINE === '') { @@ -53,8 +58,8 @@ const getVoices = async () => { if (TTS_ENGINE === '') { - const getVoicesLoop = setInterval(async () => { - voices = await speechSynthesis.getVoices(); + const getVoicesLoop = setInterval(() => { + voices = speechSynthesis.getVoices(); // do your loop if (voices.length > 0) { @@ -81,7 +86,8 @@ API_KEY: TTS_API_KEY, ENGINE: TTS_ENGINE, MODEL: TTS_MODEL, - VOICE: TTS_VOICE + VOICE: TTS_VOICE, + SPLIT_ON: TTS_SPLIT_ON }, stt: { OPENAI_API_BASE_URL: STT_OPENAI_API_BASE_URL, @@ -92,9 +98,10 @@ }); if (res) { - toast.success($i18n.t('Audio settings updated successfully')); - - config.set(await getBackendConfig()); + saveHandler(); + getBackendConfig() + .then(config.set) + .catch(() => {}); } }; @@ -111,6 +118,8 @@ TTS_MODEL = res.tts.MODEL; TTS_VOICE = res.tts.VOICE; + TTS_SPLIT_ON = res.tts.SPLIT_ON || TTS_RESPONSE_SPLIT.PUNCTUATION; + STT_OPENAI_API_BASE_URL = res.stt.OPENAI_API_BASE_URL; STT_OPENAI_API_KEY = res.stt.OPENAI_API_KEY; @@ -139,7 +148,7 @@
{$i18n.t('Speech-to-Text Engine')}
{ @@ -203,7 +212,7 @@ await getVoices(); await getModels(); - if (e.target.value === 'openai') { + if (e.target?.value === 'openai') { TTS_VOICE = 'alloy'; TTS_MODEL = 'tts-1'; } else { @@ -351,6 +360,30 @@
{/if} + +
+ +
+
{$i18n.t('Response splitting')}
+
+ +
+
+
+ {$i18n.t( + "Control how message text is split for TTS requests. 'Punctuation' splits into sentences, 'paragraphs' splits into paragraphs, and 'none' keeps the message as a single string." + )} +
diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index bac84902f9..7f935a08a0 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -1,4 +1,8 @@ @@ -266,7 +278,6 @@ class="flex flex-col h-full justify-between space-y-3 text-sm" on:submit|preventDefault={() => { submitHandler(); - saveHandler(); }} >
@@ -610,6 +621,62 @@
{/if} + +
+ +
+
{$i18n.t('Files')}
+ +
+
+
+ {$i18n.t('Max Upload Size')} +
+ +
+ + + +
+
+ +
+
+ {$i18n.t('Max Upload Count')} +
+
+ + + +
+
+
+
+
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 543b7b77de..3b3c9cf613 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -3,13 +3,13 @@ import { toast } from 'svelte-sonner'; import mermaid from 'mermaid'; - import { getContext, onMount, tick } from 'svelte'; + import { getContext, onDestroy, onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import type { Writable } from 'svelte/store'; + import type { Unsubscriber, Writable } from 'svelte/store'; import type { i18n as i18nType } from 'i18next'; - import { OLLAMA_API_BASE_URL, OPENAI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; + import { WEBUI_BASE_URL } from '$lib/constants'; import { chatId, @@ -19,31 +19,26 @@ models, settings, showSidebar, - tags as _tags, WEBUI_NAME, banners, user, socket, showCallOverlay, - tools, currentChatPage, temporaryChatEnabled } from '$lib/stores'; import { convertMessagesToHistory, copyToClipboard, + getMessageContentParts, extractSentencesForAudio, - getUserPosition, promptTemplate, splitStream } from '$lib/utils'; import { generateChatCompletion } from '$lib/apis/ollama'; import { - addTagById, createNewChat, - deleteTagById, - getAllChatTags, getChatById, getChatList, getTagsById, @@ -66,8 +61,6 @@ import MessageInput from '$lib/components/chat/MessageInput.svelte'; import Messages from '$lib/components/chat/Messages.svelte'; import Navbar from '$lib/components/layout/Navbar.svelte'; - import CallOverlay from './MessageInput/CallOverlay.svelte'; - import { error } from '@sveltejs/kit'; import ChatControls from './ChatControls.svelte'; import EventConfirmDialog from '../common/ConfirmDialog.svelte'; @@ -118,6 +111,8 @@ let params = {}; + let chatIdUnsubscriber: Unsubscriber | undefined; + $: if (history.currentId !== null) { let _messages = []; @@ -207,47 +202,51 @@ } }; - onMount(async () => { - const onMessageHandler = async (event) => { - if (event.origin === window.origin) { - // Replace with your iframe's origin - console.log('Message received from iframe:', event.data); - if (event.data.type === 'input:prompt') { - console.log(event.data.text); + const onMessageHandler = async (event: { + origin: string; + data: { type: string; text: string }; + }) => { + if (event.origin !== window.origin) { + return; + } - const inputElement = document.getElementById('chat-textarea'); + // Replace with your iframe's origin + if (event.data.type === 'input:prompt') { + console.debug(event.data.text); - if (inputElement) { - prompt = event.data.text; - inputElement.focus(); - } - } + const inputElement = document.getElementById('chat-textarea'); - if (event.data.type === 'action:submit') { - console.log(event.data.text); - - if (prompt !== '') { - await tick(); - submitPrompt(prompt); - } - } - - if (event.data.type === 'input:prompt:submit') { - console.log(event.data.text); - - if (prompt !== '') { - await tick(); - submitPrompt(event.data.text); - } - } + if (inputElement) { + prompt = event.data.text; + inputElement.focus(); } - }; - window.addEventListener('message', onMessageHandler); + } - $socket.on('chat-events', chatEventHandler); + if (event.data.type === 'action:submit') { + console.debug(event.data.text); + + if (prompt !== '') { + await tick(); + submitPrompt(prompt); + } + } + + if (event.data.type === 'input:prompt:submit') { + console.debug(event.data.text); + + if (prompt !== '') { + await tick(); + submitPrompt(event.data.text); + } + } + }; + + onMount(async () => { + window.addEventListener('message', onMessageHandler); + $socket?.on('chat-events', chatEventHandler); if (!$chatId) { - chatId.subscribe(async (value) => { + chatIdUnsubscriber = chatId.subscribe(async (value) => { if (!value) { await initNewChat(); } @@ -257,12 +256,12 @@ await goto('/'); } } + }); - return () => { - window.removeEventListener('message', onMessageHandler); - - $socket.off('chat-events'); - }; + onDestroy(() => { + chatIdUnsubscriber?.(); + window.removeEventListener('message', onMessageHandler); + $socket?.off('chat-events'); }); ////////////////////////// @@ -311,6 +310,10 @@ } } + if ($page.url.searchParams.get('call') === 'true') { + showCallOverlay.set(true); + } + selectedModels = selectedModels.map((modelId) => $models.map((m) => m.id).includes(modelId) ? modelId : '' ); @@ -539,6 +542,16 @@ `Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.` ) ); + } else if ( + ($config?.file?.max_count ?? null) !== null && + files.length + chatFiles.length > $config?.file?.max_count + ) { + console.log(chatFiles.length, files.length); + toast.error( + $i18n.t(`You can only chat with a maximum of {{maxCount}} file(s) at a time.`, { + maxCount: $config?.file?.max_count + }) + ); } else { // Reset chat input textarea const chatTextAreaElement = document.getElementById('chat-textarea'); @@ -591,11 +604,11 @@ }; const sendPrompt = async ( - prompt, - parentId, + prompt: string, + parentId: string, { modelId = null, modelIdx = null, newChat = false } = {} ) => { - let _responses = []; + let _responses: string[] = []; // If modelId is provided, use it, else use selected model let selectedModelIds = modelId @@ -605,7 +618,7 @@ : selectedModels; // Create response messages for each selected model - const responseMessageIds = {}; + const responseMessageIds: Record = {}; for (const [_modelIdx, modelId] of selectedModelIds.entries()) { const model = $models.filter((m) => m.id === modelId).at(0); @@ -735,13 +748,13 @@ ); currentChatPage.set(1); - await chats.set(await getChatList(localStorage.token, $currentChatPage)); + chats.set(await getChatList(localStorage.token, $currentChatPage)); return _responses; }; const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => { - let _response = null; + let _response: string | null = null; const responseMessage = history.messages[responseMessageId]; const userMessage = history.messages[responseMessage.parentId]; @@ -772,7 +785,7 @@ ...messages ] .filter((message) => message?.content?.trim()) - .map((message, idx, arr) => { + .map((message) => { // Prepare the base message object const baseMessage = { role: message.role, @@ -809,7 +822,18 @@ let files = JSON.parse(JSON.stringify(chatFiles)); if (model?.info?.meta?.knowledge ?? false) { + // Only initialize and add status if knowledge exists + responseMessage.statusHistory = [ + { + action: 'knowledge_search', + description: $i18n.t(`Searching Knowledge for "{{searchQuery}}"`, { + searchQuery: userMessage.content + }), + done: false + } + ]; files.push(...model.info.meta.knowledge); + messages = messages; // Trigger Svelte update } files.push( ...(userMessage?.files ?? []).filter((item) => @@ -818,6 +842,8 @@ ...(responseMessage?.files ?? []).filter((item) => ['web_search_results'].includes(item.type)) ); + scrollToBottom(); + eventTarget.dispatchEvent( new CustomEvent('chat:start', { detail: { @@ -888,6 +914,12 @@ if ('citations' in data) { responseMessage.citations = data.citations; + // Only remove status if it was initially set + if (model?.info?.meta?.knowledge ?? false) { + responseMessage.statusHistory = responseMessage.statusHistory.filter( + (status) => status.action !== 'knowledge_search' + ); + } continue; } @@ -905,18 +937,26 @@ navigator.vibrate(5); } - const sentences = extractSentencesForAudio(responseMessage.content); - sentences.pop(); + const messageContentParts = getMessageContentParts( + responseMessage.content, + $config?.audio?.tts?.split_on ?? 'punctuation' + ); + messageContentParts.pop(); // dispatch only last sentence and make sure it hasn't been dispatched before if ( - sentences.length > 0 && - sentences[sentences.length - 1] !== responseMessage.lastSentence + messageContentParts.length > 0 && + messageContentParts[messageContentParts.length - 1] !== + responseMessage.lastSentence ) { - responseMessage.lastSentence = sentences[sentences.length - 1]; + responseMessage.lastSentence = + messageContentParts[messageContentParts.length - 1]; eventTarget.dispatchEvent( new CustomEvent('chat', { - detail: { id: responseMessageId, content: sentences[sentences.length - 1] } + detail: { + id: responseMessageId, + content: messageContentParts[messageContentParts.length - 1] + } }) ); } @@ -977,7 +1017,20 @@ } } - await saveChatHandler(_chatId); + if ($chatId == _chatId) { + if ($settings.saveChatHistory ?? true) { + chat = await updateChatById(localStorage.token, _chatId, { + messages: messages, + history: history, + models: selectedModels, + params: params, + files: chatFiles + }); + + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + } + } } else { if (res !== null) { const error = await res.json(); @@ -1000,20 +1053,32 @@ }; } responseMessage.done = true; + + if (responseMessage.statusHistory) { + responseMessage.statusHistory = responseMessage.statusHistory.filter( + (status) => status.action !== 'knowledge_search' + ); + } + messages = messages; } stopResponseFlag = false; await tick(); - let lastSentence = extractSentencesForAudio(responseMessage.content)?.at(-1) ?? ''; - if (lastSentence) { + let lastMessageContentPart = + getMessageContentParts( + responseMessage.content, + $config?.audio?.tts?.split_on ?? 'punctuation' + )?.at(-1) ?? ''; + if (lastMessageContentPart) { eventTarget.dispatchEvent( new CustomEvent('chat', { - detail: { id: responseMessageId, content: lastSentence } + detail: { id: responseMessageId, content: lastMessageContentPart } }) ); } + eventTarget.dispatchEvent( new CustomEvent('chat:finish', { detail: { @@ -1044,7 +1109,18 @@ let files = JSON.parse(JSON.stringify(chatFiles)); if (model?.info?.meta?.knowledge ?? false) { + // Only initialize and add status if knowledge exists + responseMessage.statusHistory = [ + { + action: 'knowledge_search', + description: $i18n.t(`Searching Knowledge for "{{searchQuery}}"`, { + searchQuery: userMessage.content + }), + done: false + } + ]; files.push(...model.info.meta.knowledge); + messages = messages; // Trigger Svelte update } files.push( ...(userMessage?.files ?? []).filter((item) => @@ -1184,6 +1260,12 @@ if (citations) { responseMessage.citations = citations; + // Only remove status if it was initially set + if (model?.info?.meta?.knowledge ?? false) { + responseMessage.statusHistory = responseMessage.statusHistory.filter( + (status) => status.action !== 'knowledge_search' + ); + } continue; } @@ -1196,18 +1278,24 @@ navigator.vibrate(5); } - const sentences = extractSentencesForAudio(responseMessage.content); - sentences.pop(); + const messageContentParts = getMessageContentParts( + responseMessage.content, + $config?.audio?.tts?.split_on ?? 'punctuation' + ); + messageContentParts.pop(); // dispatch only last sentence and make sure it hasn't been dispatched before if ( - sentences.length > 0 && - sentences[sentences.length - 1] !== responseMessage.lastSentence + messageContentParts.length > 0 && + messageContentParts[messageContentParts.length - 1] !== responseMessage.lastSentence ) { - responseMessage.lastSentence = sentences[sentences.length - 1]; + responseMessage.lastSentence = messageContentParts[messageContentParts.length - 1]; eventTarget.dispatchEvent( new CustomEvent('chat', { - detail: { id: responseMessageId, content: sentences[sentences.length - 1] } + detail: { + id: responseMessageId, + content: messageContentParts[messageContentParts.length - 1] + } }) ); } @@ -1238,7 +1326,7 @@ } if ($chatId == _chatId) { - if (!$temporaryChatEnabled) { + if ($settings.saveChatHistory ?? true) { chat = await updateChatById(localStorage.token, _chatId, { models: selectedModels, messages: messages, @@ -1262,11 +1350,15 @@ stopResponseFlag = false; await tick(); - let lastSentence = extractSentencesForAudio(responseMessage.content)?.at(-1) ?? ''; - if (lastSentence) { + let lastMessageContentPart = + getMessageContentParts( + responseMessage.content, + $config?.audio?.tts?.split_on ?? 'punctuation' + )?.at(-1) ?? ''; + if (lastMessageContentPart) { eventTarget.dispatchEvent( new CustomEvent('chat', { - detail: { id: responseMessageId, content: lastSentence } + detail: { id: responseMessageId, content: lastMessageContentPart } }) ); } @@ -1330,6 +1422,12 @@ }; responseMessage.done = true; + if (responseMessage.statusHistory) { + responseMessage.statusHistory = responseMessage.statusHistory.filter( + (status) => status.action !== 'knowledge_search' + ); + } + messages = messages; }; @@ -1600,17 +1698,6 @@ }} /> -{#if $showCallOverlay} - -{/if} - {#if !chatIdProp || (loaded && chatIdProp)}
{/if} +{:else if $showCallOverlay} +
+
+ { + show = false; + }} + /> +
+
{:else}
diff --git a/src/lib/components/chat/Controls/Controls.svelte b/src/lib/components/chat/Controls/Controls.svelte index 31b58ab90e..35184f3851 100644 --- a/src/lib/components/chat/Controls/Controls.svelte +++ b/src/lib/components/chat/Controls/Controls.svelte @@ -1,4 +1,4 @@ - diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index bfd5b3001d..76f80f3641 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,6 +1,8 @@ {#if $showCallOverlay} -
-
-
- {#if camera} - - {/if} - -
- {#if !camera} -
+ {:else if loading || assistantSpeaking} + + {:else} +
+ {/if} + + + {/if} + +
+ {#if !camera} + {:else}
-
+ class=" {rmsLevel * 100 > 4 + ? ' size-52' + : rmsLevel * 100 > 2 + ? 'size-48' + : rmsLevel * 100 > 1 + ? 'size-44' + : 'size-40'} transition-all rounded-full {(model?.info?.meta + ?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png' + ? ' bg-cover bg-center bg-no-repeat' + : 'bg-black dark:bg-white'} " + style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !== + '/static/favicon.png' + ? `background-image: url('${model?.info?.meta?.profile_image_url}');` + : ''} + /> {/if} -
+ + {:else} +
+
+ +
+
diff --git a/src/lib/components/chat/MessageInput/Commands.svelte b/src/lib/components/chat/MessageInput/Commands.svelte new file mode 100644 index 0000000000..88877592cd --- /dev/null +++ b/src/lib/components/chat/MessageInput/Commands.svelte @@ -0,0 +1,131 @@ + + +{#if ['/', '#', '@'].includes(command?.charAt(0))} + {#if command?.charAt(0) === '/'} + + {:else if command?.charAt(0) === '#'} + { + console.log(e); + uploadYoutubeTranscription(e.detail); + }} + on:url={(e) => { + console.log(e); + uploadWeb(e.detail); + }} + on:select={(e) => { + console.log(e); + files = [ + ...files, + { + type: e?.detail?.type ?? 'file', + ...e.detail, + status: 'processed' + } + ]; + + dispatch('select'); + }} + /> + {:else if command?.charAt(0) === '@'} + { + prompt = removeLastWordFromString(prompt, command); + + dispatch('select', { + type: 'model', + data: e.detail + }); + }} + /> + {/if} +{/if} diff --git a/src/lib/components/chat/MessageInput/Documents.svelte b/src/lib/components/chat/MessageInput/Commands/Documents.svelte similarity index 89% rename from src/lib/components/chat/MessageInput/Documents.svelte rename to src/lib/components/chat/MessageInput/Commands/Documents.svelte index 50956e4c01..147544534b 100644 --- a/src/lib/components/chat/MessageInput/Documents.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Documents.svelte @@ -2,13 +2,14 @@ import { createEventDispatcher } from 'svelte'; import { documents } from '$lib/stores'; - import { removeFirstHashWord, isValidHttpUrl } from '$lib/utils'; + import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; import { tick, getContext } from 'svelte'; import { toast } from 'svelte-sonner'; const i18n = getContext('i18n'); export let prompt = ''; + export let command = ''; const dispatch = createEventDispatcher(); let selectedIdx = 0; @@ -43,16 +44,16 @@ ]; $: filteredCollections = collections - .filter((collection) => findByName(collection, prompt)) + .filter((collection) => findByName(collection, command)) .sort((a, b) => a.name.localeCompare(b.name)); $: filteredDocs = $documents - .filter((doc) => findByName(doc, prompt)) + .filter((doc) => findByName(doc, command)) .sort((a, b) => a.title.localeCompare(b.title)); $: filteredItems = [...filteredCollections, ...filteredDocs]; - $: if (prompt) { + $: if (command) { selectedIdx = 0; console.log(filteredCollections); @@ -62,9 +63,9 @@ name: string; }; - const findByName = (obj: ObjectWithName, prompt: string) => { + const findByName = (obj: ObjectWithName, command: string) => { const name = obj.name.toLowerCase(); - return name.includes(prompt.toLowerCase().split(' ')?.at(0)?.substring(1) ?? ''); + return name.includes(command.toLowerCase().split(' ')?.at(0)?.substring(1) ?? ''); }; export const selectUp = () => { @@ -78,7 +79,7 @@ const confirmSelect = async (doc) => { dispatch('select', doc); - prompt = removeFirstHashWord(prompt); + prompt = removeLastWordFromString(prompt, command); const chatInputElement = document.getElementById('chat-textarea'); await tick(); @@ -89,7 +90,7 @@ const confirmSelectWeb = async (url) => { dispatch('url', url); - prompt = removeFirstHashWord(prompt); + prompt = removeLastWordFromString(prompt, command); const chatInputElement = document.getElementById('chat-textarea'); await tick(); @@ -100,7 +101,7 @@ const confirmSelectYoutube = async (url) => { dispatch('youtube', url); - prompt = removeFirstHashWord(prompt); + prompt = removeLastWordFromString(prompt, command); const chatInputElement = document.getElementById('chat-textarea'); await tick(); @@ -110,7 +111,10 @@ {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} -
+
#
diff --git a/src/lib/components/chat/MessageInput/Commands/Models.svelte b/src/lib/components/chat/MessageInput/Commands/Models.svelte new file mode 100644 index 0000000000..4b660a62c4 --- /dev/null +++ b/src/lib/components/chat/MessageInput/Commands/Models.svelte @@ -0,0 +1,90 @@ + + +{#if filteredModels.length > 0} +
+
+
+
@
+
+ +
+
+ {#each filteredModels as model, modelIdx} + + {/each} +
+
+
+
+{/if} diff --git a/src/lib/components/chat/MessageInput/PromptCommands.svelte b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte similarity index 80% rename from src/lib/components/chat/MessageInput/PromptCommands.svelte rename to src/lib/components/chat/MessageInput/Commands/Prompts.svelte index 4dd8d33024..9fd48c7493 100644 --- a/src/lib/components/chat/MessageInput/PromptCommands.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte @@ -7,27 +7,30 @@ const i18n = getContext('i18n'); export let files; - export let prompt = ''; - let selectedCommandIdx = 0; - let filteredPromptCommands = []; - $: filteredPromptCommands = $prompts - .filter((p) => p.command.toLowerCase().includes(prompt.toLowerCase())) + export let prompt = ''; + export let command = ''; + + let selectedPromptIdx = 0; + let filteredPrompts = []; + + $: filteredPrompts = $prompts + .filter((p) => p.command.toLowerCase().includes(command.toLowerCase())) .sort((a, b) => a.title.localeCompare(b.title)); - $: if (prompt) { - selectedCommandIdx = 0; + $: if (command) { + selectedPromptIdx = 0; } export const selectUp = () => { - selectedCommandIdx = Math.max(0, selectedCommandIdx - 1); + selectedPromptIdx = Math.max(0, selectedPromptIdx - 1); }; export const selectDown = () => { - selectedCommandIdx = Math.min(selectedCommandIdx + 1, filteredPromptCommands.length - 1); + selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1); }; - const confirmCommand = async (command) => { + const confirmPrompt = async (command) => { let text = command.content; if (command.content.includes('{{CLIPBOARD}}')) { @@ -79,7 +82,6 @@ await tick(); const words = findWordIndices(prompt); - if (words.length > 0) { const word = words.at(0); chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1); @@ -87,8 +89,11 @@ }; -{#if filteredPromptCommands.length > 0} -
+{#if filteredPrompts.length > 0} +
/
@@ -98,26 +103,26 @@ class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100" >
- {#each filteredPromptCommands as command, commandIdx} + {#each filteredPrompts as prompt, promptIdx} {/each} diff --git a/src/lib/components/chat/MessageInput/Models.svelte b/src/lib/components/chat/MessageInput/Models.svelte deleted file mode 100644 index c4655991fc..0000000000 --- a/src/lib/components/chat/MessageInput/Models.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - -{#if prompt.charAt(0) === '@'} - {#if filteredModels.length > 0} -
-
-
-
@
-
- -
-
- {#each filteredModels as model, modelIdx} - - {/each} -
-
-
-
- {/if} -{/if} diff --git a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte index 766454c298..4dfb9f2c5b 100644 --- a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte +++ b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte @@ -1,6 +1,7 @@