diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index da29f90105..fd20c0c41e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1914,6 +1914,24 @@ YOUCOM_API_KEY = ConfigVar( os.getenv('YOUCOM_API_KEY', ''), ) +LINKUP_API_KEY = ConfigVar( + 'LINKUP_API_KEY', + 'rag.web.search.linkup_api_key', + os.getenv('LINKUP_API_KEY', ''), +) + +linkup_search_params = os.getenv('LINKUP_SEARCH_PARAMS', '') +try: + linkup_search_params = json.loads(linkup_search_params) +except json.JSONDecodeError: + linkup_search_params = {} + +LINKUP_SEARCH_PARAMS = ConfigVar( + 'LINKUP_SEARCH_PARAMS', + 'rag.web.search.linkup_search_params', + linkup_search_params, +) + #################################### # Images #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 527d28707b..7291a40fcd 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -410,6 +410,8 @@ from open_webui.config import ( YANDEX_WEB_SEARCH_CONFIG, YANDEX_WEB_SEARCH_URL, YOUCOM_API_KEY, + LINKUP_API_KEY, + LINKUP_SEARCH_PARAMS, YOUTUBE_LOADER_LANGUAGE, YOUTUBE_LOADER_PROXY_URL, AppConfig, @@ -1130,6 +1132,8 @@ app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY +app.state.config.LINKUP_API_KEY = LINKUP_API_KEY +app.state.config.LINKUP_SEARCH_PARAMS = LINKUP_SEARCH_PARAMS app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL diff --git a/backend/open_webui/retrieval/web/linkup.py b/backend/open_webui/retrieval/web/linkup.py new file mode 100644 index 0000000000..7547fe887e --- /dev/null +++ b/backend/open_webui/retrieval/web/linkup.py @@ -0,0 +1,76 @@ +import logging +from typing import Optional + +import requests + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results + +log = logging.getLogger(__name__) + +DEFAULT_LINKUP_PARAMS = { + 'url': 'https://api.linkup.so/v1/search', + 'depth': 'standard', + 'outputType': 'sourcedAnswer', +} + + +def search_linkup( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, + params: Optional[dict] = None, +) -> list[SearchResult]: + """Search using the Linkup Search API. + + ``params`` is forwarded almost verbatim as the JSON body; only ``q`` + and ``maxResults`` are injected automatically. The special key + ``url`` (default ``https://api.linkup.so/v1/search``) is popped and + used as the endpoint. + """ + if hasattr(api_key, '__str__'): + api_key = str(api_key) + + merged = {**DEFAULT_LINKUP_PARAMS, **(params or {})} + api_url = str(merged.pop('url', DEFAULT_LINKUP_PARAMS['url'])) + + payload = {**merged, 'q': query, 'maxResults': count} + + try: + response = requests.post( + api_url, + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json=payload, + timeout=30, + ) + response.raise_for_status() + json_response = response.json() + + output_type = merged.get('outputType', 'sourcedAnswer') + search_results = ( + json_response.get('sources', []) + if output_type == 'sourcedAnswer' + else json_response.get('results', []) + ) + + if filter_list: + search_results = get_filtered_results(search_results, filter_list) + + return [ + SearchResult( + link=r.get('url', ''), + title=r.get('name') or r.get('title'), + snippet=r.get('content') or r.get('text') or r.get('snippet'), + ) + for r in search_results + ][:count] + + except requests.exceptions.RequestException as e: + log.error(f'Linkup API request failed: {e}') + raise Exception(f'Linkup search failed: {str(e)}') + except Exception as e: + log.error(f'Error searching Linkup: {e}') + raise Exception(f'Linkup search error: {str(e)}') diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 9a1dfdcc16..afc1dea087 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -107,6 +107,7 @@ from open_webui.retrieval.web.utils import get_web_loader from open_webui.retrieval.web.yacy import search_yacy from open_webui.retrieval.web.yandex import search_yandex from open_webui.retrieval.web.ydc import search_youcom +from open_webui.retrieval.web.linkup import search_linkup from open_webui.storage.provider import Storage from open_webui.utils.access_control import has_permission from open_webui.utils.access_control.files import has_access_to_file @@ -553,6 +554,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, + 'LINKUP_API_KEY': request.app.state.config.LINKUP_API_KEY, + 'LINKUP_SEARCH_PARAMS': request.app.state.config.LINKUP_SEARCH_PARAMS, }, } @@ -622,6 +625,8 @@ class WebConfig(BaseModel): YANDEX_WEB_SEARCH_API_KEY: str | None = None YANDEX_WEB_SEARCH_CONFIG: str | None = None YOUCOM_API_KEY: str | None = None + LINKUP_API_KEY: str | None = None + LINKUP_SEARCH_PARAMS: dict | None = None class ConfigForm(BaseModel): @@ -1109,6 +1114,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = form_data.web.YANDEX_WEB_SEARCH_API_KEY request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = form_data.web.YANDEX_WEB_SEARCH_CONFIG request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY + request.app.state.config.LINKUP_API_KEY = form_data.web.LINKUP_API_KEY + request.app.state.config.LINKUP_SEARCH_PARAMS = form_data.web.LINKUP_SEARCH_PARAMS return { 'status': True, @@ -1241,6 +1248,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, + 'LINKUP_API_KEY': request.app.state.config.LINKUP_API_KEY, + 'LINKUP_SEARCH_PARAMS': request.app.state.config.LINKUP_SEARCH_PARAMS, }, } @@ -1876,6 +1885,7 @@ def search_web(request: Request, engine: str, query: str, user=None) -> list[Sea - SOUGOU_API_SID + SOUGOU_API_SK - SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`) - SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`) + - LINKUP_API_KEY Args: query (str): The query to search for """ @@ -2166,6 +2176,17 @@ def search_web(request: Request, engine: str, query: str, user=None) -> list[Sea request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) + elif engine == 'linkup': + if request.app.state.config.LINKUP_API_KEY: + return search_linkup( + api_key=request.app.state.config.LINKUP_API_KEY, + query=query, + count=request.app.state.config.WEB_SEARCH_RESULT_COUNT, + filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + params=request.app.state.config.LINKUP_SEARCH_PARAMS, + ) + else: + raise Exception('No LINKUP_API_KEY found in environment variables') else: raise Exception('No search engine API key found in environment variables') diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index cac70fc55a..3dfd3306c2 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -39,7 +39,8 @@ 'firecrawl', 'external', 'yandex', - 'youcom' + 'youcom', + 'linkup' ]; let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external']; @@ -78,8 +79,14 @@ webConfig.PLAYWRIGHT_TIMEOUT = webConfig.PLAYWRIGHT_TIMEOUT.toString(); } + // Convert Linkup params JSON string to object before sending + const linkupParams = + typeof webConfig.LINKUP_SEARCH_PARAMS === 'string' && webConfig.LINKUP_SEARCH_PARAMS.trim() !== '' + ? JSON.parse(webConfig.LINKUP_SEARCH_PARAMS) + : webConfig.LINKUP_SEARCH_PARAMS ?? {}; + const res = await updateRAGConfig(localStorage.token, { - web: webConfig + web: { ...webConfig, LINKUP_SEARCH_PARAMS: linkupParams } }); // Convert arrays back to strings for display @@ -123,6 +130,12 @@ webConfig.PLAYWRIGHT_TIMEOUT = parsed; } } + + // Convert Linkup params object to JSON string for textarea display + webConfig.LINKUP_SEARCH_PARAMS = + typeof webConfig.LINKUP_SEARCH_PARAMS === 'object' + ? JSON.stringify(webConfig.LINKUP_SEARCH_PARAMS ?? {}, null, 2) + : webConfig.LINKUP_SEARCH_PARAMS ?? ''; } }); @@ -834,6 +847,30 @@ /> + {:else if webConfig.WEB_SEARCH_ENGINE === 'linkup'} +