mirror of
https://github.com/open-webui/open-webui
synced 2025-06-26 18:26:48 +00:00
Merge branch 'upstream-dev' into dev
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 "<context>" in context and "</context>" 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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
####################################
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
319
backend/open_webui/static/assets/pdf-style.css
Normal file
319
backend/open_webui/static/assets/pdf-style.css
Normal file
@@ -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 <ul> following a <p> */
|
||||
.markdown-section p + ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Remove bottom margin of <p> if it is followed by a <ul> */
|
||||
/* Note: :has is not supported in CSS, so you would need JavaScript for this behavior */
|
||||
.markdown-section p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Add a rule to reset margin-bottom for <p> not followed by <ul> */
|
||||
.markdown-section p + ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* List item styles */
|
||||
.markdown-section li {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.markdown-section li p {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Avoid margins for nested lists */
|
||||
.markdown-section li > ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.markdown-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-section th,
|
||||
.markdown-section td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-section th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
.markdown-section pre {
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.markdown-section pre code {
|
||||
position: relative;
|
||||
color: rgb(172, 0, 95);
|
||||
}
|
||||
BIN
backend/open_webui/static/fonts/NotoSans-Variable.ttf
Normal file
BIN
backend/open_webui/static/fonts/NotoSans-Variable.ttf
Normal file
Binary file not shown.
BIN
backend/open_webui/static/fonts/NotoSansJP-Variable.ttf
Normal file
BIN
backend/open_webui/static/fonts/NotoSansJP-Variable.ttf
Normal file
Binary file not shown.
BIN
backend/open_webui/static/fonts/NotoSansKR-Variable.ttf
Normal file
BIN
backend/open_webui/static/fonts/NotoSansKR-Variable.ttf
Normal file
Binary file not shown.
BIN
backend/open_webui/static/fonts/NotoSansSC-Variable.ttf
Normal file
BIN
backend/open_webui/static/fonts/NotoSansSC-Variable.ttf
Normal file
Binary file not shown.
139
backend/open_webui/utils/pdf_generator.py
Normal file
139
backend/open_webui/utils/pdf_generator.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from markdown import markdown
|
||||
|
||||
import site
|
||||
from fpdf import FPDF
|
||||
|
||||
from open_webui.env import STATIC_DIR, FONTS_DIR
|
||||
from open_webui.apps.webui.models.chats import ChatTitleMessagesForm
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""
|
||||
Description:
|
||||
The `PDFGenerator` class is designed to create PDF documents from chat messages.
|
||||
The process involves transforming markdown content into HTML and then into a PDF format
|
||||
|
||||
Attributes:
|
||||
- `form_data`: An instance of `ChatTitleMessagesForm` containing title and messages.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, form_data: ChatTitleMessagesForm):
|
||||
self.html_body = None
|
||||
self.messages_html = None
|
||||
self.form_data = form_data
|
||||
|
||||
self.css = Path(STATIC_DIR / "assets" / "pdf-style.css").read_text()
|
||||
|
||||
def format_timestamp(self, timestamp: float) -> str:
|
||||
"""Convert a UNIX timestamp to a formatted date string."""
|
||||
try:
|
||||
date_time = datetime.fromtimestamp(timestamp)
|
||||
return date_time.strftime("%Y-%m-%d, %H:%M:%S")
|
||||
except (ValueError, TypeError) as e:
|
||||
# Log the error if necessary
|
||||
return ""
|
||||
|
||||
def _build_html_message(self, message: Dict[str, Any]) -> str:
|
||||
"""Build HTML for a single message."""
|
||||
role = message.get("role", "user")
|
||||
content = message.get("content", "")
|
||||
timestamp = message.get("timestamp")
|
||||
|
||||
model = message.get("model") if role == "assistant" else ""
|
||||
|
||||
date_str = self.format_timestamp(timestamp) if timestamp else ""
|
||||
|
||||
# extends pymdownx extension to convert markdown to html.
|
||||
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/
|
||||
html_content = markdown(content, extensions=["pymdownx.extra"])
|
||||
|
||||
html_message = f"""
|
||||
<div class="message">
|
||||
<small> {date_str} </small>
|
||||
<div>
|
||||
<h2>
|
||||
<strong>{role.title()}</strong>
|
||||
<small class="text-muted">{model}</small>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="markdown-section">
|
||||
{html_content}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return html_message
|
||||
|
||||
def _generate_html_body(self) -> str:
|
||||
"""Generate the full HTML body for the PDF."""
|
||||
return f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="text-center">
|
||||
<h1>{self.form_data.title}</h1>
|
||||
</div>
|
||||
<div>
|
||||
{self.messages_html}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def generate_chat_pdf(self) -> bytes:
|
||||
"""
|
||||
Generate a PDF from chat messages.
|
||||
"""
|
||||
try:
|
||||
global FONTS_DIR
|
||||
|
||||
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)
|
||||
|
||||
# Build HTML messages
|
||||
messages_html_list: List[str] = [
|
||||
self._build_html_message(msg) for msg in self.form_data.messages
|
||||
]
|
||||
self.messages_html = "<div>" + "".join(messages_html_list) + "</div>"
|
||||
|
||||
# Generate full HTML body
|
||||
self.html_body = self._generate_html_body()
|
||||
|
||||
pdf.write_html(self.html_body)
|
||||
|
||||
# Save the pdf with name .pdf
|
||||
pdf_bytes = pdf.output()
|
||||
|
||||
return bytes(pdf_bytes)
|
||||
except Exception as e:
|
||||
raise e
|
||||
@@ -44,13 +44,15 @@ chromadb==0.5.9
|
||||
pymilvus==2.4.7
|
||||
qdrant-client~=1.12.0
|
||||
|
||||
sentence-transformers==3.0.1
|
||||
sentence-transformers==3.2.0
|
||||
colbert-ai==0.2.21
|
||||
einops==0.8.0
|
||||
|
||||
|
||||
ftfy==6.2.3
|
||||
pypdf==4.3.1
|
||||
xhtml2pdf==0.2.16
|
||||
pymdown-extensions==10.11.2
|
||||
docx2txt==0.8
|
||||
python-pptx==1.0.0
|
||||
unstructured==0.15.9
|
||||
|
||||
Reference in New Issue
Block a user