diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index c37b831de..e74c54cf9 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -927,6 +927,12 @@ USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = ( == "true" ) +USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = ( + os.environ.get("USER_PERMISSIONS_FEATURES_CODE_INTERPRETER", "True").lower() + == "true" +) + + DEFAULT_USER_PERMISSIONS = { "workspace": { "models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS, @@ -944,6 +950,7 @@ DEFAULT_USER_PERMISSIONS = { "features": { "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, + "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, }, } @@ -1094,21 +1101,27 @@ TITLE_GENERATION_PROMPT_TEMPLATE = PersistentConfig( os.environ.get("TITLE_GENERATION_PROMPT_TEMPLATE", ""), ) -DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """Create a concise, 3-5 word title with an emoji as a title for the chat history, in the given language. Suitable Emojis for the summary can be used to enhance understanding but avoid quotation marks or special formatting. RESPOND ONLY WITH THE TITLE TEXT. - -Examples of titles: -📉 Stock Market Trends -🍪 Perfect Chocolate Chip Recipe -Evolution of Music Streaming -Remote Work Productivity Tips -Artificial Intelligence in Healthcare -🎮 Video Game Development Insights - +DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: +Generate a concise, 3-5 word title with an emoji summarizing the chat history. +### Guidelines: +- The title should clearly represent the main theme or subject of the conversation. +- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. +- Write the title in the chat's primary language; default to English if multilingual. +- Prioritize accuracy over excessive creativity; keep it clear and simple. +### Output: +JSON format: { "title": "your concise title here" } +### Examples: +- { "title": "📉 Stock Market Trends" }, +- { "title": "🍪 Perfect Chocolate Chip Recipe" }, +- { "title": "Evolution of Music Streaming" }, +- { "title": "Remote Work Productivity Tips" }, +- { "title": "Artificial Intelligence in Healthcare" }, +- { "title": "🎮 Video Game Development Insights" } +### Chat History: {{MESSAGES:END:2}} """ - TAGS_GENERATION_PROMPT_TEMPLATE = PersistentConfig( "TAGS_GENERATION_PROMPT_TEMPLATE", "task.tags.prompt_template", @@ -1277,7 +1290,28 @@ TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig( ) -DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}}\nReturn an empty string if no tools match the query. If a function tool matches, construct and return a JSON object in the format {\"name\": \"functionName\", \"parameters\": {\"requiredFunctionParamKey\": \"requiredFunctionParamValue\"}} using the appropriate tool and its parameters. Only return the object and limit the response to the JSON object without additional text.""" +DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}} + +Your task is to choose and return the correct tool(s) from the list of available tools based on the query. Follow these guidelines: + +- Return only the JSON object, without any additional text or explanation. + +- If no tools match the query, return an empty array: + { + "tool_calls": [] + } + +- If one or more tools match the query, construct a JSON response containing a "tool_calls" array with objects that include: + - "name": The tool's name. + - "parameters": A dictionary of required parameters and their corresponding values. + +The format for the JSON response is strictly: +{ + "tool_calls": [ + {"name": "toolName1", "parameters": {"key1": "value1"}}, + {"name": "toolName2", "parameters": {"key2": "value2"}} + ] +}""" DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE = """Your task is to reflect the speaker's likely facial expression through a fitting emoji. Interpret emotions from the message and reflect their facial expression using fitting, diverse emojis (e.g., 😊, 😢, 😡, 😱). @@ -1290,6 +1324,24 @@ Your task is to synthesize these responses into a single, high-quality response. Responses from models: {{responses}}""" + +DEFAULT_CODE_INTERPRETER_PROMPT = """ +#### Tools Available + +1. **Code Interpreter**: `` + - You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response. + - The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**. + - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. Do NOT use triple backticks. + - When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user. + - After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.** + - If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary. + - If a link is provided for an image, audio, or any file, include it in the response exactly as given to ensure the user has access to the original resource. + - All responses should be communicated in the chat's primary language, ensuring seamless understanding. If the chat is multilingual, default to English for clarity. + - **If a link to an image, audio, or any file is provided in markdown format, explicitly display it as part of the response to ensure the user can access it easily, do NOT change the link.** + +Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user.""" + + #################################### # Vector Database #################################### @@ -1319,6 +1371,7 @@ CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_DB = os.environ.get("MILVUS_DB", "default") +MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None) # Qdrant QDRANT_URI = os.environ.get("QDRANT_URI", None) @@ -1699,6 +1752,11 @@ BING_SEARCH_V7_SUBSCRIPTION_KEY = PersistentConfig( os.environ.get("BING_SEARCH_V7_SUBSCRIPTION_KEY", ""), ) +EXA_API_KEY = PersistentConfig( + "EXA_API_KEY", + "rag.web.search.exa_api_key", + os.getenv("EXA_API_KEY", ""), +) RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig( "RAG_WEB_SEARCH_RESULT_COUNT", diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index cb65e0d77..86d87a2c3 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -57,7 +57,7 @@ class ERROR_MESSAGES(str, Enum): ) FILE_NOT_SENT = "FILE_NOT_SENT" - FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format (e.g., JPG, PNG, PDF, TXT) and try again." + FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format and try again." NOT_FOUND = "We could not find what you're looking for :/" USER_NOT_FOUND = "We could not find what you're looking for :/" diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index b589e9490..00605e15d 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -358,14 +358,21 @@ WEBUI_SECRET_KEY = os.environ.get( WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax") -WEBUI_SESSION_COOKIE_SECURE = os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true" +WEBUI_SESSION_COOKIE_SECURE = ( + os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true" +) -WEBUI_AUTH_COOKIE_SAME_SITE = os.environ.get("WEBUI_AUTH_COOKIE_SAME_SITE", WEBUI_SESSION_COOKIE_SAME_SITE) +WEBUI_AUTH_COOKIE_SAME_SITE = os.environ.get( + "WEBUI_AUTH_COOKIE_SAME_SITE", WEBUI_SESSION_COOKIE_SAME_SITE +) -WEBUI_AUTH_COOKIE_SECURE = os.environ.get( - "WEBUI_AUTH_COOKIE_SECURE", - os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false") -).lower() == "true" +WEBUI_AUTH_COOKIE_SECURE = ( + os.environ.get( + "WEBUI_AUTH_COOKIE_SECURE", + os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false"), + ).lower() + == "true" +) if WEBUI_AUTH and WEBUI_SECRET_KEY == "": raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 00270aabc..863f58dea 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -177,6 +177,7 @@ from open_webui.config import ( BING_SEARCH_V7_ENDPOINT, BING_SEARCH_V7_SUBSCRIPTION_KEY, BRAVE_SEARCH_API_KEY, + EXA_API_KEY, KAGI_SEARCH_API_KEY, MOJEEK_SEARCH_API_KEY, GOOGLE_PSE_API_KEY, @@ -523,6 +524,7 @@ app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE app.state.config.JINA_API_KEY = JINA_API_KEY app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY +app.state.config.EXA_API_KEY = EXA_API_KEY app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS @@ -859,6 +861,7 @@ async def chat_completion( if model_id not in request.app.state.MODELS: raise Exception("Model not found") model = request.app.state.MODELS[model_id] + model_info = Models.get_model_by_id(model_id) # Check if user has access to the model if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user": @@ -875,12 +878,25 @@ async def chat_completion( "tool_ids": form_data.get("tool_ids", None), "files": form_data.get("files", None), "features": form_data.get("features", None), + "variables": form_data.get("variables", None), + "model": model_info, + **( + {"function_calling": "native"} + if form_data.get("params", {}).get("function_calling") == "native" + or ( + model_info + and model_info.params.model_dump().get("function_calling") + == "native" + ) + else {} + ), } form_data["metadata"] = metadata - form_data, events = await process_chat_payload( + form_data, metadata, events = await process_chat_payload( request, form_data, metadata, user, model ) + except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -889,6 +905,7 @@ async def chat_completion( try: response = await chat_completion_handler(request, form_data, user) + return await process_chat_response( request, response, form_data, user, events, metadata, tasks ) @@ -1007,10 +1024,6 @@ async def get_app_config(request: Request): else {} ), }, - "google_drive": { - "client_id": GOOGLE_DRIVE_CLIENT_ID.value, - "api_key": GOOGLE_DRIVE_API_KEY.value, - }, **( { "default_models": app.state.config.DEFAULT_MODELS, @@ -1030,6 +1043,10 @@ async def get_app_config(request: Request): "max_count": app.state.config.FILE_MAX_COUNT, }, "permissions": {**app.state.config.USER_PERMISSIONS}, + "google_drive": { + "client_id": GOOGLE_DRIVE_CLIENT_ID.value, + "api_key": GOOGLE_DRIVE_API_KEY.value, + }, } if user is not None else {} @@ -1063,7 +1080,7 @@ async def get_app_version(): @app.get("/api/version/updates") -async def get_app_latest_release_version(): +async def get_app_latest_release_version(user=Depends(get_verified_user)): if OFFLINE_MODE: log.debug( f"Offline mode is enabled, returning current version as latest version" diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index bdfa16eb6..43c3f3d1a 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -8,13 +8,17 @@ from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.config import ( MILVUS_URI, MILVUS_DB, + MILVUS_TOKEN, ) class MilvusClient: def __init__(self): self.collection_prefix = "open_webui" - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB) + if MILVUS_TOKEN is None: + self.client = Client(uri=MILVUS_URI, database=MILVUS_DB) + else: + self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN) def _result_to_get_result(self, result) -> GetResult: ids = [] diff --git a/backend/open_webui/retrieval/web/exa.py b/backend/open_webui/retrieval/web/exa.py new file mode 100644 index 000000000..3dbcc472e --- /dev/null +++ b/backend/open_webui/retrieval/web/exa.py @@ -0,0 +1,74 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +import requests +from open_webui.env import SRC_LOG_LEVELS +from open_webui.retrieval.web.main import SearchResult + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + +EXA_API_BASE = "https://api.exa.ai" + + +@dataclass +class ExaResult: + url: str + title: str + text: str + + +def search_exa( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """Search using Exa Search API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A Exa Search API key + query (str): The query to search for + count (int): Number of results to return + filter_list (Optional[list[str]]): List of domains to filter results by + """ + log.info(f"Searching with Exa for query: {query}") + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + + payload = { + "query": query, + "numResults": count or 5, + "includeDomains": filter_list, + "contents": {"text": True, "highlights": True}, + "type": "auto", # Use the auto search type (keyword or neural) + } + + try: + response = requests.post(f"{EXA_API_BASE}/search", headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + results = [] + for result in data["results"]: + results.append( + ExaResult( + url=result["url"], + title=result["title"], + text=result["text"], + ) + ) + + log.info(f"Found {len(results)} results") + return [ + SearchResult( + link=result.url, + title=result.title, + snippet=result.text, + ) + for result in results + ] + except Exception as e: + log.error(f"Error searching Exa: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index 1af8a70aa..28a749e7d 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -1,3 +1,5 @@ +import validators + from typing import Optional from urllib.parse import urlparse @@ -10,6 +12,8 @@ def get_filtered_results(results, filter_list): filtered_results = [] for result in results: url = result.get("url") or result.get("link", "") + if not validators.url(url): + continue domain = urlparse(url).netloc if any(domain.endswith(filtered_domain) for filtered_domain in filter_list): filtered_results.append(result) diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index a322bbbfc..3a73444b3 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -43,6 +43,17 @@ def validate_url(url: Union[str, Sequence[str]]): return False +def safe_validate_urls(url: Sequence[str]) -> Sequence[str]: + valid_urls = [] + for u in url: + try: + if validate_url(u): + valid_urls.append(u) + except ValueError: + continue + return valid_urls + + def resolve_hostname(hostname): # Get address information addr_info = socket.getaddrinfo(hostname, None) @@ -86,11 +97,11 @@ def get_web_loader( verify_ssl: bool = True, requests_per_second: int = 2, ): - # Check if the URL is valid - if not validate_url(urls): - raise ValueError(ERROR_MESSAGES.INVALID_URL) + # Check if the URLs are valid + safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) + return SafeWebBaseLoader( - urls, + safe_urls, verify_ssl=verify_ssl, requests_per_second=requests_per_second, continue_on_failure=True, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index d7c4fa013..b6a2c7562 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -164,7 +164,7 @@ async def update_password( ############################ # LDAP Authentication ############################ -@router.post("/ldap", response_model=SigninResponse) +@router.post("/ldap", response_model=SessionUserResponse) async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ENABLE_LDAP = request.app.state.config.ENABLE_LDAP LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL @@ -288,6 +288,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): httponly=True, # Ensures the cookie is not accessible via JavaScript ) + user_permissions = get_permissions( + user.id, request.app.state.config.USER_PERMISSIONS + ) + return { "token": token, "token_type": "Bearer", @@ -296,6 +300,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): "name": user.name, "role": user.role, "profile_image_url": user.profile_image_url, + "permissions": user_permissions, } else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index a001dd01f..2efd043ef 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -444,15 +444,21 @@ async def pin_chat_by_id(id: str, user=Depends(get_verified_user)): ############################ +class CloneForm(BaseModel): + title: Optional[str] = None + + @router.post("/{id}/clone", response_model=Optional[ChatResponse]) -async def clone_chat_by_id(id: str, user=Depends(get_verified_user)): +async def clone_chat_by_id( + form_data: CloneForm, id: str, user=Depends(get_verified_user) +): chat = Chats.get_chat_by_id_and_user_id(id, user.id) if chat: updated_chat = { **chat.chat, "originalChatId": chat.id, "branchPointMessageId": chat.chat["history"]["currentId"], - "title": f"Clone of {chat.title}", + "title": form_data.title if form_data.title else f"Clone of {chat.title}", } chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat})) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index a85ccd05e..aac16e851 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -264,7 +264,8 @@ def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if (knowledge.user_id != user.id + if ( + knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" ): @@ -349,7 +350,7 @@ def update_file_from_knowledge_by_id( knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" - ): + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -418,7 +419,7 @@ def remove_file_from_knowledge_by_id( knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" - ): + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -441,10 +442,6 @@ def remove_file_from_knowledge_by_id( if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) - # Delete physical file - if file.path: - Storage.delete_file(file.path) - # Delete file from database Files.delete_file_by_id(form_data.file_id) @@ -500,7 +497,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" - ): + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -563,7 +560,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" - ): + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -606,7 +603,7 @@ def add_files_to_knowledge_batch( knowledge.user_id != user.id and not has_access(user.id, "write", knowledge.access_control) and user.role != "admin" - ): + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a45814d32..0cf3308f1 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -184,10 +184,10 @@ async def delete_model_by_id(id: str, user=Depends(get_verified_user)): ) if ( - user.role == "admin" - or model.user_id == user.id - or has_access(user.id, "write", model.access_control) - ): + user.role != "admin" + and model.user_id != user.id + and not has_access(user.id, "write", model.access_control) + ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 261cd5ba3..10367b020 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -395,7 +395,7 @@ async def get_ollama_tags( ) if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: - models["models"] = get_filtered_models(models, user) + models["models"] = await get_filtered_models(models, user) return models @@ -939,6 +939,7 @@ async def generate_completion( class ChatMessage(BaseModel): role: str content: str + tool_calls: Optional[list[dict]] = None images: Optional[list[str]] = None @@ -950,6 +951,7 @@ class GenerateChatCompletionForm(BaseModel): template: Optional[str] = None stream: Optional[bool] = True keep_alive: Optional[Union[int, str]] = None + tools: Optional[list[dict]] = None async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] = None): @@ -977,6 +979,7 @@ async def generate_chat_completion( if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True + metadata = form_data.pop("metadata", None) try: form_data = GenerateChatCompletionForm(**form_data) except Exception as e: @@ -987,8 +990,6 @@ async def generate_chat_completion( ) payload = {**form_data.model_dump(exclude_none=True)} - if "metadata" in payload: - del payload["metadata"] model_id = payload["model"] model_info = Models.get_model_by_id(model_id) @@ -1006,7 +1007,7 @@ async def generate_chat_completion( payload["options"] = apply_model_params_to_body_ollama( params, payload["options"] ) - payload = apply_model_system_prompt_to_body(params, payload, user) + payload = apply_model_system_prompt_to_body(params, payload, metadata) # Check if user has access to the model if not bypass_filter and user.role == "user": diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index f7d7fd294..c27f35e7e 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -489,7 +489,7 @@ async def get_models( raise HTTPException(status_code=500, detail=error_detail) if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: - models["data"] = get_filtered_models(models, user) + models["data"] = await get_filtered_models(models, user) return models @@ -551,9 +551,9 @@ async def generate_chat_completion( bypass_filter = True idx = 0 + payload = {**form_data} - if "metadata" in payload: - del payload["metadata"] + metadata = payload.pop("metadata", None) model_id = form_data.get("model") model_info = Models.get_model_by_id(model_id) @@ -566,7 +566,7 @@ async def generate_chat_completion( params = model_info.params.model_dump() payload = apply_model_params_to_body_openai(params, payload) - payload = apply_model_system_prompt_to_body(params, payload, user) + payload = apply_model_system_prompt_to_body(params, payload, metadata) # Check if user has access to the model if not bypass_filter and user.role == "user": diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 2cffd9ead..35cea6237 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -55,6 +55,7 @@ from open_webui.retrieval.web.serply import search_serply from open_webui.retrieval.web.serpstack import search_serpstack from open_webui.retrieval.web.tavily import search_tavily from open_webui.retrieval.web.bing import search_bing +from open_webui.retrieval.web.exa import search_exa from open_webui.retrieval.utils import ( @@ -388,6 +389,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "jina_api_key": request.app.state.config.JINA_API_KEY, "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + "exa_api_key": request.app.state.config.EXA_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, }, @@ -436,6 +438,7 @@ class WebSearchConfig(BaseModel): jina_api_key: Optional[str] = None bing_search_v7_endpoint: Optional[str] = None bing_search_v7_subscription_key: Optional[str] = None + exa_api_key: Optional[str] = None result_count: Optional[int] = None concurrent_requests: Optional[int] = None @@ -542,6 +545,8 @@ async def update_rag_config( form_data.web.search.bing_search_v7_subscription_key ) + request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = ( form_data.web.search.result_count ) @@ -591,6 +596,7 @@ async def update_rag_config( "jina_api_key": request.app.state.config.JINA_API_KEY, "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + "exa_api_key": request.app.state.config.EXA_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, }, @@ -1099,6 +1105,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: - SERPER_API_KEY - SERPLY_API_KEY - TAVILY_API_KEY + - EXA_API_KEY - SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`) Args: query (str): The query to search for @@ -1233,6 +1240,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, ) + elif engine == "exa": + return search_exa( + request.app.state.config.EXA_API_KEY, + query, + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + ) else: raise Exception("No search engine API key found in environment variables") diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 6d7343c8a..f56a0232d 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel from typing import Optional import logging +import re from open_webui.utils.chat import generate_chat_completion from open_webui.utils.task import ( @@ -89,6 +90,10 @@ async def update_task_config( form_data.TITLE_GENERATION_PROMPT_TEMPLATE ) + request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = ( + form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE + ) + request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ( form_data.ENABLE_AUTOCOMPLETE_GENERATION ) @@ -161,9 +166,20 @@ async def generate_title( else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE + messages = form_data["messages"] + + # Remove reasoning details from the messages + for message in messages: + message["content"] = re.sub( + r"]*>.*?<\/details>", + "", + message["content"], + flags=re.S, + ).strip() + content = title_generation_template( template, - form_data["messages"], + messages, { "name": user.name, "location": user.info.get("location") if user.info else None, @@ -175,10 +191,10 @@ async def generate_title( "messages": [{"role": "user", "content": content}], "stream": False, **( - {"max_tokens": 50} + {"max_tokens": 1000} if models[task_model_id]["owned_by"] == "ollama" else { - "max_completion_tokens": 50, + "max_completion_tokens": 1000, } ), "metadata": { diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index b37ad4b39..ddcaef767 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -79,6 +79,7 @@ class ChatPermissions(BaseModel): class FeaturesPermissions(BaseModel): web_search: bool = True image_generation: bool = True + code_interpreter: bool = True class UserPermissions(BaseModel): diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 2d12f5803..3788139ea 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -325,7 +325,7 @@ def get_event_emitter(request_info): def get_event_call(request_info): - async def __event_call__(event_data): + async def __event_caller__(event_data): response = await sio.call( "chat-events", { @@ -337,7 +337,10 @@ def get_event_call(request_info): ) return response - return __event_call__ + return __event_caller__ + + +get_event_caller = get_event_call def get_user_id_from_session_pool(sid): diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 6b2329be1..f80680280 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1,13 +1,18 @@ import time import logging import sys +import os +import base64 import asyncio from aiocache import cached from typing import Any, Optional import random import json +import html import inspect +import re + from uuid import uuid4 from concurrent.futures import ThreadPoolExecutor @@ -52,8 +57,10 @@ from open_webui.utils.task import ( tools_function_calling_generation_template, ) from open_webui.utils.misc import ( + deep_update, get_message_list, add_or_update_system_message, + add_or_update_user_message, get_last_user_message, get_last_assistant_message, prepend_to_first_user_message_content, @@ -64,7 +71,11 @@ from open_webui.utils.plugin import load_function_module_by_id from open_webui.tasks import create_task -from open_webui.config import DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE +from open_webui.config import ( + CACHE_DIR, + DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + DEFAULT_CODE_INTERPRETER_PROMPT, +) from open_webui.env import ( SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, @@ -173,7 +184,7 @@ async def chat_completion_filter_functions_handler(request, body, model, extra_p async def chat_completion_tools_handler( - request: Request, body: dict, user: UserModel, models, extra_params: dict + request: Request, body: dict, user: UserModel, models, tools ) -> tuple[dict, dict]: async def get_content_from_response(response) -> Optional[str]: content = None @@ -208,35 +219,15 @@ async def chat_completion_tools_handler( "metadata": {"task": str(TASKS.FUNCTION_CALLING)}, } - # If tool_ids field is present, call the functions - metadata = body.get("metadata", {}) - - tool_ids = metadata.get("tool_ids", None) - log.debug(f"{tool_ids=}") - if not tool_ids: - return body, {} - - skip_files = False - sources = [] - task_model_id = get_task_model_id( body["model"], request.app.state.config.TASK_MODEL, request.app.state.config.TASK_MODEL_EXTERNAL, models, ) - tools = get_tools( - request, - tool_ids, - user, - { - **extra_params, - "__model__": models[task_model_id], - "__messages__": body["messages"], - "__files__": metadata.get("files", []), - }, - ) - log.info(f"{tools=}") + + skip_files = False + sources = [] specs = [tool["spec"] for tool in tools.values()] tools_specs = json.dumps(specs) @@ -270,60 +261,72 @@ async def chat_completion_tools_handler( result = json.loads(content) - tool_function_name = result.get("name", None) - if tool_function_name not in tools: - return body, {} + async def tool_call_handler(tool_call): + nonlocal skip_files - tool_function_params = result.get("parameters", {}) + log.debug(f"{tool_call=}") - try: - required_params = ( - tools[tool_function_name] - .get("spec", {}) - .get("parameters", {}) - .get("required", []) - ) - tool_function = tools[tool_function_name]["callable"] - tool_function_params = { - k: v - for k, v in tool_function_params.items() - if k in required_params - } - tool_output = await tool_function(**tool_function_params) + tool_function_name = tool_call.get("name", None) + if tool_function_name not in tools: + return body, {} - except Exception as e: - tool_output = str(e) + tool_function_params = tool_call.get("parameters", {}) - if isinstance(tool_output, str): - if tools[tool_function_name]["citation"]: - sources.append( - { - "source": { - "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" - }, - "document": [tool_output], - "metadata": [ - { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" - } - ], - } - ) - else: - sources.append( - { - "source": {}, - "document": [tool_output], - "metadata": [ - { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" - } - ], - } + try: + required_params = ( + tools[tool_function_name] + .get("spec", {}) + .get("parameters", {}) + .get("required", []) ) + tool_function = tools[tool_function_name]["callable"] + tool_function_params = { + k: v + for k, v in tool_function_params.items() + if k in required_params + } + tool_output = await tool_function(**tool_function_params) - if tools[tool_function_name]["file_handler"]: - skip_files = True + except Exception as e: + tool_output = str(e) + + if isinstance(tool_output, str): + if tools[tool_function_name]["citation"]: + sources.append( + { + "source": { + "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + }, + "document": [tool_output], + "metadata": [ + { + "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + } + ], + } + ) + else: + sources.append( + { + "source": {}, + "document": [tool_output], + "metadata": [ + { + "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + } + ], + } + ) + + if tools[tool_function_name]["file_handler"]: + skip_files = True + + # check if "tool_calls" in result + if result.get("tool_calls"): + for tool_call in result.get("tool_calls"): + await tool_call_handler(tool_call) + else: + await tool_call_handler(result) except Exception as e: log.exception(f"Error: {e}") @@ -401,7 +404,7 @@ async def chat_web_search_handler( }, } ) - return + return form_data searchQuery = queries[0] @@ -666,6 +669,9 @@ def apply_params_to_form_data(form_data, model): if "temperature" in params: form_data["temperature"] = params["temperature"] + if "max_tokens" in params: + form_data["max_tokens"] = params["max_tokens"] + if "top_p" in params: form_data["top_p"] = params["top_p"] @@ -679,6 +685,7 @@ def apply_params_to_form_data(form_data, model): async def process_chat_payload(request, form_data, metadata, user, model): + form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") @@ -701,6 +708,12 @@ async def process_chat_payload(request, form_data, metadata, user, model): # Initialize events to store additional event to be sent to the client # Initialize contexts and citation models = request.app.state.MODELS + task_model_id = get_task_model_id( + form_data["model"], + request.app.state.config.TASK_MODEL, + request.app.state.config.TASK_MODEL_EXTERNAL, + models, + ) events = [] sources = [] @@ -746,6 +759,8 @@ async def process_chat_payload(request, form_data, metadata, user, model): files.extend(knowledge_files) form_data["files"] = files + variables = form_data.pop("variables", None) + features = form_data.pop("features", None) if features: if "web_search" in features and features["web_search"]: @@ -758,6 +773,11 @@ async def process_chat_payload(request, form_data, metadata, user, model): request, form_data, extra_params, user ) + if "code_interpreter" in features and features["code_interpreter"]: + form_data["messages"] = add_or_update_user_message( + DEFAULT_CODE_INTERPRETER_PROMPT, form_data["messages"] + ) + try: form_data, flags = await chat_completion_filter_functions_handler( request, form_data, model, extra_params @@ -778,13 +798,41 @@ async def process_chat_payload(request, form_data, metadata, user, model): } form_data["metadata"] = metadata - try: - form_data, flags = await chat_completion_tools_handler( - request, form_data, user, models, extra_params + tool_ids = metadata.get("tool_ids", None) + log.debug(f"{tool_ids=}") + + if tool_ids: + # If tool_ids field is present, then get the tools + tools = get_tools( + request, + tool_ids, + user, + { + **extra_params, + "__model__": models[task_model_id], + "__messages__": form_data["messages"], + "__files__": metadata.get("files", []), + }, ) - sources.extend(flags.get("sources", [])) - except Exception as e: - log.exception(e) + log.info(f"{tools=}") + + if metadata.get("function_calling") == "native": + # If the function calling is native, then call the tools function calling handler + metadata["tools"] = tools + form_data["tools"] = [ + {"type": "function", "function": tool.get("spec", {})} + for tool in tools.values() + ] + else: + # If the function calling is not native, then call the tools function calling handler + try: + form_data, flags = await chat_completion_tools_handler( + request, form_data, user, models, tools + ) + sources.extend(flags.get("sources", [])) + + except Exception as e: + log.exception(e) try: form_data, flags = await chat_completion_files_handler(request, form_data, user) @@ -800,11 +848,11 @@ async def process_chat_payload(request, form_data, metadata, user, model): if "document" in source: for doc_idx, doc_context in enumerate(source["document"]): - metadata = source.get("metadata") + doc_metadata = source.get("metadata") doc_source_id = None - if metadata: - doc_source_id = metadata[doc_idx].get("source", source_id) + if doc_metadata: + doc_source_id = doc_metadata[doc_idx].get("source", source_id) if source_id: context_string += f"{doc_source_id if doc_source_id is not None else source_id}{doc_context}\n" @@ -861,7 +909,7 @@ async def process_chat_payload(request, form_data, metadata, user, model): } ) - return form_data, events + return form_data, metadata, events async def process_chat_response( @@ -874,7 +922,7 @@ async def process_chat_response( if message: messages = get_message_list(message_map, message.get("id")) - if tasks: + if tasks and messages: if TASKS.TITLE_GENERATION in tasks: if tasks[TASKS.TITLE_GENERATION]: res = await generate_title( @@ -889,16 +937,24 @@ async def process_chat_response( if res and isinstance(res, dict): if len(res.get("choices", [])) == 1: - title = ( + title_string = ( res.get("choices", [])[0] .get("message", {}) - .get( - "content", - message.get("content", "New Chat"), - ) - ).strip() + .get("content", message.get("content", "New Chat")) + ) else: - title = None + title_string = "" + + title_string = title_string[ + title_string.find("{") : title_string.rfind("}") + 1 + ] + + try: + title = json.loads(title_string).get( + "title", "New Chat" + ) + except Exception as e: + title = "" if not title: title = messages[0].get("content", "New Chat") @@ -964,6 +1020,7 @@ async def process_chat_response( pass event_emitter = None + event_caller = None if ( "session_id" in metadata and metadata["session_id"] @@ -973,10 +1030,11 @@ async def process_chat_response( and metadata["message_id"] ): event_emitter = get_event_emitter(metadata) + event_caller = get_event_call(metadata) + # Non-streaming response if not isinstance(response, StreamingResponse): if event_emitter: - if "selected_model_id" in response: Chats.upsert_message_to_chat_by_id_and_message_id( metadata["chat_id"], @@ -1041,22 +1099,205 @@ async def process_chat_response( else: return response + # Non standard response if not any( content_type in response.headers["Content-Type"] for content_type in ["text/event-stream", "application/x-ndjson"] ): return response - if event_emitter: - + # Streaming response + if event_emitter and event_caller: task_id = str(uuid4()) # Create a unique task ID. + model_id = form_data.get("model", "") + + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "model": model_id, + }, + ) # Handle as a background task async def post_response_handler(response, events): + def serialize_content_blocks(content_blocks, raw=False): + content = "" + + for block in content_blocks: + if block["type"] == "text": + content = f"{content}{block['content'].strip()}\n" + elif block["type"] == "tool_calls": + attributes = block.get("attributes", {}) + + block_content = block.get("content", []) + results = block.get("results", []) + + if results: + + result_display_content = "" + + for result in results: + tool_call_id = result.get("tool_call_id", "") + tool_name = "" + + for tool_call in block_content: + if tool_call.get("id", "") == tool_call_id: + tool_name = tool_call.get("function", {}).get( + "name", "" + ) + break + + result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}" + + if not raw: + content = f'{content}\n
\nTool Executed\n{result_display_content}\n
\n' + else: + if not raw: + content = f'{content}\n
\nTool Executing...\n
\n' + + elif block["type"] == "reasoning": + reasoning_display_content = "\n".join( + (f"> {line}" if not line.startswith(">") else line) + for line in block["content"].splitlines() + ) + + reasoning_duration = block.get("duration", None) + + if reasoning_duration: + if raw: + content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + else: + content = f'{content}\n
\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n
\n' + else: + if raw: + content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + else: + content = f'{content}\n
\nThinking…\n{reasoning_display_content}\n
\n' + + elif block["type"] == "code_interpreter": + attributes = block.get("attributes", {}) + output = block.get("output", None) + lang = attributes.get("lang", "") + + if output: + output = html.escape(json.dumps(output)) + + if raw: + content = f'{content}\n\n{block["content"]}\n\n```output\n{output}\n```\n' + else: + content = f'{content}\n
\nAnalyzed\n```{lang}\n{block["content"]}\n```\n
\n' + else: + if raw: + content = f'{content}\n\n{block["content"]}\n\n' + else: + content = f'{content}\n
\nAnalyzing...\n```{lang}\n{block["content"]}\n```\n
\n' + + else: + block_content = str(block["content"]).strip() + content = f"{content}{block['type']}: {block_content}\n" + + return content + + def tag_content_handler(content_type, tags, content, content_blocks): + end_flag = False + + def extract_attributes(tag_content): + """Extract attributes from a tag if they exist.""" + attributes = {} + # Match attributes in the format: key="value" (ignores single quotes for simplicity) + matches = re.findall(r'(\w+)\s*=\s*"([^"]+)"', tag_content) + for key, value in matches: + attributes[key] = value + return attributes + + if content_blocks[-1]["type"] == "text": + for tag in tags: + # Match start tag e.g., or + start_tag_pattern = rf"<{tag}(.*?)>" + match = re.search(start_tag_pattern, content) + if match: + # Extract attributes in the tag (if present) + attributes = extract_attributes(match.group(1)) + # Remove the start tag from the currently handling text block + content_blocks[-1]["content"] = content_blocks[-1][ + "content" + ].replace(match.group(0), "") + if not content_blocks[-1]["content"]: + content_blocks.pop() + # Append the new block + content_blocks.append( + { + "type": content_type, + "tag": tag, + "attributes": attributes, + "content": "", + "started_at": time.time(), + } + ) + break + elif content_blocks[-1]["type"] == content_type: + tag = content_blocks[-1]["tag"] + # Match end tag e.g., + end_tag_pattern = rf"" + if re.search(end_tag_pattern, content): + block_content = content_blocks[-1]["content"] + # Strip start and end tags from the content + start_tag_pattern = rf"<{tag}(.*?)>" + block_content = re.sub( + start_tag_pattern, "", block_content + ).strip() + block_content = re.sub( + end_tag_pattern, "", block_content + ).strip() + if block_content: + end_flag = True + content_blocks[-1]["content"] = block_content + content_blocks[-1]["ended_at"] = time.time() + content_blocks[-1]["duration"] = int( + content_blocks[-1]["ended_at"] + - content_blocks[-1]["started_at"] + ) + # Reset the content_blocks by appending a new text block + content_blocks.append( + { + "type": "text", + "content": "", + } + ) + # Clean processed content + content = re.sub( + rf"<{tag}(.*?)>(.|\n)*?", + "", + content, + flags=re.DOTALL, + ) + else: + # Remove the block if content is empty + content_blocks.pop() + return content, content_blocks, end_flag + message = Chats.get_message_by_id_and_message_id( metadata["chat_id"], metadata["message_id"] ) + + tool_calls = [] content = message.get("content", "") if message else "" + content_blocks = [ + { + "type": "text", + "content": content, + } + ] + + # We might want to disable this by default + DETECT_REASONING = True + DETECT_CODE_INTERPRETER = metadata.get("features", {}).get( + "code_interpreter", False + ) + + reasoning_tags = ["think", "reason", "reasoning", "thought", "Thought"] + code_interpreter_tags = ["code_interpreter"] try: for event in events: @@ -1076,148 +1317,391 @@ async def process_chat_response( }, ) - # We might want to disable this by default - detect_reasoning = True - reasoning_tags = ["think", "reason", "reasoning", "thought", "Thought"] - current_tag = None + async def stream_body_handler(response): + nonlocal content + nonlocal content_blocks - reasoning_start_time = None + response_tool_calls = [] - reasoning_content = "" - ongoing_content = "" + async for line in response.body_iterator: + line = line.decode("utf-8") if isinstance(line, bytes) else line + data = line - async for line in response.body_iterator: - line = line.decode("utf-8") if isinstance(line, bytes) else line - data = line + # Skip empty lines + if not data.strip(): + continue - # Skip empty lines - if not data.strip(): - continue + # "data:" is the prefix for each event + if not data.startswith("data:"): + continue - # "data:" is the prefix for each event - if not data.startswith("data:"): - continue + # Remove the prefix + data = data[len("data:") :].strip() - # Remove the prefix - data = data[len("data:") :].strip() + try: + data = json.loads(data) - try: - data = json.loads(data) + if "selected_model_id" in data: + model_id = data["selected_model_id"] + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "selectedModelId": model_id, + }, + ) + else: + choices = data.get("choices", []) + if not choices: + continue - if "selected_model_id" in data: - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "selectedModelId": data["selected_model_id"], - }, - ) - else: - value = ( - data.get("choices", [])[0] - .get("delta", {}) - .get("content") - ) + delta = choices[0].get("delta", {}) + delta_tool_calls = delta.get("tool_calls", None) - if value: - content = f"{content}{value}" + if delta_tool_calls: + for delta_tool_call in delta_tool_calls: + tool_call_index = delta_tool_call.get("index") - if detect_reasoning: - for tag in reasoning_tags: - start_tag = f"<{tag}>\n" - end_tag = f"\n" + if tool_call_index is not None: + if ( + len(response_tool_calls) + <= tool_call_index + ): + response_tool_calls.append( + delta_tool_call + ) + else: + delta_name = delta_tool_call.get( + "function", {} + ).get("name") + delta_arguments = delta_tool_call.get( + "function", {} + ).get("arguments") - if start_tag in content: - # Remove the start tag - content = content.replace(start_tag, "") - ongoing_content = content + if delta_name: + response_tool_calls[ + tool_call_index + ]["function"]["name"] += delta_name - reasoning_start_time = time.time() - reasoning_content = "" + if delta_arguments: + response_tool_calls[ + tool_call_index + ]["function"][ + "arguments" + ] += delta_arguments - current_tag = tag + value = delta.get("content") + + if value: + content = f"{content}{value}" + content_blocks[-1]["content"] = ( + content_blocks[-1]["content"] + value + ) + + if DETECT_REASONING: + content, content_blocks, _ = ( + tag_content_handler( + "reasoning", + reasoning_tags, + content, + content_blocks, + ) + ) + + if DETECT_CODE_INTERPRETER: + content, content_blocks, end = ( + tag_content_handler( + "code_interpreter", + code_interpreter_tags, + content, + content_blocks, + ) + ) + + if end: break - if reasoning_start_time is not None: - # Remove the last value from the content - content = content[: -len(value)] + if ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "content": serialize_content_blocks( + content_blocks + ), + }, + ) + else: + data = { + "content": serialize_content_blocks( + content_blocks + ), + } - reasoning_content += value + await event_emitter( + { + "type": "chat:completion", + "data": data, + } + ) + except Exception as e: + done = "data: [DONE]" in line + if done: + pass + else: + log.debug("Error: ", e) + continue - end_tag = f"\n" - if end_tag in reasoning_content: - reasoning_end_time = time.time() - reasoning_duration = int( - reasoning_end_time - - reasoning_start_time - ) - reasoning_content = ( - reasoning_content.strip( - f"<{current_tag}>\n" - ) - .strip(end_tag) - .strip() - ) + # Clean up the last text block + if content_blocks[-1]["type"] == "text": + content_blocks[-1]["content"] = content_blocks[-1][ + "content" + ].strip() - if reasoning_content: - reasoning_display_content = "\n".join( - ( - f"> {line}" - if not line.startswith(">") - else line - ) - for line in reasoning_content.splitlines() - ) + if not content_blocks[-1]["content"]: + content_blocks.pop() - # Format reasoning with
tag - content = f'{ongoing_content}
\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n
\n' - else: - content = "" + if response_tool_calls: + tool_calls.append(response_tool_calls) - reasoning_start_time = None - else: + if response.background: + await response.background() - reasoning_display_content = "\n".join( - ( - f"> {line}" - if not line.startswith(">") - else line - ) - for line in reasoning_content.splitlines() - ) + await stream_body_handler(response) - # Show ongoing thought process - content = f'{ongoing_content}
\nThinking…\n{reasoning_display_content}\n
\n' + MAX_TOOL_CALL_RETRIES = 5 + tool_call_retries = 0 - if ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES: + tool_call_retries += 1 + + response_tool_calls = tool_calls.pop(0) + + content_blocks.append( + { + "type": "tool_calls", + "content": response_tool_calls, + } + ) + + await event_emitter( + { + "type": "chat:completion", + "data": { + "content": serialize_content_blocks(content_blocks), + }, + } + ) + + tools = metadata.get("tools", {}) + + results = [] + for tool_call in response_tool_calls: + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("function", {}).get("name", "") + + tool_function_params = {} + try: + tool_function_params = json.loads( + tool_call.get("function", {}).get("arguments", "{}") + ) + except Exception as e: + log.debug(e) + + tool_result = None + + if tool_name in tools: + tool = tools[tool_name] + spec = tool.get("spec", {}) + + try: + required_params = spec.get("parameters", {}).get( + "required", [] + ) + tool_function = tool["callable"] + tool_function_params = { + k: v + for k, v in tool_function_params.items() + if k in required_params + } + tool_result = await tool_function( + **tool_function_params + ) + except Exception as e: + tool_result = str(e) + + results.append( + { + "tool_call_id": tool_call_id, + "content": tool_result, + } + ) + + content_blocks[-1]["results"] = results + + content_blocks.append( + { + "type": "text", + "content": "", + } + ) + + await event_emitter( + { + "type": "chat:completion", + "data": { + "content": serialize_content_blocks(content_blocks), + }, + } + ) + + try: + res = await generate_chat_completion( + request, + { + "model": model_id, + "stream": True, + "messages": [ + *form_data["messages"], + { + "role": "assistant", + "content": serialize_content_blocks( + content_blocks, raw=True + ), + "tool_calls": response_tool_calls, + }, + *[ { - "content": content, + "role": "tool", + "tool_call_id": result["tool_call_id"], + "content": result["content"], + } + for result in results + ], + ], + }, + user, + ) + + if isinstance(res, StreamingResponse): + await stream_body_handler(res) + else: + break + except Exception as e: + log.debug(e) + break + + if DETECT_CODE_INTERPRETER: + MAX_RETRIES = 5 + retries = 0 + + while ( + content_blocks[-1]["type"] == "code_interpreter" + and retries < MAX_RETRIES + ): + retries += 1 + log.debug(f"Attempt count: {retries}") + + output = "" + try: + if content_blocks[-1]["attributes"].get("type") == "code": + output = await event_caller( + { + "type": "execute:python", + "data": { + "id": str(uuid4()), + "code": content_blocks[-1]["content"], }, - ) - else: - data = { - "content": content, } + ) + + if isinstance(output, dict): + stdout = output.get("stdout", "") + + if stdout: + stdoutLines = stdout.split("\n") + for idx, line in enumerate(stdoutLines): + if "data:image/png;base64" in line: + id = str(uuid4()) + + # ensure the path exists + os.makedirs( + os.path.join(CACHE_DIR, "images"), + exist_ok=True, + ) + + image_path = os.path.join( + CACHE_DIR, + f"images/{id}.png", + ) + + with open(image_path, "wb") as f: + f.write( + base64.b64decode( + line.split(",")[1] + ) + ) + + stdoutLines[idx] = ( + f"![Output Image {idx}](/cache/images/{id}.png)" + ) + + output["stdout"] = "\n".join(stdoutLines) + except Exception as e: + output = str(e) + + content_blocks[-1]["output"] = output + + content_blocks.append( + { + "type": "text", + "content": "", + } + ) await event_emitter( { "type": "chat:completion", - "data": data, + "data": { + "content": serialize_content_blocks(content_blocks), + }, } ) - except Exception as e: - done = "data: [DONE]" in line - if done: - pass - else: - continue + + try: + res = await generate_chat_completion( + request, + { + "model": model_id, + "stream": True, + "messages": [ + *form_data["messages"], + { + "role": "assistant", + "content": serialize_content_blocks( + content_blocks, raw=True + ), + }, + ], + }, + user, + ) + + if isinstance(res, StreamingResponse): + await stream_body_handler(res) + else: + break + except Exception as e: + log.debug(e) + break title = Chats.get_chat_title_by_id(metadata["chat_id"]) - data = {"done": True, "content": content, "title": title} + data = { + "done": True, + "content": serialize_content_blocks(content_blocks), + "title": title, + } if not ENABLE_REALTIME_CHAT_SAVE: # Save message in the database @@ -1225,7 +1709,7 @@ async def process_chat_response( metadata["chat_id"], metadata["message_id"], { - "content": content, + "content": serialize_content_blocks(content_blocks), }, ) @@ -1262,7 +1746,7 @@ async def process_chat_response( metadata["chat_id"], metadata["message_id"], { - "content": content, + "content": serialize_content_blocks(content_blocks), }, ) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 8792b1cfc..b07393921 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -7,6 +7,18 @@ from pathlib import Path from typing import Callable, Optional +import collections.abc + + +def deep_update(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = deep_update(d.get(k, {}), v) + else: + d[k] = v + return d + + def get_message_list(messages, message_id): """ Reconstructs a list of messages in order up to the specified message_id. @@ -20,7 +32,7 @@ def get_message_list(messages, message_id): current_message = messages.get(message_id) if not current_message: - return f"Message ID {message_id} not found in the history." + return None # Reconstruct the chain by following the parentId links message_list = [] @@ -131,6 +143,44 @@ def add_or_update_system_message(content: str, messages: list[dict]): return messages +def add_or_update_user_message(content: str, messages: list[dict]): + """ + Adds a new user message at the end of the messages list + or updates the existing user message at the end. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[-1].get("role") == "user": + messages[-1]["content"] = f"{messages[-1]['content']}\n{content}" + else: + # Insert at the end + messages.append({"role": "user", "content": content}) + + return messages + + +def append_or_update_assistant_message(content: str, messages: list[dict]): + """ + Adds a new assistant message at the end of the messages list + or updates the existing assistant message at the end. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[-1].get("role") == "assistant": + messages[-1]["content"] = f"{messages[-1]['content']}\n{content}" + else: + # Insert at the end + messages.append({"role": "assistant", "content": content}) + + return messages + + def openai_chat_message_template(model: str): return { "id": f"{model}-{str(uuid.uuid4())}", @@ -141,15 +191,25 @@ def openai_chat_message_template(model: str): def openai_chat_chunk_message_template( - model: str, message: Optional[str] = None, usage: Optional[dict] = None + model: str, + content: Optional[str] = None, + tool_calls: Optional[list[dict]] = None, + usage: Optional[dict] = None, ) -> dict: template = openai_chat_message_template(model) template["object"] = "chat.completion.chunk" - if message: - template["choices"][0]["delta"] = {"content": message} - else: + + template["choices"][0]["index"] = 0 + template["choices"][0]["delta"] = {} + + if content: + template["choices"][0]["delta"]["content"] = content + + if tool_calls: + template["choices"][0]["delta"]["tool_calls"] = tool_calls + + if not content and not tool_calls: template["choices"][0]["finish_reason"] = "stop" - template["choices"][0]["delta"] = {} if usage: template["usage"] = usage diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index b336f0631..7c0c53c2d 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -82,7 +82,8 @@ class OAuthManager: oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES oauth_roles = None - role = "pending" # Default/fallback role if no matching roles are found + # Default/fallback role if no matching roles are found + role = auth_manager_config.DEFAULT_USER_ROLE # Next block extracts the roles from the user data, accepting nested claims of any depth if oauth_claim and oauth_allowed_roles and oauth_admin_roles: @@ -273,11 +274,16 @@ class OAuthManager: log.error( f"Error downloading profile image '{picture_url}': {e}" ) - picture_url = "" + picture_url = "/user.png" if not picture_url: picture_url = "/user.png" + username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM + name = user_data.get(username_claim) + if not isinstance(user, str): + name = email + role = self.get_user_role(None, user_data) user = Auths.insert_new_auth( @@ -285,7 +291,7 @@ class OAuthManager: password=get_password_hash( str(uuid.uuid4()) ), # Random password, not used - name=user_data.get(username_claim, "User"), + name=name, profile_image_url=picture_url, role=role, oauth_sub=provider_sub, diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 13f98ee01..86f67c41d 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -1,4 +1,4 @@ -from open_webui.utils.task import prompt_template +from open_webui.utils.task import prompt_variables_template from open_webui.utils.misc import ( add_or_update_system_message, ) @@ -7,19 +7,18 @@ from typing import Callable, Optional # inplace function: form_data is modified -def apply_model_system_prompt_to_body(params: dict, form_data: dict, user) -> dict: +def apply_model_system_prompt_to_body( + params: dict, form_data: dict, metadata: Optional[dict] = None +) -> dict: system = params.get("system", None) if not system: return form_data - if user: - template_params = { - "user_name": user.name, - "user_location": user.info.get("location") if user.info else None, - } - else: - template_params = {} - system = prompt_template(system, **template_params) + if metadata: + print("apply_model_system_prompt_to_body: metadata", metadata) + variables = metadata.get("variables", {}) + system = prompt_variables_template(system, variables) + form_data["messages"] = add_or_update_system_message( system, form_data.get("messages", []) ) @@ -155,6 +154,9 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: ) ollama_payload["stream"] = openai_payload.get("stream", False) + if "tools" in openai_payload: + ollama_payload["tools"] = openai_payload["tools"] + if "format" in openai_payload: ollama_payload["format"] = openai_payload["format"] @@ -188,4 +190,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: if ollama_options: ollama_payload["options"] = ollama_options + if "metadata" in openai_payload: + ollama_payload["metadata"] = openai_payload["metadata"] + return ollama_payload diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 17b86cea1..d6e24d6b9 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -167,9 +167,14 @@ def load_function_module_by_id(function_id, content=None): def install_frontmatter_requirements(requirements): if requirements: - req_list = [req.strip() for req in requirements.split(",")] - for req in req_list: - log.info(f"Installing requirement: {req}") - subprocess.check_call([sys.executable, "-m", "pip", "install", req]) + try: + req_list = [req.strip() for req in requirements.split(",")] + for req in req_list: + log.info(f"Installing requirement: {req}") + subprocess.check_call([sys.executable, "-m", "pip", "install", req]) + except Exception as e: + log.error(f"Error installing package: {req}") + raise e + else: log.info("No requirements found in frontmatter.") diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index d6f7b0ac6..b16805bf3 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -1,4 +1,5 @@ import json +from uuid import uuid4 from open_webui.utils.misc import ( openai_chat_chunk_message_template, openai_chat_completion_message_template, @@ -9,7 +10,48 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict: model = ollama_response.get("model", "ollama") message_content = ollama_response.get("message", {}).get("content", "") - response = openai_chat_completion_message_template(model, message_content) + data = ollama_response + usage = { + "response_token/s": ( + round( + ( + ( + data.get("eval_count", 0) + / ((data.get("eval_duration", 0) / 10_000_000)) + ) + * 100 + ), + 2, + ) + if data.get("eval_duration", 0) > 0 + else "N/A" + ), + "prompt_token/s": ( + round( + ( + ( + data.get("prompt_eval_count", 0) + / ((data.get("prompt_eval_duration", 0) / 10_000_000)) + ) + * 100 + ), + 2, + ) + if data.get("prompt_eval_duration", 0) > 0 + else "N/A" + ), + "total_duration": data.get("total_duration", 0), + "load_duration": data.get("load_duration", 0), + "prompt_eval_count": data.get("prompt_eval_count", 0), + "prompt_eval_duration": data.get("prompt_eval_duration", 0), + "eval_count": data.get("eval_count", 0), + "eval_duration": data.get("eval_duration", 0), + "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( + (data.get("total_duration", 0) or 0) // 1_000_000_000 + ), + } + + response = openai_chat_completion_message_template(model, message_content, usage) return response @@ -19,6 +61,23 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) model = data.get("model", "ollama") message_content = data.get("message", {}).get("content", "") + tool_calls = data.get("message", {}).get("tool_calls", None) + openai_tool_calls = None + + if tool_calls: + openai_tool_calls = [] + for tool_call in tool_calls: + openai_tool_call = { + "index": tool_call.get("index", 0), + "id": tool_call.get("id", f"call_{str(uuid4())}"), + "type": "function", + "function": { + "name": tool_call.get("function", {}).get("name", ""), + "arguments": f"{tool_call.get('function', {}).get('arguments', {})}", + }, + } + openai_tool_calls.append(openai_tool_call) + done = data.get("done", False) usage = None @@ -64,7 +123,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) } data = openai_chat_chunk_message_template( - model, message_content if not done else None, usage + model, message_content if not done else None, openai_tool_calls, usage ) line = f"data: {json.dumps(data)}\n\n" diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index f5ba75ebe..3d8c05d45 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -32,6 +32,12 @@ def get_task_model_id( return task_model_id +def prompt_variables_template(template: str, variables: dict[str, str]) -> str: + for variable, value in variables.items(): + template = template.replace(variable, value) + return template + + def prompt_template( template: str, user_name: Optional[str] = None, user_location: Optional[str] = None ) -> str: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index b6e13011d..c44c30402 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -61,6 +61,12 @@ def get_tools( ) for spec in tools.specs: + # TODO: Fix hack for OpenAI API + # Some times breaks OpenAI but others don't. Leaving the comment + for val in spec.get("parameters", {}).get("properties", {}).values(): + if val["type"] == "str": + val["type"] = "string" + # Remove internal parameters spec["parameters"]["properties"] = { key: val @@ -73,6 +79,13 @@ def get_tools( # convert to function that takes only model params and inserts custom params original_func = getattr(module, function_name) callable = apply_extra_params_to_tool_function(original_func, extra_params) + + if callable.__doc__ and callable.__doc__.strip() != "": + s = re.split(":(param|return)", callable.__doc__, 1) + spec["description"] = s[0] + else: + spec["description"] = function_name + # TODO: This needs to be a pydantic model tool_dict = { "toolkit_id": tool_id, diff --git a/backend/requirements.txt b/backend/requirements.txt index eecb9c4a5..14ad4b9cd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.111.0 +fastapi==0.115.7 uvicorn[standard]==0.30.6 pydantic==2.9.2 python-multipart==0.0.18 @@ -11,7 +11,7 @@ python-jose==3.3.0 passlib[bcrypt]==1.7.4 requests==2.32.3 -aiohttp==3.11.8 +aiohttp==3.11.11 async-timeout aiocache aiofiles @@ -57,10 +57,10 @@ einops==0.8.0 ftfy==6.2.3 pypdf==4.3.1 fpdf2==2.8.2 -pymdown-extensions==10.11.2 +pymdown-extensions==10.14.2 docx2txt==0.8 python-pptx==1.0.0 -unstructured==0.15.9 +unstructured==0.16.11 nltk==3.9.1 Markdown==3.7 pypandoc==1.13 @@ -71,16 +71,16 @@ xlrd==2.0.1 validators==0.34.0 psutil sentencepiece -soundfile==0.12.1 +soundfile==0.13.1 -opencv-python-headless==4.10.0.84 +opencv-python-headless==4.11.0.86 rapidocr-onnxruntime==1.3.24 rank-bm25==0.2.2 faster-whisper==1.0.3 PyJWT[crypto]==2.10.1 -authlib==1.3.2 +authlib==1.4.1 black==24.8.0 langfuse==2.44.0 @@ -89,7 +89,7 @@ pytube==15.0.0 extract_msg pydub -duckduckgo-search~=7.2.1 +duckduckgo-search~=7.3.0 ## Google Drive google-api-python-client diff --git a/package-lock.json b/package-lock.json index c98e814d9..57bcf2906 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", - "pyodide": "^0.26.1", + "pyodide": "^0.27.2", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", @@ -9366,9 +9366,10 @@ } }, "node_modules/pyodide": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.26.1.tgz", - "integrity": "sha512-P+Gm88nwZqY7uBgjbQH8CqqU6Ei/rDn7pS1t02sNZsbyLJMyE2OVXjgNuqVT3KqYWnyGREUN0DbBUCJqk8R0ew==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.2.tgz", + "integrity": "sha512-sfA2kiUuQVRpWI4BYnU3sX5PaTTt/xrcVEmRzRcId8DzZXGGtPgCBC0gCqjUTUYSa8ofPaSjXmzESc86yvvCHg==", + "license": "Apache-2.0", "dependencies": { "ws": "^8.5.0" }, diff --git a/package.json b/package.json index a2463d9e3..a28091668 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", - "pyodide": "^0.26.1", + "pyodide": "^0.27.2", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", diff --git a/pyproject.toml b/pyproject.toml index edd01db8f..f121089e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.111.0", + "fastapi==0.115.7", "uvicorn[standard]==0.30.6", "pydantic==2.9.2", "python-multipart==0.0.18", @@ -19,7 +19,7 @@ dependencies = [ "passlib[bcrypt]==1.7.4", "requests==2.32.3", - "aiohttp==3.11.8", + "aiohttp==3.11.11", "async-timeout", "aiocache", "aiofiles", @@ -62,10 +62,10 @@ dependencies = [ "ftfy==6.2.3", "pypdf==4.3.1", "fpdf2==2.8.2", - "pymdown-extensions==10.11.2", + "pymdown-extensions==10.14.2", "docx2txt==0.8", "python-pptx==1.0.0", - "unstructured==0.15.9", + "unstructured==0.16.11", "nltk==3.9.1", "Markdown==3.7", "pypandoc==1.13", @@ -76,16 +76,16 @@ dependencies = [ "validators==0.34.0", "psutil", "sentencepiece", - "soundfile==0.12.1", + "soundfile==0.13.1", - "opencv-python-headless==4.10.0.84", + "opencv-python-headless==4.11.0.86", "rapidocr-onnxruntime==1.3.24", "rank-bm25==0.2.2", "faster-whisper==1.0.3", "PyJWT[crypto]==2.10.1", - "authlib==1.3.2", + "authlib==1.4.1", "black==24.8.0", "langfuse==2.44.0", @@ -94,7 +94,7 @@ dependencies = [ "extract_msg", "pydub", - "duckduckgo-search~=7.2.1", + "duckduckgo-search~=7.3.0", "google-api-python-client", "google-auth-httplib2", diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 5aaac5927..71f2a2cb2 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -9,7 +9,10 @@ const packages = [ 'scikit-learn', 'scipy', 'regex', - 'seaborn' + 'sympy', + 'tiktoken', + 'seaborn', + 'pytz' ]; import { loadPyodide } from 'pyodide'; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 1772529d3..7af504cc7 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -580,7 +580,7 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => { return res; }; -export const cloneChatById = async (token: string, id: string) => { +export const cloneChatById = async (token: string, id: string, title?: string) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone`, { @@ -589,7 +589,10 @@ export const cloneChatById = async (token: string, id: string) => { Accept: 'application/json', 'Content-Type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) - } + }, + body: JSON.stringify({ + ...(title && { title: title }) + }) }) .then(async (res) => { if (!res.ok) throw await res.json(); diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 22d3c6ba4..c7fd78819 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -880,13 +880,14 @@ export const getChangelog = async () => { return res; }; -export const getVersionUpdates = async () => { +export const getVersionUpdates = async (token: string) => { let error = null; const res = await fetch(`${WEBUI_BASE_URL}/api/version/updates`, { method: 'GET', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` } }) .then(async (res) => { diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 5880874bb..9cf625d03 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -219,7 +219,7 @@ export const deleteModelById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = err.detail; console.log(err); return null; diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index 5ddfbe935..a801bcdbb 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -322,7 +322,7 @@ export const generateOpenAIChatCompletion = async ( return res.json(); }) .catch((err) => { - error = `${err?.detail ?? 'Network Problem'}`; + error = `${err?.detail ?? err}`; return null; }); diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index a7a030027..f3b7e982c 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -51,7 +51,7 @@ models = []; } else { const res = await _getModels(localStorage.token).catch((e) => { - toast.error(e); + toast.error(`${e}`); }); if (res) { @@ -74,7 +74,7 @@ }, 100); } else { const res = await _getVoices(localStorage.token).catch((e) => { - toast.error(e); + toast.error(`${e}`); }); if (res) { diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 055acbf80..332d02c5a 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -31,7 +31,8 @@ ENABLE_TAGS_GENERATION: true, ENABLE_SEARCH_QUERY_GENERATION: true, ENABLE_RETRIEVAL_QUERY_GENERATION: true, - QUERY_GENERATION_PROMPT_TEMPLATE: '' + QUERY_GENERATION_PROMPT_TEMPLATE: '', + TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: '' }; let promptSuggestions = []; @@ -251,6 +252,20 @@ +
+
{$i18n.t('Tools Function Calling Prompt')}
+ + +