diff --git a/Dockerfile b/Dockerfile index 2e898dc89..ec879d732 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Initialize device type args -# use build args in the docker build commmand with --build-arg="BUILDARG=true" +# use build args in the docker build command with --build-arg="BUILDARG=true" ARG USE_CUDA=false ARG USE_OLLAMA=false # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) @@ -11,6 +11,10 @@ ARG USE_CUDA_VER=cu121 # IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 ARG USE_RERANKING_MODEL="" + +# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken +ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base" + ARG BUILD_HASH=dev-build # Override at your own risk - non-root configurations are untested ARG UID=0 @@ -72,6 +76,10 @@ ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \ RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \ SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models" +## Tiktoken model settings ## +ENV TIKTOKEN_ENCODING_NAME="$USE_TIKTOKEN_ENCODING_NAME" \ + TIKTOKEN_CACHE_DIR="/app/backend/data/cache/tiktoken" + ## Hugging Face download cache ## ENV HF_HOME="/app/backend/data/cache/embedding/models" @@ -131,11 +139,13 @@ RUN pip3 install uv && \ uv pip install --system -r requirements.txt --no-cache-dir && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ else \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ fi; \ chown -R $UID:$GID /app/backend/data/ diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 9bf242381..83251a3a9 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -18,7 +18,7 @@ If you're experiencing connection issues, it’s often due to the WebUI docker c docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main ``` -### Error on Slow Reponses for Ollama +### Error on Slow Responses for Ollama Open WebUI has a default timeout of 5 minutes for Ollama to finish generating the response. If needed, this can be adjusted via the environment variable AIOHTTP_CLIENT_TIMEOUT, which sets the timeout in seconds. diff --git a/backend/open_webui/apps/openai/main.py b/backend/open_webui/apps/openai/main.py index 70cefb29c..dbdf9911d 100644 --- a/backend/open_webui/apps/openai/main.py +++ b/backend/open_webui/apps/openai/main.py @@ -18,7 +18,10 @@ from open_webui.config import ( OPENAI_API_KEYS, AppConfig, ) -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT +from open_webui.env import ( + AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, +) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -179,7 +182,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): async def fetch_url(url, key): - timeout = aiohttp.ClientTimeout(total=3) + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) try: headers = {"Authorization": f"Bearer {key}"} async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: diff --git a/backend/open_webui/apps/retrieval/main.py b/backend/open_webui/apps/retrieval/main.py index c80b2011d..87df03238 100644 --- a/backend/open_webui/apps/retrieval/main.py +++ b/backend/open_webui/apps/retrieval/main.py @@ -15,6 +15,9 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, sta from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel + +from open_webui.apps.webui.models.knowledge import Knowledges + from open_webui.apps.retrieval.vector.connector import VECTOR_DB_CLIENT # Document loaders @@ -47,6 +50,8 @@ from open_webui.apps.retrieval.utils import ( from open_webui.apps.webui.models.files import Files from open_webui.config import ( BRAVE_SEARCH_API_KEY, + TIKTOKEN_ENCODING_NAME, + RAG_TEXT_SPLITTER, CHUNK_OVERLAP, CHUNK_SIZE, CONTENT_EXTRACTION_ENGINE, @@ -102,7 +107,7 @@ from open_webui.utils.misc import ( ) from open_webui.utils.utils import get_admin_user, get_verified_user -from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter from langchain_community.document_loaders import ( YoutubeLoader, ) @@ -129,6 +134,9 @@ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL +app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER +app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME + app.state.config.CHUNK_SIZE = CHUNK_SIZE app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP @@ -171,9 +179,9 @@ def update_embedding_model( auto_update: bool = False, ): if embedding_model and app.state.config.RAG_EMBEDDING_ENGINE == "": - import sentence_transformers + from sentence_transformers import SentenceTransformer - app.state.sentence_transformer_ef = sentence_transformers.SentenceTransformer( + app.state.sentence_transformer_ef = SentenceTransformer( get_model_path(embedding_model, auto_update), device=DEVICE_TYPE, trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, @@ -384,18 +392,19 @@ 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, }, "chunk": { + "text_splitter": app.state.config.TEXT_SPLITTER, "chunk_size": app.state.config.CHUNK_SIZE, "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, + "file": { + "max_size": app.state.config.FILE_MAX_SIZE, + "max_count": app.state.config.FILE_MAX_COUNT, + }, "youtube": { "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, @@ -434,6 +443,7 @@ class ContentExtractionConfig(BaseModel): class ChunkParamUpdateForm(BaseModel): + text_splitter: Optional[str] = None chunk_size: int chunk_overlap: int @@ -493,6 +503,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ app.state.config.TIKA_SERVER_URL = form_data.content_extraction.tika_server_url if form_data.chunk is not None: + app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter app.state.config.CHUNK_SIZE = form_data.chunk.chunk_size app.state.config.CHUNK_OVERLAP = form_data.chunk.chunk_overlap @@ -539,6 +550,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ "tika_server_url": app.state.config.TIKA_SERVER_URL, }, "chunk": { + "text_splitter": app.state.config.TEXT_SPLITTER, "chunk_size": app.state.config.CHUNK_SIZE, "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, @@ -599,11 +611,10 @@ class QuerySettingsForm(BaseModel): async def update_query_settings( form_data: QuerySettingsForm, user=Depends(get_admin_user) ): - app.state.config.RAG_TEMPLATE = ( - form_data.template if form_data.template != "" else DEFAULT_RAG_TEMPLATE - ) + app.state.config.RAG_TEMPLATE = form_data.template app.state.config.TOP_K = form_data.k if form_data.k else 4 app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 + app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( form_data.hybrid if form_data.hybrid else False ) @@ -648,18 +659,41 @@ def save_docs_to_vector_db( raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) if split: - text_splitter = RecursiveCharacterTextSplitter( - chunk_size=app.state.config.CHUNK_SIZE, - chunk_overlap=app.state.config.CHUNK_OVERLAP, - add_start_index=True, - ) + if app.state.config.TEXT_SPLITTER in ["", "character"]: + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=app.state.config.CHUNK_SIZE, + chunk_overlap=app.state.config.CHUNK_OVERLAP, + add_start_index=True, + ) + elif app.state.config.TEXT_SPLITTER == "token": + text_splitter = TokenTextSplitter( + encoding_name=app.state.config.TIKTOKEN_ENCODING_NAME, + chunk_size=app.state.config.CHUNK_SIZE, + chunk_overlap=app.state.config.CHUNK_OVERLAP, + add_start_index=True, + ) + else: + raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter")) + docs = text_splitter.split_documents(docs) if len(docs) == 0: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) texts = [doc.page_content for doc in docs] - metadatas = [{**doc.metadata, **(metadata if metadata else {})} for doc in docs] + metadatas = [ + { + **doc.metadata, + **(metadata if metadata else {}), + "embedding_config": json.dumps( + { + "engine": app.state.config.RAG_EMBEDDING_ENGINE, + "model": app.state.config.RAG_EMBEDDING_MODEL, + } + ), + } + for doc in docs + ] # ChromaDB does not like datetime formats # for meta-data so convert them to string. @@ -1255,6 +1289,7 @@ def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin @app.post("/reset/db") def reset_vector_db(user=Depends(get_admin_user)): VECTOR_DB_CLIENT.reset() + Knowledges.delete_all_knowledge() @app.post("/reset/uploads") @@ -1277,28 +1312,6 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: print(f"The directory {folder} does not exist") except Exception as e: print(f"Failed to process the directory {folder}. Reason: {e}") - - return True - - -@app.post("/reset") -def reset(user=Depends(get_admin_user)) -> bool: - folder = f"{UPLOAD_DIR}" - for filename in os.listdir(folder): - file_path = os.path.join(folder, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - log.error("Failed to delete %s. Reason: %s" % (file_path, e)) - - try: - VECTOR_DB_CLIENT.reset() - except Exception as e: - log.exception(e) - return True diff --git a/backend/open_webui/apps/retrieval/utils.py b/backend/open_webui/apps/retrieval/utils.py index 4ca2db1bd..aa09ec582 100644 --- a/backend/open_webui/apps/retrieval/utils.py +++ b/backend/open_webui/apps/retrieval/utils.py @@ -19,6 +19,7 @@ from open_webui.apps.retrieval.vector.connector import VECTOR_DB_CLIENT from open_webui.utils.misc import get_last_user_message from open_webui.env import SRC_LOG_LEVELS +from open_webui.config import DEFAULT_RAG_TEMPLATE log = logging.getLogger(__name__) @@ -239,8 +240,13 @@ def query_collection_with_hybrid_search( def rag_template(template: str, context: str, query: str): - count = template.count("[context]") - assert "[context]" in template, "RAG template does not contain '[context]'" + if template == "": + template = DEFAULT_RAG_TEMPLATE + + if "[context]" not in template and "{{CONTEXT}}" not in template: + log.debug( + "WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder." + ) if "" in context and "" in context: log.debug( @@ -249,14 +255,25 @@ def rag_template(template: str, context: str, query: str): "nothing, or the user might be trying to hack something." ) + query_placeholders = [] if "[query]" in context: - query_placeholder = f"[query-{str(uuid.uuid4())}]" + query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" template = template.replace("[query]", query_placeholder) - template = template.replace("[context]", context) + query_placeholders.append(query_placeholder) + + if "{{QUERY}}" in context: + query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" + template = template.replace("{{QUERY}}", query_placeholder) + query_placeholders.append(query_placeholder) + + template = template.replace("[context]", context) + template = template.replace("{{CONTEXT}}", context) + template = template.replace("[query]", query) + template = template.replace("{{QUERY}}", query) + + for query_placeholder in query_placeholders: template = template.replace(query_placeholder, query) - else: - template = template.replace("[context]", context) - template = template.replace("[query]", query) + return template @@ -375,8 +392,21 @@ def get_rag_context( for context in relevant_contexts: try: if "documents" in context: + file_names = list( + set( + [ + metadata["name"] + for metadata in context["metadatas"][0] + if metadata is not None and "name" in metadata + ] + ) + ) + contexts.append( - "\n\n".join( + (", ".join(file_names) + ":\n\n") + if file_names + else "" + + "\n\n".join( [text for text in context["documents"][0] if text is not None] ) ) @@ -393,6 +423,7 @@ def get_rag_context( except Exception as e: log.exception(e) + print(contexts, citations) return contexts, citations diff --git a/backend/open_webui/apps/webui/models/chats.py b/backend/open_webui/apps/webui/models/chats.py index 04355e997..509dff9fe 100644 --- a/backend/open_webui/apps/webui/models/chats.py +++ b/backend/open_webui/apps/webui/models/chats.py @@ -61,6 +61,9 @@ class ChatModel(BaseModel): class ChatForm(BaseModel): chat: dict +class ChatTitleMessagesForm(BaseModel): + title: str + messages: list[dict] class ChatTitleForm(BaseModel): title: str diff --git a/backend/open_webui/apps/webui/models/knowledge.py b/backend/open_webui/apps/webui/models/knowledge.py index 2423d1f84..269ad8cc3 100644 --- a/backend/open_webui/apps/webui/models/knowledge.py +++ b/backend/open_webui/apps/webui/models/knowledge.py @@ -154,5 +154,15 @@ class KnowledgeTable: except Exception: return False + def delete_all_knowledge(self) -> bool: + with get_db() as db: + try: + db.query(Knowledge).delete() + db.commit() + + return True + except Exception: + return False + Knowledges = KnowledgeTable() diff --git a/backend/open_webui/apps/webui/models/tags.py b/backend/open_webui/apps/webui/models/tags.py index ef209b565..7424a2660 100644 --- a/backend/open_webui/apps/webui/models/tags.py +++ b/backend/open_webui/apps/webui/models/tags.py @@ -8,7 +8,7 @@ from open_webui.apps.webui.internal.db import Base, get_db from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, JSON +from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -19,11 +19,14 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### class Tag(Base): __tablename__ = "tag" - id = Column(String, primary_key=True) + id = Column(String) name = Column(String) user_id = Column(String) meta = Column(JSON, nullable=True) + # Unique constraint ensuring (id, user_id) is unique, not just the `id` column + __table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),) + class TagModel(BaseModel): id: str @@ -57,7 +60,8 @@ class TagTable: return TagModel.model_validate(result) else: return None - except Exception: + except Exception as e: + print(e) return None def get_tag_by_name_and_user_id( @@ -78,11 +82,15 @@ class TagTable: for tag in (db.query(Tag).filter_by(user_id=user_id).all()) ] - def get_tags_by_ids(self, ids: list[str]) -> list[TagModel]: + def get_tags_by_ids_and_user_id( + self, ids: list[str], user_id: str + ) -> list[TagModel]: with get_db() as db: return [ TagModel.model_validate(tag) - for tag in (db.query(Tag).filter(Tag.id.in_(ids)).all()) + for tag in ( + db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).all() + ) ] def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool: diff --git a/backend/open_webui/apps/webui/routers/chats.py b/backend/open_webui/apps/webui/routers/chats.py index 6a9c26f8c..b919d1447 100644 --- a/backend/open_webui/apps/webui/routers/chats.py +++ b/backend/open_webui/apps/webui/routers/chats.py @@ -465,7 +465,7 @@ async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user)): chat = Chats.get_chat_by_id_and_user_id(id, user.id) if chat: tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids(tags) + return Tags.get_tags_by_ids_and_user_id(tags, user.id) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND @@ -494,7 +494,7 @@ async def add_tag_by_id_and_tag_name( chat = Chats.get_chat_by_id_and_user_id(id, user.id) tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids(tags) + return Tags.get_tags_by_ids_and_user_id(tags, user.id) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() @@ -519,7 +519,7 @@ async def delete_tag_by_id_and_tag_name( chat = Chats.get_chat_by_id_and_user_id(id, user.id) tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids(tags) + return Tags.get_tags_by_ids_and_user_id(tags, user.id) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND @@ -543,7 +543,7 @@ async def delete_all_chat_tags_by_id(id: str, user=Depends(get_verified_user)): chat = Chats.get_chat_by_id_and_user_id(id, user.id) tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids(tags) + return Tags.get_tags_by_ids_and_user_id(tags, user.id) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND diff --git a/backend/open_webui/apps/webui/routers/utils.py b/backend/open_webui/apps/webui/routers/utils.py index 82c294bd7..0ab0f6b15 100644 --- a/backend/open_webui/apps/webui/routers/utils.py +++ b/backend/open_webui/apps/webui/routers/utils.py @@ -1,16 +1,14 @@ -import site -from pathlib import Path - import black import markdown + +from open_webui.apps.webui.models.chats import ChatTitleMessagesForm from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT -from open_webui.env import FONTS_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Response, status -from fpdf import FPDF from pydantic import BaseModel from starlette.responses import FileResponse from open_webui.utils.misc import get_gravatar_url +from open_webui.utils.pdf_generator import PDFGenerator from open_webui.utils.utils import get_admin_user router = APIRouter() @@ -56,58 +54,19 @@ class ChatForm(BaseModel): @router.post("/pdf") async def download_chat_as_pdf( - form_data: ChatForm, + form_data: ChatTitleMessagesForm, ): - global FONTS_DIR + try: + pdf_bytes = PDFGenerator(form_data).generate_chat_pdf() - pdf = FPDF() - pdf.add_page() - - # When running using `pip install` the static directory is in the site packages. - if not FONTS_DIR.exists(): - FONTS_DIR = Path(site.getsitepackages()[0]) / "static/fonts" - # When running using `pip install -e .` the static directory is in the site packages. - # This path only works if `open-webui serve` is run from the root of this project. - if not FONTS_DIR.exists(): - FONTS_DIR = Path("./backend/static/fonts") - - pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf") - pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf") - pdf.add_font("NotoSans", "i", f"{FONTS_DIR}/NotoSans-Italic.ttf") - pdf.add_font("NotoSansKR", "", f"{FONTS_DIR}/NotoSansKR-Regular.ttf") - pdf.add_font("NotoSansJP", "", f"{FONTS_DIR}/NotoSansJP-Regular.ttf") - pdf.add_font("NotoSansSC", "", f"{FONTS_DIR}/NotoSansSC-Regular.ttf") - - pdf.set_font("NotoSans", size=12) - pdf.set_fallback_fonts(["NotoSansKR", "NotoSansJP", "NotoSansSC"]) - - pdf.set_auto_page_break(auto=True, margin=15) - - # Adjust the effective page width for multi_cell - effective_page_width = ( - pdf.w - 2 * pdf.l_margin - 10 - ) # Subtracted an additional 10 for extra padding - - # Add chat messages - for message in form_data.messages: - role = message["role"] - content = message["content"] - pdf.set_font("NotoSans", "B", size=14) # Bold for the role - pdf.multi_cell(effective_page_width, 10, f"{role.upper()}", 0, "L") - pdf.ln(1) # Extra space between messages - - pdf.set_font("NotoSans", size=10) # Regular for content - pdf.multi_cell(effective_page_width, 6, content, 0, "L") - pdf.ln(1.5) # Extra space between messages - - # Save the pdf with name .pdf - pdf_bytes = pdf.output() - - return Response( - content=bytes(pdf_bytes), - media_type="application/pdf", - headers={"Content-Disposition": "attachment;filename=chat.pdf"}, - ) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment;filename=chat.pdf"}, + ) + except Exception as e: + print(e) + raise HTTPException(status_code=400, detail=str(e)) @router.get("/db/download") diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 98d342897..d55619ee0 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1014,6 +1014,22 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "").lower() == "true" ) + +RAG_TEXT_SPLITTER = PersistentConfig( + "RAG_TEXT_SPLITTER", + "rag.text_splitter", + os.environ.get("RAG_TEXT_SPLITTER", ""), +) + + +TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken") +TIKTOKEN_ENCODING_NAME = PersistentConfig( + "TIKTOKEN_ENCODING_NAME", + "rag.tiktoken_encoding_name", + os.environ.get("TIKTOKEN_ENCODING_NAME", "cl100k_base"), +) + + CHUNK_SIZE = PersistentConfig( "CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000")) ) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 37461402b..704cdd074 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -20,7 +20,7 @@ class ERROR_MESSAGES(str, Enum): def __str__(self) -> str: return super().__str__() - DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}" + DEFAULT = lambda err="": f"Something went wrong :/\n[ERROR: {err if err else ''}]" ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance." DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot." diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 0f2ecada0..4b61e1a89 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -230,6 +230,8 @@ if FROM_INIT_PY: DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) +STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")) + FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() @@ -361,6 +363,20 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT = 300 +AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get( + "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "3" +) + +if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "": + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None +else: + try: + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int( + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST + ) + except Exception: + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 3 + #################################### # OFFLINE_MODE #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 5b819d78b..169a9ea4f 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -578,7 +578,7 @@ class ChatCompletionMiddleware(BaseHTTPMiddleware): } # Initialize data_items to store additional data to be sent to the client - # Initalize contexts and citation + # Initialize contexts and citation data_items = [] contexts = [] citations = [] @@ -990,11 +990,13 @@ async def get_all_models(): owned_by = model["owned_by"] if "pipe" in model: pipe = model["pipe"] - - if "info" in model and "meta" in model["info"]: - action_ids.extend(model["info"]["meta"].get("actionIds", [])) break + if custom_model.meta: + meta = custom_model.meta.model_dump() + if "actionIds" in meta: + action_ids.extend(meta["actionIds"]) + models.append( { "id": custom_model.id, @@ -2277,7 +2279,7 @@ async def oauth_login(provider: str, request: Request): # 2. If OAUTH_MERGE_ACCOUNTS_BY_EMAIL is true, find a user with the email address provided via OAuth # - This is considered insecure in general, as OAuth providers do not always verify email addresses # 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user -# - Email addresses are considered unique, so we fail registration if the email address is alreayd taken +# - Email addresses are considered unique, so we fail registration if the email address is already taken @app.get("/oauth/{provider}/callback") async def oauth_callback(provider: str, request: Request, response: Response): if provider not in OAUTH_PROVIDERS: @@ -2385,7 +2387,7 @@ async def oauth_callback(provider: str, request: Request, response: Response): key="token", value=jwt_token, httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_SESSION_COOKIE_SAME_SITE, + samesite=WEBUI_SESSION_COOKIE_SAME_SITE, secure=WEBUI_SESSION_COOKIE_SECURE, ) diff --git a/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py new file mode 100644 index 000000000..7c7126e2f --- /dev/null +++ b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py @@ -0,0 +1,67 @@ +"""Update tags + +Revision ID: 3ab32c4b8f59 +Revises: 1af9b942657b +Create Date: 2024-10-09 21:02:35.241684 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, select, update, column +from sqlalchemy.engine.reflection import Inspector + +import json + +revision = "3ab32c4b8f59" +down_revision = "1af9b942657b" +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + + # Inspecting the 'tag' table constraints and structure + existing_pk = inspector.get_pk_constraint("tag") + unique_constraints = inspector.get_unique_constraints("tag") + existing_indexes = inspector.get_indexes("tag") + + print(existing_pk, unique_constraints) + + with op.batch_alter_table("tag", schema=None) as batch_op: + # Drop unique constraints that could conflict with new primary key + for constraint in unique_constraints: + if constraint["name"] == "uq_id_user_id": + batch_op.drop_constraint(constraint["name"], type_="unique") + + for index in existing_indexes: + if index["unique"]: + # Drop the unique index + batch_op.drop_index(index["name"]) + + # Drop existing primary key constraint if it exists + if existing_pk and existing_pk.get("constrained_columns"): + batch_op.drop_constraint(existing_pk["name"], type_="primary") + + # Immediately after dropping the old primary key, create the new one + batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"]) + + +def downgrade(): + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + + current_pk = inspector.get_pk_constraint("tag") + + with op.batch_alter_table("tag", schema=None) as batch_op: + # Drop the current primary key first, if it matches the one we know we added in upgrade + if current_pk and "pk_id_user_id" == current_pk.get("name"): + batch_op.drop_constraint("pk_id_user_id", type_="primary") + + # Restore the original primary key + batch_op.create_primary_key("pk_id", ["id"]) + + # Since primary key on just 'id' is restored, we now add back any unique constraints if necessary + batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"]) diff --git a/backend/open_webui/static/assets/pdf-style.css b/backend/open_webui/static/assets/pdf-style.css new file mode 100644 index 000000000..db9ac83dd --- /dev/null +++ b/backend/open_webui/static/assets/pdf-style.css @@ -0,0 +1,319 @@ +/* HTML and Body */ +@font-face { + font-family: 'NotoSans'; + src: url('fonts/NotoSans-Variable.ttf'); +} + +@font-face { + font-family: 'NotoSansJP'; + src: url('fonts/NotoSansJP-Variable.ttf'); +} + +@font-face { + font-family: 'NotoSansKR'; + src: url('fonts/NotoSansKR-Variable.ttf'); +} + +@font-face { + font-family: 'NotoSansSC'; + src: url('fonts/NotoSansSC-Variable.ttf'); +} + +@font-face { + font-family: 'NotoSansSC-Regular'; + src: url('fonts/NotoSansSC-Regular.ttf'); +} + +html { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR', + 'NotoSansSC', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto, + 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; /* Default font size */ + line-height: 1.5; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +body { + margin: 0; + color: #212529; + background-color: #fff; + width: auto; +} + +/* Typography */ +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 500; + margin: 0; +} + +h1 { + font-size: 2.5rem; +} + +h2 { + font-size: 2rem; +} + +h3 { + font-size: 1.75rem; +} + +h4 { + font-size: 1.5rem; +} + +h5 { + font-size: 1.25rem; +} + +h6 { + font-size: 1rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +/* Grid System */ +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +/* Utilities */ +.text-center { + text-align: center; +} + +/* Additional Text Utilities */ +.text-muted { + color: #6c757d; /* Muted text color */ +} + +/* Small Text */ +small { + font-size: 80%; /* Smaller font size relative to the base */ + color: #6c757d; /* Lighter text color for secondary information */ + margin-bottom: 0; + margin-top: 0; +} + +/* Strong Element Styles */ +strong { + font-weight: bolder; /* Ensures the text is bold */ + color: inherit; /* Inherits the color from its parent element */ +} + +/* link */ +a { + color: #007bff; + text-decoration: none; + background-color: transparent; +} + +a:hover { + color: #0056b3; + text-decoration: underline; +} + +/* General styles for lists */ +ol, +ul, +li { + padding-left: 40px; /* Increase padding to move bullet points to the right */ + margin-left: 20px; /* Indent lists from the left */ +} + +/* Ordered list styles */ +ol { + list-style-type: decimal; /* Use numbers for ordered lists */ + margin-bottom: 10px; /* Space after each list */ +} + +ol li { + margin-bottom: 0.5rem; /* Space between ordered list items */ +} + +/* Unordered list styles */ +ul { + list-style-type: disc; /* Use bullets for unordered lists */ + margin-bottom: 10px; /* Space after each list */ +} + +ul li { + margin-bottom: 0.5rem; /* Space between unordered list items */ +} + +/* List item styles */ +li { + margin-bottom: 5px; /* Space between list items */ + line-height: 1.5; /* Line height for better readability */ +} + +/* Nested lists */ +ol ol, +ol ul, +ul ol, +ul ul { + padding-left: 20px; + margin-left: 30px; /* Further indent nested lists */ + margin-bottom: 0; /* Remove extra margin at the bottom of nested lists */ +} + +/* Code blocks */ +pre { + background-color: #f4f4f4; + padding: 10px; + overflow-x: auto; + max-width: 100%; /* Ensure it doesn't overflow the page */ + width: 80%; /* Set a specific width for a container-like appearance */ + margin: 0 1em; /* Center the pre block */ + box-sizing: border-box; /* Include padding in the width */ + border: 1px solid #ccc; /* Optional: Add a border for better definition */ + border-radius: 4px; /* Optional: Add rounded corners */ +} + +code { + font-family: 'Courier New', Courier, monospace; + background-color: #f4f4f4; + padding: 2px 4px; + border-radius: 4px; + box-sizing: border-box; /* Include padding in the width */ +} + +.message { + margin-top: 8px; + margin-bottom: 8px; + max-width: 100%; + overflow-wrap: break-word; +} + +/* Table Styles */ +table { + width: 100%; + margin-bottom: 1rem; + color: #212529; + border-collapse: collapse; /* Removes the space between borders */ +} + +th, +td { + margin: 0; + padding: 0.75rem; + vertical-align: top; + border-top: 1px solid #dee2e6; +} + +thead th { + vertical-align: bottom; + border-bottom: 2px solid #dee2e6; +} + +tbody + tbody { + border-top: 2px solid #dee2e6; +} + +/* markdown-section styles */ +.markdown-section blockquote, +.markdown-section h1, +.markdown-section h2, +.markdown-section h3, +.markdown-section h4, +.markdown-section h5, +.markdown-section h6, +.markdown-section p, +.markdown-section pre, +.markdown-section table, +.markdown-section ul { + /* Give most block elements margin top and bottom */ + margin-top: 1rem; +} + +/* Remove top margin if it's the first child */ +.markdown-section blockquote:first-child, +.markdown-section h1:first-child, +.markdown-section h2:first-child, +.markdown-section h3:first-child, +.markdown-section h4:first-child, +.markdown-section h5:first-child, +.markdown-section h6:first-child, +.markdown-section p:first-child, +.markdown-section pre:first-child, +.markdown-section table:first-child, +.markdown-section ul:first-child { + margin-top: 0; +} + +/* Remove top margin of