diff --git a/CHANGELOG.md b/CHANGELOG.md index a62e18871..04a79ca43 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 0ac1f0401..71a40cb47 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 ad1088be0..8eb48488b 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 63bc18190..7ad4af064 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 b628f4f9f..8f798c317 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 0a76626c1..15d209941 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 9c7d6f9e9..c5fdfabfb 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 6e591311d..28b5a10a5 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 aa936db47..177bded66 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 bcef773a5..fbcf7d697 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 3b71ba746..604161a31 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 c83e6b3b7..79e898c6a 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 eaa39b6db..51dcb23eb 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 80e7b4fc3..220f5c691 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 0dc8e856d..0554baa9e 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 659498add..dba61300a 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 2e9e836a8..e76aa3c99 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 6c6b18b9f..21ae792fa 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 2fee518ee..9c669dae5 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 6a2feb6cc..4922b5b6f 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 d8b1a33d1..a3ccbec1d 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 4c8447829..7cf03b4b7 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 03080d9b5..e6a653420 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 16e3cdb91..4df2b552a 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 @@ }} >