diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index e8ce3aa811..2020a29633 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -3,7 +3,7 @@ import time from typing import Optional from open_webui.internal.db import Base, JSONField, get_db -from open_webui.models.users import Users +from open_webui.models.users import Users, UserModel from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index @@ -76,6 +76,10 @@ class FunctionWithValvesModel(BaseModel): #################### +class FunctionUserResponse(FunctionModel): + user: Optional[UserModel] = None + + class FunctionResponse(BaseModel): id: str user_id: str @@ -203,6 +207,28 @@ class FunctionsTable: FunctionModel.model_validate(function) for function in functions ] + def get_function_list(self) -> list[FunctionUserResponse]: + with get_db() as db: + functions = db.query(Function).order_by(Function.updated_at.desc()).all() + user_ids = list(set(func.user_id for func in functions)) + + users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users_dict = {user.id: user for user in users} + + return [ + FunctionUserResponse.model_validate( + { + **FunctionModel.model_validate(func).model_dump(), + "user": ( + users_dict.get(func.user_id).model_dump() + if func.user_id in users_dict + else None + ), + } + ) + for func in functions + ] + def get_functions_by_type( self, type: str, active_only=False ) -> list[FunctionModel]: diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index c36e656d5f..c8f131553c 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -10,6 +10,7 @@ from open_webui.models.functions import ( FunctionForm, FunctionModel, FunctionResponse, + FunctionUserResponse, FunctionWithValvesModel, Functions, ) @@ -42,6 +43,11 @@ async def get_functions(user=Depends(get_verified_user)): return Functions.get_functions() +@router.get("/list", response_model=list[FunctionUserResponse]) +async def get_function_list(user=Depends(get_admin_user)): + return Functions.get_function_list() + + ############################ # ExportFunctions ############################ diff --git a/src/lib/apis/functions/index.ts b/src/lib/apis/functions/index.ts index 60e88118b8..47346b4a20 100644 --- a/src/lib/apis/functions/index.ts +++ b/src/lib/apis/functions/index.ts @@ -62,6 +62,37 @@ export const getFunctions = async (token: string = '') => { return res; }; +export const getFunctionList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/functions/list`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .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 loadFunctionByUrl = async (token: string = '', url: string) => { let error = null; diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 0d1e7edadc..4bbcebd15f 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -3,7 +3,7 @@ import fileSaver from 'file-saver'; const { saveAs } = fileSaver; - import { WEBUI_NAME, config, functions, models, settings } from '$lib/stores'; + import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores'; import { onMount, getContext, tick } from 'svelte'; import { goto } from '$app/navigation'; @@ -12,6 +12,7 @@ deleteFunctionById, exportFunctions, getFunctionById, + getFunctionList, getFunctions, loadFunctionByUrl, toggleFunctionById, @@ -36,6 +37,10 @@ import XMark from '../icons/XMark.svelte'; import AddFunctionMenu from './Functions/AddFunctionMenu.svelte'; import ImportModal from '../ImportModal.svelte'; + import ViewSelector from '../workspace/common/ViewSelector.svelte'; + import TagSelector from '../workspace/common/TagSelector.svelte'; + import { capitalizeFirstLetter } from '$lib/utils'; + import Spinner from '../common/Spinner.svelte'; const i18n = getContext('i18n'); @@ -44,12 +49,16 @@ let functionsImportInputElement: HTMLInputElement; let importFiles; + let tagsContainerElement: HTMLDivElement; + let viewOption = ''; + + let query = ''; + let selectedTag = ''; + let selectedType = ''; + let showImportModal = false; let showConfirm = false; - let query = ''; - - let selectedType = 'all'; let showManifestModal = false; let showValvesModal = false; @@ -57,17 +66,33 @@ let showDeleteConfirm = false; + let loaded = false; + let functions = null; let filteredItems = []; - $: filteredItems = $functions - .filter( - (f) => - (selectedType !== 'all' ? f.type === selectedType : true) && - (query === '' || - f.name.toLowerCase().includes(query.toLowerCase()) || - f.id.toLowerCase().includes(query.toLowerCase())) - ) - .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)); + $: if ( + functions && + query !== undefined && + selectedType !== undefined && + viewOption !== undefined + ) { + setFilteredItems(); + } + + const setFilteredItems = () => { + filteredItems = functions + .filter( + (f) => + (selectedType !== '' ? f.type === selectedType : true) && + (query === '' || + f.name.toLowerCase().includes(query.toLowerCase()) || + f.id.toLowerCase().includes(query.toLowerCase())) && + (viewOption === '' || + (viewOption === 'created' && f.user_id === $user?.id) || + (viewOption === 'shared' && f.user_id !== $user?.id)) + ) + .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)); + }; const shareHandler = async (func) => { const item = await getFunctionById(localStorage.token, func.id).catch((error) => { toast.error(`${error}`); @@ -134,7 +159,7 @@ if (res) { toast.success($i18n.t('Function deleted successfully')); - functions.set(await getFunctions(localStorage.token)); + _functions.set(await getFunctions(localStorage.token)); models.set( await getModels( localStorage.token, @@ -162,7 +187,7 @@ : toast.success($i18n.t('Function is now globally disabled')); } - functions.set(await getFunctions(localStorage.token)); + _functions.set(await getFunctions(localStorage.token)); models.set( await getModels( localStorage.token, @@ -174,7 +199,16 @@ } }; - onMount(() => { + onMount(async () => { + viewOption = localStorage?.workspaceViewOption || ''; + functions = await getFunctionList(localStorage.token).catch((error) => { + toast.error(`${error}`); + return []; + }); + + await tick(); + loaded = true; + const onKeyDown = (event) => { if (event.key === 'Shift') { shiftKey = true; @@ -222,424 +256,388 @@ }} /> -
-
-
- {$i18n.t('Functions')} -
- {filteredItems.length} -
-
+{#if loaded} +
+
+
+ { + console.log(importFiles); + showConfirm = true; + }} + /> -
-
-
- +
+
+
+ {$i18n.t('Functions')} +
+ +
+ {filteredItems.length} +
+
+ +
+ {#if $user?.role === 'admin'} + + + {#if functions.length} + + {/if} + {/if} + { + goto('/admin/functions/create'); + }} + importFromLinkHandler={() => { + showImportModal = true; + }} + > +
+ + + +
+
+
+
- +
- {#if query} -
- +
+ {/if} +
+
+ +
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ { + localStorage.workspaceViewOption = value; + + await tick(); }} - > - - + /> + + +
+
+ + {#if (filteredItems ?? []).length !== 0} +
+ {#each filteredItems as func (func.id)} +
+ +
+
+ +
+
+ {func.type} +
+ +
+ {func.name} +
+ {#if func?.meta?.manifest?.version} +
+ v{func?.meta?.manifest?.version ?? ''} +
+ {/if} +
+
+ +
+
+ + {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User') + ) + })} + +
+
+ {func.meta.description} +
+
+
+
+
+
+ {#if shiftKey} + + + + {:else} + {#if func?.meta?.manifest?.funding_url ?? false} + + + + {/if} + + + + + + { + goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`); + }} + shareHandler={() => { + shareHandler(func); + }} + cloneHandler={() => { + cloneHandler(func); + }} + exportHandler={() => { + exportHandler(func); + }} + deleteHandler={async () => { + selectedFunction = func; + showDeleteConfirm = true; + }} + toggleGlobalHandler={() => { + if (['filter', 'action'].includes(func.type)) { + toggleGlobalHandler(func); + } + }} + onClose={() => {}} + > + + + {/if} + +
+ + { + toggleFunctionById(localStorage.token, func.id); + models.set( + await getModels( + localStorage.token, + $config?.features?.enable_direct_connections && + ($settings?.directConnections ?? null), + false, + true + ) + ); + }} + /> + +
+
+
+ {/each} +
+ {:else} +
+
+
😕
+
{$i18n.t('No functions found')}
+
+ {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} +
+
{/if}
-
- { - goto('/admin/functions/create'); - }} - importFromLinkHandler={() => { - showImportModal = true; - }} - > -
- -
-
-
-
- -
-
- - - - - - - -
-
-
- -
- {#each filteredItems as func (func.id)} -
- -
-
-
-
- {func.type} -
- - {#if func?.meta?.manifest?.version} -
- v{func?.meta?.manifest?.version ?? ''} -
- {/if} - -
- {func.name} -
-
- -
-
{func.id}
- -
- {func.meta.description} -
-
-
-
-
-
- {#if shiftKey} - - - - {:else} - {#if func?.meta?.manifest?.funding_url ?? false} - - - - {/if} - - - - - - { - goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`); - }} - shareHandler={() => { - shareHandler(func); - }} - cloneHandler={() => { - cloneHandler(func); - }} - exportHandler={() => { - exportHandler(func); - }} - deleteHandler={async () => { - selectedFunction = func; - showDeleteConfirm = true; - }} - toggleGlobalHandler={() => { - if (['filter', 'action'].includes(func.type)) { - toggleGlobalHandler(func); - } - }} - onClose={() => {}} - > - - - {/if} - -
- - { - toggleFunctionById(localStorage.token, func.id); - models.set( - await getModels( - localStorage.token, - $config?.features?.enable_direct_connections && - ($settings?.directConnections ?? null), - false, - true - ) - ); - }} - /> - -
-
-
- {/each} -
- - -
-
- { - console.log(importFiles); - showConfirm = true; - }} - /> + {#if $config?.features.enable_community_sharing} +
+
+ {$i18n.t('Made by Open WebUI Community')} +
- - - {#if $functions.length} - {/if}
-
-{#if $config?.features.enable_community_sharing} -
-
- {$i18n.t('Made by Open WebUI Community')} + { + deleteHandler(selectedFunction); + }} + > +
+ {$i18n.t('This will delete')} {selectedFunction.name}.
+
- -
-
{$i18n.t('Discover a function')}
-
- {$i18n.t('Discover, download, and explore custom functions')} -
-
- -
-
- -
-
-
-
-{/if} - - { - deleteHandler(selectedFunction); - }} -> -
- {$i18n.t('This will delete')} {selectedFunction.name}. -
-
- - - { - await tick(); - models.set( - await getModels( - localStorage.token, - $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null), - false, - true - ) - ); - }} -/> - - { - const reader = new FileReader(); - reader.onload = async (event) => { - const _functions = JSON.parse(event.target.result); - console.log(_functions); - - for (let func of _functions) { - if ('function' in func) { - // Required for Community JSON import - func = func.function; - } - - const res = await createNewFunction(localStorage.token, func).catch((error) => { - toast.error(`${error}`); - return null; - }); - } - - toast.success($i18n.t('Functions imported successfully')); - functions.set(await getFunctions(localStorage.token)); + + { + await tick(); models.set( await getModels( localStorage.token, @@ -648,25 +646,63 @@ true ) ); - }; + }} + /> - reader.readAsText(importFiles[0]); - }} -> -
-
-
{$i18n.t('Please carefully review the following warnings:')}
+ { + const reader = new FileReader(); + reader.onload = async (event) => { + const _functions = JSON.parse(event.target.result); + console.log(_functions); -
    -
  • {$i18n.t('Functions allow arbitrary code execution.')}
  • -
  • {$i18n.t('Do not install functions from sources you do not fully trust.')}
  • -
-
- -
- {$i18n.t( - 'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.' - )} + for (let func of _functions) { + if ('function' in func) { + // Required for Community JSON import + func = func.function; + } + + const res = await createNewFunction(localStorage.token, func).catch((error) => { + toast.error(`${error}`); + return null; + }); + } + + toast.success($i18n.t('Functions imported successfully')); + functions.set(await getFunctions(localStorage.token)); + models.set( + await getModels( + localStorage.token, + $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null), + false, + true + ) + ); + }; + + reader.readAsText(importFiles[0]); + }} + > +
+
+
{$i18n.t('Please carefully review the following warnings:')}
+ +
    +
  • {$i18n.t('Functions allow arbitrary code execution.')}
  • +
  • {$i18n.t('Do not install functions from sources you do not fully trust.')}
  • +
+
+ +
+ {$i18n.t( + 'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.' + )} +
+ +{:else} +
+
- +{/if} diff --git a/src/lib/components/admin/Functions/AddFunctionMenu.svelte b/src/lib/components/admin/Functions/AddFunctionMenu.svelte index 2c3412fdfc..0272bac298 100644 --- a/src/lib/components/admin/Functions/AddFunctionMenu.svelte +++ b/src/lib/components/admin/Functions/AddFunctionMenu.svelte @@ -41,27 +41,27 @@