mirror of
https://github.com/open-webui/open-webui
synced 2025-06-11 00:49:44 +00:00
enh: load tool by url
This commit is contained in:
parent
4461122a0e
commit
85a384fab5
@ -2,6 +2,9 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import time
|
import time
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
from open_webui.models.tools import (
|
from open_webui.models.tools import (
|
||||||
ToolForm,
|
ToolForm,
|
||||||
@ -21,6 +24,7 @@ from open_webui.env import SRC_LOG_LEVELS
|
|||||||
|
|
||||||
from open_webui.utils.tools import get_tool_servers_data
|
from open_webui.utils.tools import get_tool_servers_data
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
@ -95,6 +99,81 @@ async def get_tool_list(user=Depends(get_verified_user)):
|
|||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# LoadFunctionFromLink
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
class LoadUrlForm(BaseModel):
|
||||||
|
url: HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
def github_url_to_raw_url(url: str) -> str:
|
||||||
|
# Handle 'tree' (folder) URLs (add main.py at the end)
|
||||||
|
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
|
||||||
|
if m1:
|
||||||
|
org, repo, branch, path = m1.groups()
|
||||||
|
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
|
||||||
|
|
||||||
|
# Handle 'blob' (file) URLs
|
||||||
|
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
|
||||||
|
if m2:
|
||||||
|
org, repo, branch, path = m2.groups()
|
||||||
|
return (
|
||||||
|
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No match; return as-is
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/load/url", response_model=Optional[dict])
|
||||||
|
async def load_tool_from_url(
|
||||||
|
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
# NOTE: This is NOT a SSRF vulnerability:
|
||||||
|
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
|
||||||
|
# and does NOT accept untrusted user input. Access is enforced by authentication.
|
||||||
|
|
||||||
|
url = str(form_data.url)
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(status_code=400, detail="Please enter a valid URL")
|
||||||
|
|
||||||
|
url = github_url_to_raw_url(url)
|
||||||
|
url_parts = url.rstrip("/").split("/")
|
||||||
|
|
||||||
|
file_name = url_parts[-1]
|
||||||
|
tool_name = (
|
||||||
|
file_name[:-3]
|
||||||
|
if (
|
||||||
|
file_name.endswith(".py")
|
||||||
|
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
|
||||||
|
)
|
||||||
|
else url_parts[-2] if len(url_parts) > 1 else "function"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
url, headers={"Content-Type": "application/json"}
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=resp.status, detail="Failed to fetch the tool"
|
||||||
|
)
|
||||||
|
data = await resp.text()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="No data received from the URL"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": tool_name,
|
||||||
|
"content": data,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error importing tool: {e}")
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# ExportTools
|
# ExportTools
|
||||||
############################
|
############################
|
||||||
|
@ -31,6 +31,40 @@ export const createNewTool = async (token: string, tool: object) => {
|
|||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadToolByUrl = async (token: string = '', url: string) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/load/url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw await res.json();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
return json;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err.detail;
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
export const getTools = async (token: string = '') => {
|
export const getTools = async (token: string = '') => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
import Modal from '$lib/components/common/Modal.svelte';
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
import { loadFunctionByUrl } from '$lib/apis/functions';
|
|
||||||
import { extractFrontmatter } from '$lib/utils';
|
import { extractFrontmatter } from '$lib/utils';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
@ -12,6 +11,9 @@
|
|||||||
export let onImport = (e) => {};
|
export let onImport = (e) => {};
|
||||||
export let onClose = () => {};
|
export let onClose = () => {};
|
||||||
|
|
||||||
|
export let loadUrlHandler: Function = () => {};
|
||||||
|
export let successMessage: string = '';
|
||||||
|
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let url = '';
|
let url = '';
|
||||||
|
|
||||||
@ -24,14 +26,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await loadFunctionByUrl(localStorage.token, url).catch((err) => {
|
const res = await loadUrlHandler(url).catch((err) => {
|
||||||
toast.error(`${err}`);
|
toast.error(`${err}`);
|
||||||
|
loading = false;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
toast.success($i18n.t('Function loaded successfully'));
|
toast.success(successMessage || $i18n.t('Function imported successfully'));
|
||||||
let func = res;
|
let func = res;
|
||||||
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
|
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
|
||||||
|
|
||||||
@ -92,7 +94,8 @@
|
|||||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
|
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
|
||||||
type="url"
|
type="url"
|
||||||
bind:value={url}
|
bind:value={url}
|
||||||
placeholder={$i18n.t('Enter the URL of the function to import')}
|
placeholder={$i18n.t('Enter the URL to import') ||
|
||||||
|
$i18n.t('Enter the URL of the function to import')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
@ -13,6 +13,7 @@
|
|||||||
exportFunctions,
|
exportFunctions,
|
||||||
getFunctionById,
|
getFunctionById,
|
||||||
getFunctions,
|
getFunctions,
|
||||||
|
loadFunctionByUrl,
|
||||||
toggleFunctionById,
|
toggleFunctionById,
|
||||||
toggleGlobalById
|
toggleGlobalById
|
||||||
} from '$lib/apis/functions';
|
} from '$lib/apis/functions';
|
||||||
@ -34,7 +35,7 @@
|
|||||||
import ChevronRight from '../icons/ChevronRight.svelte';
|
import ChevronRight from '../icons/ChevronRight.svelte';
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
|
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
|
||||||
import ImportModal from './Functions/ImportModal.svelte';
|
import ImportModal from '../ImportModal.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@ -203,6 +204,9 @@
|
|||||||
|
|
||||||
<ImportModal
|
<ImportModal
|
||||||
bind:show={showImportModal}
|
bind:show={showImportModal}
|
||||||
|
loadUrlHandler={async (url) => {
|
||||||
|
return await loadFunctionByUrl(localStorage.token, url);
|
||||||
|
}}
|
||||||
onImport={(func) => {
|
onImport={(func) => {
|
||||||
sessionStorage.function = JSON.stringify({
|
sessionStorage.function = JSON.stringify({
|
||||||
...func
|
...func
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
createNewTool,
|
createNewTool,
|
||||||
|
loadToolByUrl,
|
||||||
deleteToolById,
|
deleteToolById,
|
||||||
exportTools,
|
exportTools,
|
||||||
getToolById,
|
getToolById,
|
||||||
@ -32,6 +33,8 @@
|
|||||||
import Spinner from '../common/Spinner.svelte';
|
import Spinner from '../common/Spinner.svelte';
|
||||||
import { capitalizeFirstLetter } from '$lib/utils';
|
import { capitalizeFirstLetter } from '$lib/utils';
|
||||||
import XMark from '../icons/XMark.svelte';
|
import XMark from '../icons/XMark.svelte';
|
||||||
|
import AddToolMenu from './Tools/AddToolMenu.svelte';
|
||||||
|
import ImportModal from '../ImportModal.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@ -53,6 +56,8 @@
|
|||||||
let tools = [];
|
let tools = [];
|
||||||
let filteredItems = [];
|
let filteredItems = [];
|
||||||
|
|
||||||
|
let showImportModal = false;
|
||||||
|
|
||||||
$: filteredItems = tools.filter((t) => {
|
$: filteredItems = tools.filter((t) => {
|
||||||
if (query === '') return true;
|
if (query === '') return true;
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
@ -173,6 +178,19 @@
|
|||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<ImportModal
|
||||||
|
bind:show={showImportModal}
|
||||||
|
onImport={(tool) => {
|
||||||
|
sessionStorage.tool = JSON.stringify({
|
||||||
|
...tool
|
||||||
|
});
|
||||||
|
goto('/workspace/tools/create');
|
||||||
|
}}
|
||||||
|
loadUrlHandler={async (url) => {
|
||||||
|
return await loadToolByUrl(localStorage.token, url);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div class="flex flex-col gap-1 my-1.5">
|
<div class="flex flex-col gap-1 my-1.5">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
@ -210,12 +228,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a
|
{#if $user?.role === 'admin'}
|
||||||
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
|
<AddToolMenu
|
||||||
href="/workspace/tools/create"
|
createHandler={() => {
|
||||||
>
|
goto('/workspace/tools/create');
|
||||||
<Plus className="size-3.5" />
|
}}
|
||||||
</a>
|
importFromLinkHandler={() => {
|
||||||
|
showImportModal = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
</AddToolMenu>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
|
||||||
|
href="/workspace/tools/create"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
77
src/lib/components/workspace/Tools/AddToolMenu.svelte
Normal file
77
src/lib/components/workspace/Tools/AddToolMenu.svelte
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu } from 'bits-ui';
|
||||||
|
import { flyAndScale } from '$lib/utils/transitions';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
|
||||||
|
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||||
|
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import Share from '$lib/components/icons/Share.svelte';
|
||||||
|
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||||
|
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||||
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
|
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||||
|
import Github from '$lib/components/icons/Github.svelte';
|
||||||
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
|
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||||
|
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
||||||
|
import Link from '$lib/components/icons/Link.svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
export let createHandler: Function;
|
||||||
|
export let importFromLinkHandler: Function;
|
||||||
|
|
||||||
|
export let onClose: Function = () => {};
|
||||||
|
|
||||||
|
let show = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
bind:show
|
||||||
|
on:change={(e) => {
|
||||||
|
if (e.detail === false) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip content={$i18n.t('Create')}>
|
||||||
|
<slot />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div slot="content">
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||||
|
sideOffset={-2}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
transition={flyAndScale}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||||
|
on:click={async () => {
|
||||||
|
createHandler();
|
||||||
|
show = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class=" self-center mr-2">
|
||||||
|
<PencilSolid />
|
||||||
|
</div>
|
||||||
|
<div class=" self-center truncate">{$i18n.t('New Tool')}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||||
|
on:click={async () => {
|
||||||
|
importFromLinkHandler();
|
||||||
|
show = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class=" self-center mr-2">
|
||||||
|
<Link />
|
||||||
|
</div>
|
||||||
|
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
Loading…
Reference in New Issue
Block a user