diff --git a/CHANGELOG.md b/CHANGELOG.md index a62e188712..04a79ca432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.7] - 2024-12-01 + +### Added + +- **✨ Prompt Input Auto-Completion**: Type a prompt and let AI intelligently suggest and complete your inputs. Simply press 'Tab' or swipe right on mobile to confirm. Available only with Rich Text Input (default setting). Disable via Admin Settings for full control. +- **🌍 Improved Translations**: Enhanced localization for multiple languages, ensuring a more polished and accessible experience for international users. + +### Fixed + +- **🛠️ Tools Export Issue**: Resolved a critical issue where exporting tools wasn’t functioning, restoring seamless export capabilities. +- **🔗 Model ID Registration**: Fixed an issue where model IDs weren’t registering correctly in the model editor, ensuring reliable model setup and tracking. +- **🖋️ Textarea Auto-Expansion**: Corrected a bug where textareas didn’t expand automatically on certain browsers, improving usability for multi-line inputs. +- **🔧 Ollama Embed Endpoint**: Addressed the /ollama/embed endpoint malfunction, ensuring consistent performance and functionality. + +### Changed + +- **🎨 Knowledge Base Styling**: Refined knowledge base visuals for a cleaner, more modern look, laying the groundwork for further enhancements in upcoming releases. + ## [0.4.6] - 2024-11-26 ### Added diff --git a/backend/open_webui/apps/ollama/main.py b/backend/open_webui/apps/ollama/main.py index 0ac1f0401d..71a40cb479 100644 --- a/backend/open_webui/apps/ollama/main.py +++ b/backend/open_webui/apps/ollama/main.py @@ -706,7 +706,7 @@ async def generate_embeddings( url_idx: Optional[int] = None, user=Depends(get_verified_user), ): - return generate_ollama_batch_embeddings(form_data, url_idx) + return await generate_ollama_batch_embeddings(form_data, url_idx) @app.post("/api/embeddings") diff --git a/backend/open_webui/apps/retrieval/loaders/youtube.py b/backend/open_webui/apps/retrieval/loaders/youtube.py index ad1088be0b..8eb48488b2 100644 --- a/backend/open_webui/apps/retrieval/loaders/youtube.py +++ b/backend/open_webui/apps/retrieval/loaders/youtube.py @@ -1,7 +1,12 @@ +import logging + from typing import Any, Dict, Generator, List, Optional, Sequence, Union from urllib.parse import parse_qs, urlparse from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) ALLOWED_SCHEMES = {"http", "https"} ALLOWED_NETLOCS = { @@ -51,12 +56,14 @@ class YoutubeLoader: self, video_id: str, language: Union[str, Sequence[str]] = "en", + proxy_url: Optional[str] = None, ): """Initialize with YouTube video ID.""" _video_id = _parse_video_id(video_id) self.video_id = _video_id if _video_id is not None else video_id self._metadata = {"source": video_id} self.language = language + self.proxy_url = proxy_url if isinstance(language, str): self.language = [language] else: @@ -76,10 +83,22 @@ class YoutubeLoader: "Please install it with `pip install youtube-transcript-api`." ) + if self.proxy_url: + youtube_proxies = { + "http": self.proxy_url, + "https": self.proxy_url, + } + # Don't log complete URL because it might contain secrets + log.debug(f"Using proxy URL: {self.proxy_url[:14]}...") + else: + youtube_proxies = None + try: - transcript_list = YouTubeTranscriptApi.list_transcripts(self.video_id) + transcript_list = YouTubeTranscriptApi.list_transcripts( + self.video_id, proxies=youtube_proxies + ) except Exception as e: - print(e) + log.exception("Loading YouTube transcript failed") return [] try: diff --git a/backend/open_webui/apps/retrieval/main.py b/backend/open_webui/apps/retrieval/main.py index 63bc18190e..7ad4af064c 100644 --- a/backend/open_webui/apps/retrieval/main.py +++ b/backend/open_webui/apps/retrieval/main.py @@ -105,6 +105,7 @@ from open_webui.config import ( TIKA_SERVER_URL, UPLOAD_DIR, YOUTUBE_LOADER_LANGUAGE, + YOUTUBE_LOADER_PROXY_URL, DEFAULT_LOCALE, AppConfig, ) @@ -171,6 +172,7 @@ app.state.config.OLLAMA_API_KEY = RAG_OLLAMA_API_KEY app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE +app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL app.state.YOUTUBE_LOADER_TRANSLATION = None @@ -471,6 +473,7 @@ async def get_rag_config(user=Depends(get_admin_user)): "youtube": { "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, + "proxy_url": app.state.config.YOUTUBE_LOADER_PROXY_URL, }, "web": { "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, @@ -518,6 +521,7 @@ class ChunkParamUpdateForm(BaseModel): class YoutubeLoaderConfig(BaseModel): language: list[str] translation: Optional[str] = None + proxy_url: str = "" class WebSearchConfig(BaseModel): @@ -580,6 +584,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ if form_data.youtube is not None: app.state.config.YOUTUBE_LOADER_LANGUAGE = form_data.youtube.language + app.state.config.YOUTUBE_LOADER_PROXY_URL = form_data.youtube.proxy_url app.state.YOUTUBE_LOADER_TRANSLATION = form_data.youtube.translation if form_data.web is not None: @@ -640,6 +645,7 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ }, "youtube": { "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, + "proxy_url": app.state.config.YOUTUBE_LOADER_PROXY_URL, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, }, "web": { @@ -867,7 +873,7 @@ def save_docs_to_vector_db( return True except Exception as e: log.exception(e) - return False + raise e class ProcessFileForm(BaseModel): @@ -897,7 +903,7 @@ def process_file( docs = [ Document( - page_content=form_data.content, + page_content=form_data.content.replace("
", "\n"), metadata={ **file.meta, "name": file.filename, @@ -1081,7 +1087,9 @@ def process_youtube_video(form_data: ProcessUrlForm, user=Depends(get_verified_u collection_name = calculate_sha256_string(form_data.url)[:63] loader = YoutubeLoader( - form_data.url, language=app.state.config.YOUTUBE_LOADER_LANGUAGE + form_data.url, + language=app.state.config.YOUTUBE_LOADER_LANGUAGE, + proxy_url=app.state.config.YOUTUBE_LOADER_PROXY_URL, ) docs = loader.load() diff --git a/backend/open_webui/apps/webui/models/tools.py b/backend/open_webui/apps/webui/models/tools.py index b628f4f9f3..8f798c3175 100644 --- a/backend/open_webui/apps/webui/models/tools.py +++ b/backend/open_webui/apps/webui/models/tools.py @@ -76,6 +76,10 @@ class ToolModel(BaseModel): #################### +class ToolUserModel(ToolModel): + user: Optional[UserResponse] = None + + class ToolResponse(BaseModel): id: str user_id: str @@ -138,13 +142,13 @@ class ToolsTable: except Exception: return None - def get_tools(self) -> list[ToolUserResponse]: + def get_tools(self) -> list[ToolUserModel]: with get_db() as db: tools = [] for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all(): user = Users.get_user_by_id(tool.user_id) tools.append( - ToolUserResponse.model_validate( + ToolUserModel.model_validate( { **ToolModel.model_validate(tool).model_dump(), "user": user.model_dump() if user else None, @@ -155,7 +159,7 @@ class ToolsTable: def get_tools_by_user_id( self, user_id: str, permission: str = "write" - ) -> list[ToolUserResponse]: + ) -> list[ToolUserModel]: tools = self.get_tools() return [ diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 0a76626c15..15d209941e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -583,6 +583,12 @@ OLLAMA_API_BASE_URL = os.environ.get( ) OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "") +if OLLAMA_BASE_URL: + # Remove trailing slash + OLLAMA_BASE_URL = ( + OLLAMA_BASE_URL[:-1] if OLLAMA_BASE_URL.endswith("/") else OLLAMA_BASE_URL + ) + K8S_FLAG = os.environ.get("K8S_FLAG", "") USE_OLLAMA_DOCKER = os.environ.get("USE_OLLAMA_DOCKER", "false") @@ -998,6 +1004,66 @@ Strictly return in JSON format: """ +ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig( + "ENABLE_AUTOCOMPLETE_GENERATION", + "task.autocomplete.enable", + os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true", +) + +AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( + "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", + "task.autocomplete.input_max_length", + int(os.environ.get("AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", "-1")), +) + +AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = PersistentConfig( + "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE", + "task.autocomplete.prompt_template", + os.environ.get("AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE", ""), +) + + +DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = """### Task: +You are an autocompletion system. Continue the text in `` based on the **completion type** in `` and the given language. + +### **Instructions**: +1. Analyze `` for context and meaning. +2. Use `` to guide your output: + - **General**: Provide a natural, concise continuation. + - **Search Query**: Complete as if generating a realistic search query. +3. Start as if you are directly continuing ``. Do **not** repeat, paraphrase, or respond as a model. Simply complete the text. +4. Ensure the continuation: + - Flows naturally from ``. + - Avoids repetition, overexplaining, or unrelated ideas. +5. If unsure, return: `{ "text": "" }`. + +### **Output Rules**: +- Respond only in JSON format: `{ "text": "" }`. + +### **Examples**: +#### Example 1: +Input: +General +The sun was setting over the horizon, painting the sky +Output: +{ "text": "with vibrant shades of orange and pink." } + +#### Example 2: +Input: +Search Query +Top-rated restaurants in +Output: +{ "text": "New York City for Italian cuisine." } + +--- +### Context: + +{{MESSAGES:END:6}} + +{{TYPE}} +{{PROMPT}} +#### Output: +""" TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig( "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", @@ -1259,6 +1325,12 @@ YOUTUBE_LOADER_LANGUAGE = PersistentConfig( os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(","), ) +YOUTUBE_LOADER_PROXY_URL = PersistentConfig( + "YOUTUBE_LOADER_PROXY_URL", + "rag.youtube_loader_proxy_url", + os.getenv("YOUTUBE_LOADER_PROXY_URL", ""), +) + ENABLE_RAG_WEB_SEARCH = PersistentConfig( "ENABLE_RAG_WEB_SEARCH", diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 9c7d6f9e9f..c5fdfabfb8 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -113,5 +113,6 @@ class TASKS(str, Enum): TAGS_GENERATION = "tags_generation" EMOJI_GENERATION = "emoji_generation" QUERY_GENERATION = "query_generation" + AUTOCOMPLETE_GENERATION = "autocomplete_generation" FUNCTION_CALLING = "function_calling" MOA_RESPONSE_GENERATION = "moa_response_generation" diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 6e591311df..28b5a10a57 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -373,7 +373,7 @@ else: AIOHTTP_CLIENT_TIMEOUT = 300 AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "3" + "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "5" ) if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "": @@ -384,7 +384,7 @@ else: AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST ) except Exception: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 3 + AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5 #################################### # OFFLINE_MODE diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index aa936db47e..177bded667 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -89,6 +89,10 @@ from open_webui.config import ( DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE, TITLE_GENERATION_PROMPT_TEMPLATE, TAGS_GENERATION_PROMPT_TEMPLATE, + ENABLE_AUTOCOMPLETE_GENERATION, + AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, + AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, + DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, WEBHOOK_URL, WEBUI_AUTH, @@ -127,6 +131,7 @@ from open_webui.utils.task import ( rag_template, title_generation_template, query_generation_template, + autocomplete_generation_template, tags_generation_template, emoji_generation_template, moa_response_generation_template, @@ -207,6 +212,11 @@ app.state.config.TASK_MODEL_EXTERNAL = TASK_MODEL_EXTERNAL app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE +app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION +app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ( + AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH +) + app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE @@ -215,6 +225,10 @@ app.state.config.ENABLE_SEARCH_QUERY_GENERATION = ENABLE_SEARCH_QUERY_GENERATION app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = ENABLE_RETRIEVAL_QUERY_GENERATION app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = QUERY_GENERATION_PROMPT_TEMPLATE +app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = ( + AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE +) + app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE ) @@ -1665,6 +1679,8 @@ async def get_task_config(user=Depends(get_verified_user)): "TASK_MODEL": app.state.config.TASK_MODEL, "TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL, "TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + "ENABLE_AUTOCOMPLETE_GENERATION": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, + "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, "TAGS_GENERATION_PROMPT_TEMPLATE": app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, "ENABLE_TAGS_GENERATION": app.state.config.ENABLE_TAGS_GENERATION, "ENABLE_SEARCH_QUERY_GENERATION": app.state.config.ENABLE_SEARCH_QUERY_GENERATION, @@ -1678,6 +1694,8 @@ class TaskConfigForm(BaseModel): TASK_MODEL: Optional[str] TASK_MODEL_EXTERNAL: Optional[str] TITLE_GENERATION_PROMPT_TEMPLATE: str + ENABLE_AUTOCOMPLETE_GENERATION: bool + AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int TAGS_GENERATION_PROMPT_TEMPLATE: str ENABLE_TAGS_GENERATION: bool ENABLE_SEARCH_QUERY_GENERATION: bool @@ -1693,6 +1711,14 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = ( form_data.TITLE_GENERATION_PROMPT_TEMPLATE ) + + app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ( + form_data.ENABLE_AUTOCOMPLETE_GENERATION + ) + app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ( + form_data.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH + ) + app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = ( form_data.TAGS_GENERATION_PROMPT_TEMPLATE ) @@ -1715,6 +1741,8 @@ async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_u "TASK_MODEL": app.state.config.TASK_MODEL, "TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL, "TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + "ENABLE_AUTOCOMPLETE_GENERATION": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, + "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, "TAGS_GENERATION_PROMPT_TEMPLATE": app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, "ENABLE_TAGS_GENERATION": app.state.config.ENABLE_TAGS_GENERATION, "ENABLE_SEARCH_QUERY_GENERATION": app.state.config.ENABLE_SEARCH_QUERY_GENERATION, @@ -1942,7 +1970,7 @@ async def generate_queries(form_data: dict, user=Depends(get_verified_user)): f"generating {type} queries using model {task_model_id} for user {user.email}" ) - if app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE != "": + if (app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != "": template = app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE @@ -1982,6 +2010,90 @@ async def generate_queries(form_data: dict, user=Depends(get_verified_user)): return await generate_chat_completions(form_data=payload, user=user) +@app.post("/api/task/auto/completions") +async def generate_autocompletion(form_data: dict, user=Depends(get_verified_user)): + if not app.state.config.ENABLE_AUTOCOMPLETE_GENERATION: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Autocompletion generation is disabled", + ) + + type = form_data.get("type") + prompt = form_data.get("prompt") + messages = form_data.get("messages") + + if app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH > 0: + if len(prompt) > app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Input prompt exceeds maximum length of {app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}", + ) + + model_list = await get_all_models() + models = {model["id"]: model for model in model_list} + + model_id = form_data["model"] + if model_id not in models: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + # Check if the user has a custom task model + # If the user has a custom task model, use that model + task_model_id = get_task_model_id( + model_id, + app.state.config.TASK_MODEL, + app.state.config.TASK_MODEL_EXTERNAL, + models, + ) + + log.debug( + f"generating autocompletion using model {task_model_id} for user {user.email}" + ) + + if (app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != "": + template = app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE + else: + template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE + + content = autocomplete_generation_template( + template, prompt, messages, type, {"name": user.name} + ) + + payload = { + "model": task_model_id, + "messages": [{"role": "user", "content": content}], + "stream": False, + "metadata": { + "task": str(TASKS.AUTOCOMPLETE_GENERATION), + "task_body": form_data, + "chat_id": form_data.get("chat_id", None), + }, + } + + print(payload) + + # Handle pipeline filters + try: + payload = filter_pipeline(payload, user, models) + except Exception as e: + if len(e.args) > 1: + return JSONResponse( + status_code=e.args[0], + content={"detail": e.args[1]}, + ) + else: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": str(e)}, + ) + if "chat_id" in payload: + del payload["chat_id"] + + return await generate_chat_completions(form_data=payload, user=user) + + @app.post("/api/task/emoji/completions") async def generate_emoji(form_data: dict, user=Depends(get_verified_user)): diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index bcef773a56..fbcf7d6977 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -27,6 +27,7 @@ def set_security_headers() -> Dict[str, str]: - x-download-options - x-frame-options - x-permitted-cross-domain-policies + - content-security-policy Each environment variable is associated with a specific setter function that constructs the header. If the environment variable is set, the @@ -45,6 +46,7 @@ def set_security_headers() -> Dict[str, str]: "XDOWNLOAD_OPTIONS": set_xdownload_options, "XFRAME_OPTIONS": set_xframe, "XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies, + "CONTENT_SECURITY_POLICY": set_content_security_policy, } for env_var, setter in header_setters.items(): @@ -124,3 +126,8 @@ def set_xpermitted_cross_domain_policies(value: str): if not match: value = "none" return {"X-Permitted-Cross-Domain-Policies": value} + + +# Set Content-Security-Policy response header +def set_content_security_policy(value: str): + return {"Content-Security-Policy": value} diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 3b71ba746e..604161a318 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -53,7 +53,9 @@ def prompt_template( def replace_prompt_variable(template: str, prompt: str) -> str: def replacement_function(match): - full_match = match.group(0) + full_match = match.group( + 0 + ).lower() # Normalize to lowercase for consistent handling start_length = match.group(1) end_length = match.group(2) middle_length = match.group(3) @@ -73,20 +75,23 @@ def replace_prompt_variable(template: str, prompt: str) -> str: return f"{start}...{end}" return "" - template = re.sub( - r"{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}", - replacement_function, - template, - ) + # Updated regex pattern to make it case-insensitive with the `(?i)` flag + pattern = r"(?i){{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}" + template = re.sub(pattern, replacement_function, template) return template -def replace_messages_variable(template: str, messages: list[str]) -> str: +def replace_messages_variable( + template: str, messages: Optional[list[str]] = None +) -> str: def replacement_function(match): full_match = match.group(0) start_length = match.group(1) end_length = match.group(2) middle_length = match.group(3) + # If messages is None, handle it as an empty list + if messages is None: + return "" # Process messages based on the number of messages required if full_match == "{{MESSAGES}}": @@ -122,7 +127,7 @@ def replace_messages_variable(template: str, messages: list[str]) -> str: def rag_template(template: str, context: str, query: str): - if template == "": + if template.strip() == "": template = DEFAULT_RAG_TEMPLATE if "[context]" not in template and "{{CONTEXT}}" not in template: @@ -212,6 +217,28 @@ def emoji_generation_template( return template +def autocomplete_generation_template( + template: str, + prompt: str, + messages: Optional[list[dict]] = None, + type: Optional[str] = None, + user: Optional[dict] = None, +) -> str: + template = template.replace("{{TYPE}}", type if type else "") + template = replace_prompt_variable(template, prompt) + template = replace_messages_variable(template, messages) + + template = prompt_template( + template, + **( + {"user_name": user.get("name"), "user_location": user.get("location")} + if user + else {} + ), + ) + return template + + def query_generation_template( template: str, messages: list[dict], user: Optional[dict] = None ) -> str: diff --git a/backend/requirements.txt b/backend/requirements.txt index c83e6b3b7d..79e898c6a2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.111.0 uvicorn[standard]==0.30.6 pydantic==2.9.2 -python-multipart==0.0.17 +python-multipart==0.0.18 Flask==3.0.3 Flask-Cors==5.0.0 @@ -11,13 +11,13 @@ python-jose==3.3.0 passlib[bcrypt]==1.7.4 requests==2.32.3 -aiohttp==3.10.8 +aiohttp==3.11.8 async-timeout aiocache aiofiles sqlalchemy==2.0.32 -alembic==1.13.2 +alembic==1.14.0 peewee==3.17.6 peewee-migrate==1.12.2 psycopg2-binary==2.9.9 @@ -44,11 +44,11 @@ langchain-chroma==0.1.4 fake-useragent==1.5.1 chromadb==0.5.15 -pymilvus==2.4.9 +pymilvus==2.5.0 qdrant-client~=1.12.0 opensearch-py==2.7.1 -sentence-transformers==3.2.0 +sentence-transformers==3.3.1 colbert-ai==0.2.21 einops==0.8.0 diff --git a/package-lock.json b/package-lock.json index eaa39b6dbc..51dcb23eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.4.6", + "version": "0.4.7", "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", @@ -1836,9 +1836,10 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.25", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "license": "MIT" }, "node_modules/@popperjs/core": { "version": "2.11.8", @@ -2257,22 +2258,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.6.2.tgz", - "integrity": "sha512-ruogrSPXjckn5poUiZU8VYNCSPHq66SFR1AATvOikQxtP6LNI4niAZVX/AWZRe/EPDG3oY2DNJ9c5z7u0t2NAQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.9.0.tgz", + "integrity": "sha512-W3E7ed3ChB6kPqRs2H7tcHp+Z7oiTFC6m+lLyAQQuyXeqw6LdNuuwEUla+5VM0OGgqQD+cYD6+7Xq80vVm17Vg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", - "cookie": "^0.7.0", + "cookie": "^0.6.0", "devalue": "^5.1.0", - "esm-env": "^1.0.0", + "esm-env": "^1.2.1", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", + "sirv": "^3.0.0", "tiny-glob": "^0.2.9" }, "bin": { @@ -2282,9 +2284,9 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" + "vite": "^5.0.3 || ^6.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { @@ -4391,9 +4393,10 @@ "dev": true }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5690,9 +5693,10 @@ } }, "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", + "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==", + "license": "MIT" }, "node_modules/espree": { "version": "9.6.1", @@ -8228,6 +8232,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "license": "MIT", "engines": { "node": ">=10" } @@ -10359,16 +10364,17 @@ } }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" + "node": ">=18" } }, "node_modules/slash": { @@ -11260,6 +11266,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", "engines": { "node": ">=6" } diff --git a/package.json b/package.json index 80e7b4fc39..220f5c6919 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.4.6", + "version": "0.4.7", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 0dc8e856d6..0554baa9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi==0.111.0", "uvicorn[standard]==0.30.6", "pydantic==2.9.2", - "python-multipart==0.0.17", + "python-multipart==0.0.18", "Flask==3.0.3", "Flask-Cors==5.0.0", @@ -19,13 +19,13 @@ dependencies = [ "passlib[bcrypt]==1.7.4", "requests==2.32.3", - "aiohttp==3.10.8", + "aiohttp==3.11.8", "async-timeout", "aiocache", "aiofiles", "sqlalchemy==2.0.32", - "alembic==1.13.2", + "alembic==1.14.0", "peewee==3.17.6", "peewee-migrate==1.12.2", "psycopg2-binary==2.9.9", @@ -51,11 +51,11 @@ dependencies = [ "fake-useragent==1.5.1", "chromadb==0.5.15", - "pymilvus==2.4.9", + "pymilvus==2.5.0", "qdrant-client~=1.12.0", "opensearch-py==2.7.1", - "sentence-transformers==3.2.0", + "sentence-transformers==3.3.1", "colbert-ai==0.2.21", "einops==0.8.0", diff --git a/src/app.css b/src/app.css index 659498adde..dba61300aa 100644 --- a/src/app.css +++ b/src/app.css @@ -45,15 +45,15 @@ math { } .input-prose { - @apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply prose dark:prose-invert prose-headings:font-semibold prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .input-prose-sm { - @apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm; + @apply prose dark:prose-invert prose-headings:font-semibold prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm; } .markdown-prose { - @apply prose dark:prose-invert prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply prose dark:prose-invert prose-headings:font-semibold prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown a { @@ -214,6 +214,13 @@ input[type='number'] { height: 0; } +.ai-autocompletion::after { + color: #a0a0a0; + + content: attr(data-suggestion); + pointer-events: none; +} + .tiptap > pre > code { border-radius: 0.4rem; font-size: 0.85rem; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 2e9e836a81..e76aa3c99e 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -397,6 +397,77 @@ export const generateQueries = async ( } }; +export const generateAutoCompletion = async ( + token: string = '', + model: string, + prompt: string, + messages?: object[], + type: string = 'search query' +) => { + const controller = new AbortController(); + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/task/auto/completions`, { + signal: controller.signal, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model: model, + prompt: prompt, + ...(messages && { messages: messages }), + type: type, + stream: false + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + if ('detail' in err) { + error = err.detail; + } + return null; + }); + + if (error) { + throw error; + } + + const response = res?.choices[0]?.message?.content ?? ''; + + try { + const jsonStartIndex = response.indexOf('{'); + const jsonEndIndex = response.lastIndexOf('}'); + + if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { + const jsonResponse = response.substring(jsonStartIndex, jsonEndIndex + 1); + + // Step 5: Parse the JSON block + const parsed = JSON.parse(jsonResponse); + + // Step 6: If there's a "queries" key, return the queries array; otherwise, return an empty array + if (parsed && parsed.text) { + return parsed.text; + } else { + return ''; + } + } + + // If no valid JSON block found, return response as is + return response; + } catch (e) { + // Catch and safely return empty array on any parsing errors + console.error('Failed to parse response: ', e); + return response; + } +}; + export const generateMoACompletion = async ( token: string = '', model: string, diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index 6c6b18b9fd..21ae792fa5 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -40,6 +40,7 @@ type ContentExtractConfigForm = { type YoutubeConfigForm = { language: string[]; translation?: string | null; + proxy_url: string; }; type RAGConfigForm = { diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 2fee518ee0..9c669dae53 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -24,6 +24,8 @@ TASK_MODEL: '', TASK_MODEL_EXTERNAL: '', TITLE_GENERATION_PROMPT_TEMPLATE: '', + ENABLE_AUTOCOMPLETE_GENERATION: true, + AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1, TAGS_GENERATION_PROMPT_TEMPLATE: '', ENABLE_TAGS_GENERATION: true, ENABLE_SEARCH_QUERY_GENERATION: true, @@ -138,11 +140,42 @@ -
+
- {$i18n.t('Enable Tags Generation')} + {$i18n.t('Autocomplete Generation')} +
+ + + + +
+ + {#if taskConfig.ENABLE_AUTOCOMPLETE_GENERATION} +
+
+ {$i18n.t('Autocomplete Generation Input Max Length')} +
+ + + + +
+ {/if} + +
+ +
+
+ {$i18n.t('Tags Generation')}
@@ -166,11 +199,11 @@
{/if} -
+
- {$i18n.t('Enable Retrieval Query Generation')} + {$i18n.t('Retrieval Query Generation')}
@@ -178,7 +211,7 @@
- {$i18n.t('Enable Web Search Query Generation')} + {$i18n.t('Web Search Query Generation')}
@@ -201,7 +234,7 @@
-
+
diff --git a/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte b/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte index 6a2feb6ccb..4922b5b6f0 100644 --- a/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte +++ b/src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte @@ -185,6 +185,10 @@ diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index d8b1a33d14..a3ccbec1db 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -29,13 +29,15 @@ let youtubeLanguage = 'en'; let youtubeTranslation = null; + let youtubeProxyUrl = ''; const submitHandler = async () => { const res = await updateRAGConfig(localStorage.token, { web: webConfig, youtube: { language: youtubeLanguage.split(',').map((lang) => lang.trim()), - translation: youtubeTranslation + translation: youtubeTranslation, + proxy_url: youtubeProxyUrl } }); }; @@ -48,6 +50,7 @@ youtubeLanguage = res.youtube.language.join(','); youtubeTranslation = res.youtube.translation; + youtubeProxyUrl = res.youtube.proxy_url; } }); @@ -358,6 +361,21 @@
+ +
+
+
{$i18n.t('Proxy URL')}
+
+ +
+
+
{/if} diff --git a/src/lib/components/admin/Users/UserList/UserChatsModal.svelte b/src/lib/components/admin/Users/UserList/UserChatsModal.svelte index 4c84478290..7cf03b4b75 100644 --- a/src/lib/components/admin/Users/UserList/UserChatsModal.svelte +++ b/src/lib/components/admin/Users/UserList/UserChatsModal.svelte @@ -9,13 +9,14 @@ import Modal from '$lib/components/common/Modal.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; const i18n = getContext('i18n'); export let show = false; export let user; - let chats = []; + let chats = null; const deleteChatHandler = async (chatId) => { const res = await deleteChatById(localStorage.token, chatId).catch((error) => { @@ -31,6 +32,8 @@ chats = await getChatListByUserId(localStorage.token, user.id); } })(); + } else { + chats = null; } let sortKey = 'updated_at'; // default sort key @@ -46,33 +49,32 @@ -
-
-
- {$i18n.t("{{user}}'s Chats", { user: user.name })} -
- +
+
+ {$i18n.t("{{user}}'s Chats", { user: user.name })}
-
+ +
-
-
+
+
+ {#if chats} {#if chats.length > 0}
@@ -176,7 +178,9 @@ {$i18n.t('has no conversations.')}
{/if} -
+ {:else} + + {/if}
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 03080d9b5d..e6a653420c 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2284,7 +2284,7 @@
-
+
- {$i18n.t('LLMs can make mistakes. Verify important information.')} +
{:else} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 16e3cdb91e..4df2b552aa 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -18,7 +18,7 @@ showControls } from '$lib/stores'; - import { blobToFile, findWordIndices } from '$lib/utils'; + import { blobToFile, createMessagesList, findWordIndices } from '$lib/utils'; import { transcribeAudio } from '$lib/apis/audio'; import { uploadFile } from '$lib/apis/files'; import { getTools } from '$lib/apis/tools'; @@ -34,6 +34,8 @@ import Commands from './MessageInput/Commands.svelte'; import XMark from '../icons/XMark.svelte'; import RichTextInput from '../common/RichTextInput.svelte'; + import { generateAutoCompletion } from '$lib/apis'; + import { error, text } from '@sveltejs/kit'; const i18n = getContext('i18n'); @@ -47,6 +49,9 @@ export let atSelectedModel: Model | undefined; export let selectedModels: ['']; + let selectedModelIds = []; + $: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels; + export let history; export let prompt = ''; @@ -267,7 +272,7 @@ {#if loaded}
-
+
{#if autoScroll === false && history?.currentId}
-
+
{#if files.length > 0} @@ -557,7 +562,7 @@ }} >