refac: functions

This commit is contained in:
Timothy Jaeryang Baek 2025-10-07 14:20:07 -05:00
parent 299e0e2674
commit f5e1a42f51
5 changed files with 536 additions and 437 deletions

View file

@ -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]:

View file

@ -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
############################

View file

@ -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;

View file

@ -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
$: if (
functions &&
query !== undefined &&
selectedType !== undefined &&
viewOption !== undefined
) {
setFilteredItems();
}
const setFilteredItems = () => {
filteredItems = functions
.filter(
(f) =>
(selectedType !== 'all' ? f.type === selectedType : true) &&
(selectedType !== '' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.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,16 +256,95 @@
}}
/>
<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
<div class="flex justify-between items-center mb-1">
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
{#if loaded}
<div class="px-4.5 w-full">
<div class="flex flex-col gap-1 px-1 mt-2.5 mb-2">
<div class="flex justify-between items-center mb-1 w-full">
<input
id="documents-import-input"
bind:this={functionsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<div class="flex justify-between items-center w-full">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Functions')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-base font-lg text-gray-500 dark:text-gray-300">{filteredItems.length}</span>
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredItems.length}
</div>
</div>
<div class=" flex w-full space-x-2">
<div class="flex w-full justify-end gap-1.5">
{#if $user?.role === 'admin'}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={() => {
functionsImportInputElement.click();
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
{#if functions.length}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
{/if}
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
>
<div
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Function')}</div>
</div>
</AddFunctionMenu>
</div>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-850"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
@ -255,69 +368,43 @@
</div>
{/if}
</div>
</div>
<div>
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
<div
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<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"
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
<Plus className="size-3.5" />
</div>
</AddFunctionMenu>
<ViewSelector
bind:value={viewOption}
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}}
/>
<TagSelector
bind:value={selectedType}
items={[
{ value: 'pipe', label: $i18n.t('Pipe') },
{ value: 'filter', label: $i18n.t('Filter') },
{ value: 'action', label: $i18n.t('Action') }
]}
/>
</div>
</div>
<div class=" flex w-full">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
>
<button
class="min-w-fit p-1.5 {selectedType === 'all'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'all';
}}>{$i18n.t('All')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'pipe'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'pipe';
}}>{$i18n.t('Pipe')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'filter'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'filter';
}}>{$i18n.t('Filter')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'action'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'action';
}}>{$i18n.t('Action')}</button
>
</div>
</div>
</div>
<div class="mb-5 px-[16px]">
{#if (filteredItems ?? []).length !== 0}
<div class="px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
@ -328,29 +415,39 @@
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<Tooltip content={func.id} placement="top-start">
<div class=" flex items-center gap-1.5">
<div
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
<div class="line-clamp-1 text-sm">
{func.name}
</div>
{#if func?.meta?.manifest?.version}
<div
class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
<div class=" text-gray-500 text-xs font-medium shrink-0">
v{func?.meta?.manifest?.version ?? ''}
</div>
{/if}
<div class=" line-clamp-1">
{func.name}
</div>
</div>
</Tooltip>
<div class="flex gap-1.5 px-1">
<div class=" text-gray-500 text-xs font-medium shrink-0">{func.id}</div>
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
@ -474,95 +571,28 @@
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No functions found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/if}
</div>
<!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
<!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t(
'Admins have access to all tools at all times; users need tools assigned per model in the workspace.'
)}
</div> -->
<div class=" flex justify-end w-full mb-2 px-[16px]">
<div class="flex space-x-2">
<input
id="documents-import-input"
bind:this={functionsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
functionsImportInputElement.click();
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Functions')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{#if $functions.length}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Functions')} ({$functions.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{/if}
</div>
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16 px-[16px]">
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by Open WebUI Community')}
</div>
@ -586,22 +616,23 @@
</div>
</a>
</div>
{/if}
{/if}
</div>
<DeleteConfirmDialog
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete function?')}
on:confirm={() => {
deleteHandler(selectedFunction);
}}
>
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
</div>
</DeleteConfirmDialog>
</DeleteConfirmDialog>
<ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
<ValvesModal
<ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
<ValvesModal
bind:show={showValvesModal}
type="function"
id={selectedFunction?.id ?? null}
@ -616,9 +647,9 @@
)
);
}}
/>
/>
<ConfirmDialog
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
@ -652,7 +683,7 @@
reader.readAsText(importFiles[0]);
}}
>
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
@ -669,4 +700,9 @@
)}
</div>
</div>
</ConfirmDialog>
</ConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}

View file

@ -41,27 +41,27 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={-2}
class="w-full max-w-[190px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={6}
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"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={async () => {
createHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<PencilSolid />
<Pencil />
</div>
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
</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 gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={async () => {
importFromLinkHandler();
show = false;