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