mirror of
https://git.mirrors.martin98.com/https://github.com/open-webui/open-webui
synced 2025-08-16 17:25:52 +08:00
feat: load function from url
This commit is contained in:
parent
7d756982c2
commit
b4caad928e
@ -1,5 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import aiohttp
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -15,6 +18,8 @@ from open_webui.constants import ERROR_MESSAGES
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
@ -42,6 +47,77 @@ async def get_functions(user=Depends(get_admin_user)):
|
|||||||
return Functions.get_functions()
|
return Functions.get_functions()
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# 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_function_from_url(
|
||||||
|
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
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]
|
||||||
|
function_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 function"
|
||||||
|
)
|
||||||
|
data = await resp.text()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="No data received from the URL"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": function_name,
|
||||||
|
"content": data,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error importing function: {e}")
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# SyncFunctions
|
# SyncFunctions
|
||||||
############################
|
############################
|
||||||
|
@ -62,6 +62,40 @@ export const getFunctions = async (token: string = '') => {
|
|||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadFunctionByUrl = async (token: string = '', url: string) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const res = await fetch(`${WEBUI_API_BASE_URL}/functions/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 exportFunctions = async (token: string = '') => {
|
export const exportFunctions = async (token: string = '') => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
@ -34,6 +34,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';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@ -200,6 +201,16 @@
|
|||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<ImportModal
|
||||||
|
bind:show={showImportModal}
|
||||||
|
onImport={(func) => {
|
||||||
|
sessionStorage.function = JSON.stringify({
|
||||||
|
...func
|
||||||
|
});
|
||||||
|
goto('/admin/functions/create');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 mt-1.5 mb-2">
|
<div class="flex flex-col gap-1 mt-1.5 mb-2">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
||||||
@ -239,7 +250,7 @@
|
|||||||
createHandler={() => {
|
createHandler={() => {
|
||||||
goto('/admin/functions/create');
|
goto('/admin/functions/create');
|
||||||
}}
|
}}
|
||||||
importFromGithubHandler={() => {
|
importFromLinkHandler={() => {
|
||||||
showImportModal = true;
|
showImportModal = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -15,11 +15,12 @@
|
|||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||||
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
||||||
|
import Link from '$lib/components/icons/Link.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let createHandler: Function;
|
export let createHandler: Function;
|
||||||
export let importFromGithubHandler: Function;
|
export let importFromLinkHandler: Function;
|
||||||
|
|
||||||
export let onClose: Function = () => {};
|
export let onClose: Function = () => {};
|
||||||
|
|
||||||
@ -62,14 +63,14 @@
|
|||||||
<button
|
<button
|
||||||
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||||
on:click={async () => {
|
on:click={async () => {
|
||||||
importFromGithubHandler();
|
importFromLinkHandler();
|
||||||
show = false;
|
show = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class=" self-center mr-2">
|
<div class=" self-center mr-2">
|
||||||
<Github />
|
<Link />
|
||||||
</div>
|
</div>
|
||||||
<div class=" self-center truncate">{$i18n.t('Import From Github')}</div>
|
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { getContext, onMount } from 'svelte';
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import Modal from '$lib/components/common/Modal.svelte';
|
||||||
|
import { loadFunctionByUrl } from '$lib/apis/functions';
|
||||||
|
import { extractFrontmatter } from '$lib/utils';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
|
||||||
|
export let onImport = (e) => {};
|
||||||
|
export let onClose = () => {};
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
let url = '';
|
||||||
|
|
||||||
|
const submitHandler = async () => {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
toast.error($i18n.t('Please enter a valid URL'));
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await loadFunctionByUrl(localStorage.token, url).catch((err) => {
|
||||||
|
toast.error(`${err}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
toast.success($i18n.t('Function loaded successfully'));
|
||||||
|
let func = res;
|
||||||
|
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
|
||||||
|
|
||||||
|
const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted
|
||||||
|
|
||||||
|
if (frontmatter?.title) {
|
||||||
|
func.name = frontmatter.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontmatter?.description) {
|
||||||
|
func.meta.description = frontmatter.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
onImport(func);
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal size="sm" bind:show>
|
||||||
|
<div>
|
||||||
|
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||||
|
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
|
||||||
|
<button
|
||||||
|
class="self-center"
|
||||||
|
on:click={() => {
|
||||||
|
show = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row w-full px-4 pb-3 md:space-x-4 dark:text-gray-200">
|
||||||
|
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||||
|
<form
|
||||||
|
class="flex flex-col w-full"
|
||||||
|
on:submit|preventDefault={() => {
|
||||||
|
submitHandler();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="px-1">
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('URL')}</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
|
||||||
|
type="url"
|
||||||
|
bind:value={url}
|
||||||
|
placeholder={$i18n.t('Enter the URL of the function to import')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||||
|
? ' cursor-not-allowed'
|
||||||
|
: ''}"
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{$i18n.t('Import')}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="ml-2 self-center">
|
||||||
|
<svg
|
||||||
|
class=" w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
><style>
|
||||||
|
.spinner_ajPY {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: spinner_AtaB 0.75s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes spinner_AtaB {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style><path
|
||||||
|
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||||
|
opacity=".25"
|
||||||
|
/><path
|
||||||
|
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||||
|
class="spinner_ajPY"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
Loading…
x
Reference in New Issue
Block a user