diff --git a/LICENSE_NOTICE b/LICENSE_NOTICE new file mode 100644 index 000000000..4e00d46d9 --- /dev/null +++ b/LICENSE_NOTICE @@ -0,0 +1,11 @@ +# Open WebUI Multi-License Notice + +This repository contains code governed by multiple licenses based on the date and origin of contribution: + +1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY). + +2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY). + +3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE). + +For details on which commits are covered by which license, refer to LICENSE_HISTORY. diff --git a/README.md b/README.md index 9b01496d9..49c0a8d9d 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http ## License 📜 -This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄 +This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files. ## Support 💬 diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index ca090efa2..2f5f34019 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]): class AppConfig: - _state: dict[str, PersistentConfig] _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None _redis_key_prefix: str + _state: dict[str, PersistentConfig] + def __init__( self, redis_url: Optional[str] = None, @@ -233,9 +234,8 @@ class AppConfig: redis_cluster: Optional[bool] = False, redis_key_prefix: str = "open-webui", ): - super().__setattr__("_state", {}) - super().__setattr__("_redis_key_prefix", redis_key_prefix) if redis_url: + super().__setattr__("_redis_key_prefix", redis_key_prefix) super().__setattr__( "_redis", get_redis_connection( @@ -246,6 +246,8 @@ class AppConfig: ), ) + super().__setattr__("_state", {}) + def __setattr__(self, key, value): if isinstance(value, PersistentConfig): self._state[key] = value @@ -2168,6 +2170,8 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( "onedrive.enable", os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", ) + + ENABLE_ONEDRIVE_PERSONAL = ( os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true" ) @@ -2175,10 +2179,12 @@ ENABLE_ONEDRIVE_BUSINESS = ( os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true" ) -ONEDRIVE_CLIENT_ID = PersistentConfig( - "ONEDRIVE_CLIENT_ID", - "onedrive.client_id", - os.environ.get("ONEDRIVE_CLIENT_ID", ""), +ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "") +ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get( + "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID +) +ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get( + "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID ) ONEDRIVE_SHAREPOINT_URL = PersistentConfig( diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index b4fdc97d8..243b8212a 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -547,16 +547,16 @@ else: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get( - "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10" + "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30" ) if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "": - CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 + CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 else: try: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES) except Exception: - CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 + CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 5630a5883..7ebb76cb5 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -301,7 +301,8 @@ from open_webui.config import ( GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_API_KEY, ENABLE_ONEDRIVE_INTEGRATION, - ONEDRIVE_CLIENT_ID, + ONEDRIVE_CLIENT_ID_PERSONAL, + ONEDRIVE_CLIENT_ID_BUSINESS, ONEDRIVE_SHAREPOINT_URL, ONEDRIVE_SHAREPOINT_TENANT_ID, ENABLE_ONEDRIVE_PERSONAL, @@ -1530,6 +1531,14 @@ async def chat_completion( except: pass + finally: + try: + if mcp_clients := metadata.get("mcp_clients"): + for client in mcp_clients: + await client.disconnect() + except Exception as e: + log.debug(f"Error cleaning up: {e}") + pass if ( metadata.get("session_id") @@ -1743,7 +1752,8 @@ async def get_app_config(request: Request): "api_key": GOOGLE_DRIVE_API_KEY.value, }, "onedrive": { - "client_id": ONEDRIVE_CLIENT_ID.value, + "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL, + "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS, "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, }, diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index aec8de684..65da1592e 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -127,7 +127,13 @@ def query_doc_with_hybrid_search( hybrid_bm25_weight: float, ) -> dict: try: - if not collection_result.documents[0]: + if ( + not collection_result + or not hasattr(collection_result, "documents") + or not collection_result.documents + or len(collection_result.documents) == 0 + or not collection_result.documents[0] + ): log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") return {"documents": [], "metadatas": [], "distances": []} diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 8ce4e0d24..31d7bce40 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,3 +1,4 @@ +import logging from fastapi import APIRouter, Depends, Request, HTTPException from pydantic import BaseModel, ConfigDict @@ -12,10 +13,16 @@ from open_webui.utils.tools import ( get_tool_server_url, set_tool_servers, ) +from open_webui.utils.mcp.client import MCPClient + +from open_webui.env import SRC_LOG_LEVELS router = APIRouter() +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + ############################ # ImportConfig @@ -87,6 +94,7 @@ async def set_connections_config( class ToolServerConnection(BaseModel): url: str path: str + type: Optional[str] = "openapi" # openapi, mcp auth_type: Optional[str] key: Optional[str] config: Optional[dict] @@ -129,19 +137,72 @@ async def verify_tool_servers_config( Verify the connection to the tool server. """ try: + if form_data.type == "mcp": + try: + client = MCPClient() + auth = None + headers = None - token = None - if form_data.auth_type == "bearer": - token = form_data.key - elif form_data.auth_type == "session": - token = request.state.token.credentials + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + elif form_data.auth_type == "system_oauth": + try: + if request.cookies.get("oauth_session_id", None): + token = ( + await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + ) + except Exception as e: + pass - url = get_tool_server_url(form_data.url, form_data.path) - return await get_tool_server_data(token, url) + if token: + headers = {"Authorization": f"Bearer {token}"} + + await client.connect(form_data.url, auth=auth, headers=headers) + specs = await client.list_tool_specs() + return { + "status": True, + "specs": specs, + } + except Exception as e: + log.debug(f"Failed to create MCP client: {e}") + raise HTTPException( + status_code=400, + detail=f"Failed to create MCP client", + ) + finally: + if client: + await client.disconnect() + else: # openapi + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + elif form_data.auth_type == "system_oauth": + try: + if request.cookies.get("oauth_session_id", None): + token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + pass + + url = get_tool_server_url(form_data.url, form_data.path) + return await get_tool_server_data(token, url) + except HTTPException as e: + raise e except Exception as e: + log.debug(f"Failed to connect to the tool server: {e}") raise HTTPException( status_code=400, - detail=f"Failed to connect to the tool server: {str(e)}", + detail=f"Failed to connect to the tool server", ) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 5f82e7f1b..71c7069fd 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -43,6 +43,7 @@ router = APIRouter() async def get_tools(request: Request, user=Depends(get_verified_user)): tools = Tools.get_tools() + # OpenAPI Tool Servers for server in await get_tool_servers(request): tools.append( ToolUserResponse( @@ -68,6 +69,29 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): ) ) + # MCP Tool Servers + for server in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if server.get("type", "openapi") == "mcp": + tools.append( + ToolUserResponse( + **{ + "id": f"server:mcp:{server.get('info', {}).get('id')}", + "user_id": f"server:mcp:{server.get('info', {}).get('id')}", + "name": server.get("info", {}).get("name", "MCP Tool Server"), + "meta": { + "description": server.get("info", {}).get( + "description", "" + ), + }, + "access_control": server.get("config", {}).get( + "access_control", None + ), + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + ) + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: # Admin can see all tools return tools diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py new file mode 100644 index 000000000..b410cbab5 --- /dev/null +++ b/backend/open_webui/utils/files.py @@ -0,0 +1,97 @@ +from open_webui.routers.images import ( + load_b64_image_data, + upload_image, +) + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + UploadFile, +) + +from open_webui.routers.files import upload_file_handler + +import mimetypes +import base64 +import io + + +def get_image_url_from_base64(request, base64_image_string, metadata, user): + if "data:image/png;base64" in base64_image_string: + image_url = "" + # Extract base64 image data from the line + image_data, content_type = load_b64_image_data(base64_image_string) + if image_data is not None: + image_url = upload_image( + request, + image_data, + content_type, + metadata, + user, + ) + return image_url + return None + + +def load_b64_audio_data(b64_str): + try: + if "," in b64_str: + header, b64_data = b64_str.split(",", 1) + else: + b64_data = b64_str + header = "data:audio/wav;base64" + audio_data = base64.b64decode(b64_data) + content_type = ( + header.split(";")[0].split(":")[1] if ";" in header else "audio/wav" + ) + return audio_data, content_type + except Exception as e: + print(f"Error decoding base64 audio data: {e}") + return None, None + + +def upload_audio(request, audio_data, content_type, metadata, user): + audio_format = mimetypes.guess_extension(content_type) + file = UploadFile( + file=io.BytesIO(audio_data), + filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file + headers={ + "content-type": content_type, + }, + ) + file_item = upload_file_handler( + request, + file=file, + metadata=metadata, + process=False, + user=user, + ) + url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) + return url + + +def get_audio_url_from_base64(request, base64_audio_string, metadata, user): + if "data:audio/wav;base64" in base64_audio_string: + audio_url = "" + # Extract base64 audio data from the line + audio_data, content_type = load_b64_audio_data(base64_audio_string) + if audio_data is not None: + audio_url = upload_audio( + request, + audio_data, + content_type, + metadata, + user, + ) + return audio_url + return None + + +def get_file_url_from_base64(request, base64_file_string, metadata, user): + if "data:image/png;base64" in base64_file_string: + return get_image_url_from_base64(request, base64_file_string, metadata, user) + elif "data:audio/wav;base64" in base64_file_string: + return get_audio_url_from_base64(request, base64_file_string, metadata, user) + return None diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py new file mode 100644 index 000000000..2d352ead2 --- /dev/null +++ b/backend/open_webui/utils/mcp/client.py @@ -0,0 +1,114 @@ +import asyncio +from typing import Optional +from contextlib import AsyncExitStack + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class MCPClient: + def __init__(self): + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + + async def connect( + self, url: str, headers: Optional[dict] = None, auth: Optional[any] = None + ): + try: + self._streams_context = streamablehttp_client( + url, headers=headers, auth=auth + ) + + transport = await self.exit_stack.enter_async_context(self._streams_context) + read_stream, write_stream, _ = transport + + self._session_context = ClientSession( + read_stream, write_stream + ) # pylint: disable=W0201 + + self.session = await self.exit_stack.enter_async_context( + self._session_context + ) + await self.session.initialize() + except Exception as e: + await self.disconnect() + raise e + + async def list_tool_specs(self) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.list_tools() + tools = result.tools + + tool_specs = [] + for tool in tools: + name = tool.name + description = tool.description + + inputSchema = tool.inputSchema + + # TODO: handle outputSchema if needed + outputSchema = getattr(tool, "outputSchema", None) + + tool_specs.append( + {"name": name, "description": description, "parameters": inputSchema} + ) + + return tool_specs + + async def call_tool( + self, function_name: str, function_args: dict + ) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.call_tool(function_name, function_args) + if not result: + raise Exception("No result returned from MCP tool call.") + + result_dict = result.model_dump(mode="json") + result_content = result_dict.get("content", {}) + + if result.isError: + raise Exception(result_content) + else: + return result_content + + async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.list_resources(cursor=cursor) + if not result: + raise Exception("No result returned from MCP list_resources call.") + + result_dict = result.model_dump() + resources = result_dict.get("resources", []) + + return resources + + async def read_resource(self, uri: str) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.read_resource(uri) + if not result: + raise Exception("No result returned from MCP read_resource call.") + result_dict = result.model_dump() + + return result_dict + + async def disconnect(self): + # Clean up and close the session + await self.exit_stack.aclose() + + async def __aenter__(self): + await self.exit_stack.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.exit_stack.__aexit__(exc_type, exc_value, traceback) + await self.disconnect() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 3cd7d3a6e..52c78f199 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -53,6 +53,11 @@ from open_webui.routers.pipelines import ( from open_webui.routers.memories import query_memory, QueryMemoryForm from open_webui.utils.webhook import post_webhook +from open_webui.utils.files import ( + get_audio_url_from_base64, + get_file_url_from_base64, + get_image_url_from_base64, +) from open_webui.models.users import UserModel @@ -87,6 +92,7 @@ from open_webui.utils.filter import ( ) from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.payload import apply_system_prompt_to_body +from open_webui.utils.mcp.client import MCPClient from open_webui.config import ( @@ -145,12 +151,14 @@ async def chat_completion_tools_handler( def get_tools_function_calling_payload(messages, task_model_id, content): user_message = get_last_user_message(messages) - history = "\n".join( + + recent_messages = messages[-4:] if len(messages) > 4 else messages + chat_history = "\n".join( f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\"" - for message in messages[::-1][:4] + for message in recent_messages ) - prompt = f"History:\n{history}\nQuery: {user_message}" + prompt = f"History:\n{chat_history}\nQuery: {user_message}" return { "model": task_model_id, @@ -988,14 +996,91 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Server side tools tool_ids = metadata.get("tool_ids", None) # Client side tools - tool_servers = metadata.get("tool_servers", None) + direct_tool_servers = metadata.get("tool_servers", None) log.debug(f"{tool_ids=}") - log.debug(f"{tool_servers=}") + log.debug(f"{direct_tool_servers=}") tools_dict = {} + mcp_clients = [] + mcp_tools_dict = {} + if tool_ids: + for tool_id in tool_ids: + if tool_id.startswith("server:mcp:"): + try: + server_id = tool_id[len("server:mcp:") :] + + mcp_server_connection = None + for ( + server_connection + ) in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if ( + server_connection.get("type", "") == "mcp" + and server_connection.get("info", {}).get("id") == server_id + ): + mcp_server_connection = server_connection + break + + if not mcp_server_connection: + log.error(f"MCP server with id {server_id} not found") + continue + + auth_type = mcp_server_connection.get("auth_type", "") + + headers = {} + if auth_type == "bearer": + headers["Authorization"] = ( + f"Bearer {mcp_server_connection.get('key', '')}" + ) + elif auth_type == "none": + # No authentication + pass + elif auth_type == "session": + headers["Authorization"] = ( + f"Bearer {request.state.token.credentials}" + ) + elif auth_type == "system_oauth": + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = ( + f"Bearer {oauth_token.get('access_token', '')}" + ) + + mcp_client = MCPClient() + await mcp_client.connect( + url=mcp_server_connection.get("url", ""), + headers=headers if headers else None, + ) + + tool_specs = await mcp_client.list_tool_specs() + for tool_spec in tool_specs: + + def make_tool_function(function_name): + async def tool_function(**kwargs): + return await mcp_client.call_tool( + function_name, + function_args=kwargs, + ) + + return tool_function + + tool_function = make_tool_function(tool_spec["name"]) + + mcp_tools_dict[tool_spec["name"]] = { + "spec": tool_spec, + "callable": tool_function, + "type": "mcp", + "client": mcp_client, + "direct": False, + } + + mcp_clients.append(mcp_client) + except Exception as e: + log.debug(e) + continue + tools_dict = await get_tools( request, tool_ids, @@ -1007,9 +1092,11 @@ async def process_chat_payload(request, form_data, user, metadata, model): "__files__": metadata.get("files", []), }, ) + if mcp_tools_dict: + tools_dict = {**tools_dict, **mcp_tools_dict} - if tool_servers: - for tool_server in tool_servers: + if direct_tool_servers: + for tool_server in direct_tool_servers: tool_specs = tool_server.pop("specs", []) for tool in tool_specs: @@ -1019,6 +1106,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): "server": tool_server, } + if mcp_clients: + metadata["mcp_clients"] = mcp_clients + if tools_dict: if metadata.get("params", {}).get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler @@ -1027,6 +1117,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): {"type": "function", "function": tool.get("spec", {})} for tool in tools_dict.values() ] + else: # If the function calling is not native, then call the tools function calling handler try: @@ -2330,6 +2421,8 @@ async def process_chat_response( results = [] for tool_call in response_tool_calls: + + print("tool_call", tool_call) tool_call_id = tool_call.get("id", "") tool_name = tool_call.get("function", {}).get("name", "") tool_args = tool_call.get("function", {}).get("arguments", "{}") @@ -2405,7 +2498,6 @@ async def process_chat_response( tool_result = str(e) tool_result_embeds = [] - if isinstance(tool_result, HTMLResponse): content_disposition = tool_result.headers.get( "Content-Disposition", "" @@ -2478,9 +2570,60 @@ async def process_chat_response( for item in tool_result: # check if string if isinstance(item, str) and item.startswith("data:"): - tool_result_files.append(item) + tool_result_files.append( + { + "type": "data", + "content": item, + } + ) tool_result.remove(item) + if tool.get("type") == "mcp": + if isinstance(item, dict): + if ( + item.get("type") == "image" + or item.get("type") == "audio" + ): + file_url = get_file_url_from_base64( + request, + f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}", + { + "chat_id": metadata.get( + "chat_id", None + ), + "message_id": metadata.get( + "message_id", None + ), + "session_id": metadata.get( + "session_id", None + ), + "result": item, + }, + user, + ) + + tool_result_files.append( + { + "type": item.get("type", "data"), + "url": file_url, + } + ) + tool_result.remove(item) + + if tool_result_files: + if not isinstance(tool_result, list): + tool_result = [ + tool_result, + ] + + for file in tool_result_files: + tool_result.append( + { + "type": file.get("type", "data"), + "content": "Result is being displayed as a file.", + } + ) + if isinstance(tool_result, dict) or isinstance( tool_result, list ): @@ -2647,23 +2790,18 @@ async def process_chat_response( if isinstance(stdout, str): stdoutLines = stdout.split("\n") for idx, line in enumerate(stdoutLines): + if "data:image/png;base64" in line: - image_url = "" - # Extract base64 image data from the line - image_data, content_type = ( - load_b64_image_data(line) + image_url = get_image_url_from_base64( + request, + line, + metadata, + user, ) - if image_data is not None: - image_url = upload_image( - request, - image_data, - content_type, - metadata, - user, + if image_url: + stdoutLines[idx] = ( + f"" ) - stdoutLines[idx] = ( - f"" - ) output["stdout"] = "\n".join(stdoutLines) @@ -2673,19 +2811,12 @@ async def process_chat_response( resultLines = result.split("\n") for idx, line in enumerate(resultLines): if "data:image/png;base64" in line: - image_url = "" - # Extract base64 image data from the line - image_data, content_type = ( - load_b64_image_data(line) + image_url = get_image_url_from_base64( + request, + line, + metadata, + user, ) - if image_data is not None: - image_url = upload_image( - request, - image_data, - content_type, - metadata, - user, - ) resultLines[idx] = ( f"" ) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 63df47700..cb3626146 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -96,94 +96,118 @@ async def get_tools( for tool_id in tool_ids: tool = Tools.get_tool_by_id(tool_id) if tool is None: + if tool_id.startswith("server:"): - server_id = tool_id.split(":")[1] + splits = tool_id.split(":") - tool_server_data = None - for server in await get_tool_servers(request): - if server["id"] == server_id: - tool_server_data = server - break + if len(splits) == 2: + type = "openapi" + server_id = splits[1] + elif len(splits) == 3: + type = splits[1] + server_id = splits[2] - if tool_server_data is None: - log.warning(f"Tool server data not found for {server_id}") + server_id_splits = server_id.split("|") + if len(server_id_splits) == 2: + server_id = server_id_splits[0] + function_names = server_id_splits[1].split(",") + + if type == "openapi": + + tool_server_data = None + for server in await get_tool_servers(request): + if server["id"] == server_id: + tool_server_data = server + break + + if tool_server_data is None: + log.warning(f"Tool server data not found for {server_id}") + continue + + tool_server_idx = tool_server_data.get("idx", 0) + tool_server_connection = ( + request.app.state.config.TOOL_SERVER_CONNECTIONS[ + tool_server_idx + ] + ) + + specs = tool_server_data.get("specs", []) + for spec in specs: + function_name = spec["name"] + + auth_type = tool_server_connection.get("auth_type", "bearer") + + cookies = {} + headers = {} + + if auth_type == "bearer": + headers["Authorization"] = ( + f"Bearer {tool_server_connection.get('key', '')}" + ) + elif auth_type == "none": + # No authentication + pass + elif auth_type == "session": + cookies = request.cookies + headers["Authorization"] = ( + f"Bearer {request.state.token.credentials}" + ) + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = ( + f"Bearer {oauth_token.get('access_token', '')}" + ) + + headers["Content-Type"] = "application/json" + + def make_tool_function( + function_name, tool_server_data, headers + ): + async def tool_function(**kwargs): + return await execute_tool_server( + url=tool_server_data["url"], + headers=headers, + cookies=cookies, + name=function_name, + params=kwargs, + server_data=tool_server_data, + ) + + return tool_function + + tool_function = make_tool_function( + function_name, tool_server_data, headers + ) + + callable = get_async_tool_function_and_apply_extra_params( + tool_function, + {}, + ) + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + # Misc info + "type": "external", + } + + # Handle function name collisions + while function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + # Prepend server ID to function name + function_name = f"{server_id}_{function_name}" + + tools_dict[function_name] = tool_dict + + else: + log.warning(f"Unsupported tool server type: {type}") continue - tool_server_idx = tool_server_data.get("idx", 0) - tool_server_connection = ( - request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx] - ) - - specs = tool_server_data.get("specs", []) - for spec in specs: - function_name = spec["name"] - - auth_type = tool_server_connection.get("auth_type", "bearer") - - cookies = {} - headers = {} - - if auth_type == "bearer": - headers["Authorization"] = ( - f"Bearer {tool_server_connection.get('key', '')}" - ) - elif auth_type == "none": - # No authentication - pass - elif auth_type == "session": - cookies = request.cookies - headers["Authorization"] = ( - f"Bearer {request.state.token.credentials}" - ) - elif auth_type == "system_oauth": - cookies = request.cookies - oauth_token = extra_params.get("__oauth_token__", None) - if oauth_token: - headers["Authorization"] = ( - f"Bearer {oauth_token.get('access_token', '')}" - ) - - headers["Content-Type"] = "application/json" - - def make_tool_function(function_name, tool_server_data, headers): - async def tool_function(**kwargs): - return await execute_tool_server( - url=tool_server_data["url"], - headers=headers, - cookies=cookies, - name=function_name, - params=kwargs, - server_data=tool_server_data, - ) - - return tool_function - - tool_function = make_tool_function( - function_name, tool_server_data, headers - ) - - callable = get_async_tool_function_and_apply_extra_params( - tool_function, - {}, - ) - - tool_dict = { - "tool_id": tool_id, - "callable": callable, - "spec": spec, - # Misc info - "type": "external", - } - - # Handle function name collisions - while function_name in tools_dict: - log.warning( - f"Tool {function_name} already exists in another tools!" - ) - # Prepend server ID to function name - function_name = f"{server_id}_{function_name}" - - tools_dict[function_name] = tool_dict else: continue else: @@ -579,7 +603,10 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, # Prepare list of enabled servers along with their original index server_entries = [] for idx, server in enumerate(servers): - if server.get("config", {}).get("enable"): + if ( + server.get("config", {}).get("enable") + and server.get("type", "openapi") == "openapi" + ): # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL openapi_path = server.get("path", "openapi.json") full_url = get_tool_server_url(server.get("url"), openapi_path) diff --git a/backend/requirements.txt b/backend/requirements.txt index 81fc0beb4..1b14ac142 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -46,6 +46,7 @@ anthropic google-genai==1.32.0 google-generativeai==0.8.5 tiktoken +mcp==1.14.1 langchain==0.3.26 langchain-community==0.3.27 @@ -121,7 +122,7 @@ pytest-docker~=3.1.1 googleapis-common-protos==1.70.0 google-cloud-storage==2.19.0 -azure-identity==1.23.0 +azure-identity==1.25.0 azure-storage-blob==12.24.1 diff --git a/package-lock.json b/package-lock.json index 9bc83bd7d..75c7d6e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^3.0.7", - "@tiptap/extension-drag-handle": "^3.0.7", + "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-highlight": "^3.3.0", @@ -3384,9 +3384,9 @@ } }, "node_modules/@tiptap/extension-collaboration": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz", - "integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz", + "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==", "license": "MIT", "peer": true, "funding": { @@ -3394,8 +3394,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7", + "@tiptap/core": "^3.4.5", + "@tiptap/pm": "^3.4.5", "@tiptap/y-tiptap": "^3.0.0-beta.3", "yjs": "^13" } @@ -3414,9 +3414,9 @@ } }, "node_modules/@tiptap/extension-drag-handle": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz", - "integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz", + "integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13" @@ -3426,10 +3426,10 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/extension-collaboration": "^3.0.7", - "@tiptap/extension-node-range": "^3.0.7", - "@tiptap/pm": "^3.0.7", + "@tiptap/core": "^3.4.5", + "@tiptap/extension-collaboration": "^3.4.5", + "@tiptap/extension-node-range": "^3.4.5", + "@tiptap/pm": "^3.4.5", "@tiptap/y-tiptap": "^3.0.0-beta.3" } }, @@ -3643,9 +3643,9 @@ } }, "node_modules/@tiptap/extension-node-range": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz", - "integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz", + "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==", "license": "MIT", "peer": true, "funding": { @@ -3653,8 +3653,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.4.5", + "@tiptap/pm": "^3.4.5" } }, "node_modules/@tiptap/extension-ordered-list": { diff --git a/package.json b/package.json index aa3c6d644..14ba04fa8 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^3.0.7", - "@tiptap/extension-drag-handle": "^3.0.7", + "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-highlight": "^3.3.0", diff --git a/pyproject.toml b/pyproject.toml index bed62ee4e..09fcce07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "asgiref==3.8.1", "tiktoken", + "mcp==1.14.1", + "openai", "anthropic", "google-genai==1.32.0", @@ -109,7 +111,7 @@ dependencies = [ "googleapis-common-protos==1.70.0", "google-cloud-storage==2.19.0", - "azure-identity==1.20.0", + "azure-identity==1.25.0", "azure-storage-blob==12.24.1", "ldap3==2.9.1", diff --git a/src/app.css b/src/app.css index f52b58355..e8f4ee137 100644 --- a/src/app.css +++ b/src/app.css @@ -661,3 +661,112 @@ body { background: #171717; color: #eee; } + +/* Position the handle relative to each LI */ +.pm-li--with-handle { + position: relative; + margin-left: 12px; /* make space for the handle */ +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + margin-left: 0px; +} + +/* The drag handle itself */ +.pm-list-drag-handle { + position: absolute; + left: -36px; /* pull into the left gutter */ + top: 1px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + border-radius: 4px; + cursor: grab; + user-select: none; + opacity: 0.35; + transition: + opacity 120ms ease, + background 120ms ease; +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + left: -16px; /* pull into the left gutter more to avoid the checkbox */ +} + +.pm-list-drag-handle:active { + cursor: grabbing; +} +.pm-li--with-handle:hover > .pm-list-drag-handle { + opacity: 1; +} +.pm-list-drag-handle:hover { + background: rgba(0, 0, 0, 0.06); +} + +:root { + --pm-accent: color-mix(in oklab, Highlight 70%, transparent); + --pm-fill-target: color-mix(in oklab, Highlight 26%, transparent); + --pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent); +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + position: relative; +} + +/* BEFORE/AFTER lines */ +.pm-li-drop-before::before, +.pm-li-drop-after::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 3px; + background: var(--pm-accent); + pointer-events: none; +} +.pm-li-drop-before::before { + top: -2px; +} +.pm-li-drop-after::after { + bottom: -2px; +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + background: var(--pm-fill-target); + border-radius: 6px; +} + +.pm-li-drop-outdent::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: 3px; + background: color-mix(in oklab, Highlight 35%, transparent); +} + +.pm-li--with-handle:has(.pm-li-drop-before), +.pm-li--with-handle:has(.pm-li-drop-after), +.pm-li--with-handle:has(.pm-li-drop-into), +.pm-li--with-handle:has(.pm-li-drop-outdent) { + background: var(--pm-fill-ancestor); + border-radius: 6px; +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + position: relative; + z-index: 0; +} diff --git a/src/lib/components/AddServerModal.svelte b/src/lib/components/AddToolServerModal.svelte similarity index 80% rename from src/lib/components/AddServerModal.svelte rename to src/lib/components/AddToolServerModal.svelte index 4a407b827..01c87010e 100644 --- a/src/lib/components/AddServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -30,6 +30,8 @@ let url = ''; let path = 'openapi.json'; + let type = 'openapi'; // 'openapi', 'mcp' + let auth_type = 'bearer'; let key = ''; @@ -70,6 +72,7 @@ const res = await verifyToolServerConnection(localStorage.token, { url, path, + type, auth_type, key, config: { @@ -97,10 +100,16 @@ // remove trailing slash from url url = url.replace(/\/$/, ''); + if (id.includes(':') || id.includes('|')) { + toast.error($i18n.t('ID cannot contain ":" or "|" characters')); + loading = false; + return; + } const connection = { url, path, + type, auth_type, key, config: { @@ -119,8 +128,11 @@ loading = false; show = false; + // reset form url = ''; path = 'openapi.json'; + type = 'openapi'; + key = ''; auth_type = 'bearer'; @@ -137,6 +149,7 @@ url = connection.url; path = connection?.path ?? 'openapi.json'; + type = connection?.type ?? 'openapi'; auth_type = connection?.auth_type ?? 'bearer'; key = connection?.key ?? ''; @@ -189,6 +202,50 @@ }} >