This commit is contained in:
Timothy Jaeryang Baek
2026-07-17 04:48:21 -04:00
parent 56f2cb5302
commit e5e2cd7876
12 changed files with 1219 additions and 686 deletions
+10
View File
@@ -149,6 +149,7 @@ class KnowledgeDirectoryForm(BaseModel):
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel):
@@ -322,6 +323,14 @@ class KnowledgeTable:
knowledge_ids = [kb.id for kb, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
file_counts = {}
if knowledge_ids:
file_count_result = await db.execute(
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
.group_by(KnowledgeFile.knowledge_id)
)
file_counts = dict(file_count_result.all())
knowledge_bases = []
for knowledge_base, user in items:
@@ -336,6 +345,7 @@ class KnowledgeTable:
)
).model_dump(),
'user': (UserModel.model_validate(user).model_dump() if user else None),
'file_count': file_counts.get(knowledge_base.id, 0),
}
)
)
+289 -147
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@@ -39,6 +43,8 @@
import { capitalizeFirstLetter } from '$lib/utils';
import Spinner from '../common/Spinner.svelte';
import SplitCreateButton from '../common/SplitCreateButton.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
const i18n = getContext('i18n');
@@ -49,6 +55,9 @@
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openFunctionMenuId: string | null = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
@@ -90,7 +99,13 @@
}
};
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
$: if (
functions &&
selectedType !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
setFilteredItems();
}
@@ -109,7 +124,32 @@
(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));
.sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1;
if (sortKey === 'name') {
return direction * (a.name ?? '').localeCompare(b.name ?? '');
}
return direction * ((a.updated_at ?? 0) - (b.updated_at ?? 0));
});
};
const setSortKey = (key: string) => {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
}
};
const openFunction = (func) => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
@@ -407,173 +447,275 @@
</div>
{#if (filteredItems ?? []).length !== 0}
<div class="my-1 gap-x-2 gap-y-0.5 grid lg:grid-cols-2">
{#each filteredItems as func (func.id)}
<div
class="flex space-x-4 cursor-pointer w-full px-2.5 py-1.5 rounded-2xl hover:bg-gray-50/70 dark:hover:bg-gray-850/50"
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
{$i18n.t('Title')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as func (func.id)}
<div
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
role="button"
tabindex="0"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openFunction(func);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFunction(func);
}
}}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<Tooltip content={func.id} placement="top-start">
<div class=" flex items-center gap-1.5">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div
class=" text-xs font-normal px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class="shrink-0 rounded-sm bg-gray-500/20 px-1 text-[10px] uppercase leading-4 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
<div class="line-clamp-1 text-sm">
{func.name}
</div>
<Tooltip content={func.id} className="min-w-0" placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{func.name}
</div>
</Tooltip>
{#if func?.meta?.manifest?.version}
<div class=" text-gray-500 text-xs font-normal shrink-0">
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
>
v{func?.meta?.manifest?.version ?? ''}
</div>
{/if}
</div>
</Tooltip>
<div class="flex gap-1.5 px-1">
<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 content={dayjs(func.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs(func.updated_at * 1000).fromNow()}
</div>
</Tooltip>
</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
</div>
{#if func?.meta?.description}
<Tooltip
content={func?.meta?.description}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{func?.meta?.description}
</div>
</Tooltip>
{/if}
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteHandler(func);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<FunctionMenu
{func}
editHandler={() => {
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={() => {}}
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</FunctionMenu>
{/if}
<div class=" self-center mx-1">
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
on:change={async (e) => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
<div class="truncate">
{capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(func);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Support')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart className="size-4" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Valves')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.940 1.11-.940h2.593c.55 0 1.02.398 1.11.940l.213 1.281c.063.374.313.686.645.870.074.040.147.083.220.127.325.196.720.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.370.490l1.296 2.247a1.125 1.125 0 0 1-.260 1.431l-1.003.827c-.293.241-.438.613-.430.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.750.430.991l1.004.827c.424.350.534.955.260 1.430l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.750-.072-1.076.124a6.47 6.47 0 0 1-.220.128c-.331.183-.581.495-.644.869l-.213 1.281c-.090.543-.560.940-1.110.940h-2.594c-.550 0-1.019-.398-1.110-.940l-.213-1.281c-.062-.374-.312-.686-.644-.870a6.52 6.52 0 0 1-.220-.127c-.325-.196-.720-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.490l-1.297-2.247a1.125 1.125 0 0 1 .260-1.431l1.004-.827c.292-.240.437-.613.430-.991a6.932 6.932 0 0 1 0-.255c.007-.380-.138-.751-.430-.992l-1.004-.827a1.125 1.125 0 0 1-.260-1.430l1.297-2.247a1.125 1.125 0 0 1 1.370-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.220-.128.332-.183.582-.495.644-.869l.214-1.280Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<div class="ml-0.5 flex shrink-0 flex-row items-center gap-1.5 self-center">
<FunctionMenu
{func}
show={openFunctionMenuId === func.id}
editHandler={() => {
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={() => {
openFunctionMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Function Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openFunctionMenuId = openFunctionMenuId === func.id ? null : func.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</FunctionMenu>
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={func.is_active}
on:change={async () => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
</Tooltip>
</button>
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
@@ -24,7 +24,12 @@
export let onClose: Function;
let show = false;
export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script>
<Dropdown
@@ -62,6 +67,7 @@
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
editHandler();
closeMenu();
}}
>
<svg
@@ -86,6 +92,7 @@
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
shareHandler();
closeMenu();
}}
>
<Share />
@@ -96,6 +103,7 @@
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
cloneHandler();
closeMenu();
}}
>
<DocumentDuplicate />
@@ -106,6 +114,7 @@
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
exportHandler();
closeMenu();
}}
>
<Download />
@@ -118,6 +127,7 @@
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
on:click={() => {
deleteHandler();
closeMenu();
}}
>
<GarbageBin strokeWidth="2" />
+159 -83
View File
@@ -10,7 +10,7 @@
const i18n = getContext<Writable<i18nType>>('i18n');
import { WEBUI_NAME, knowledge, user, workspaceActions } from '$lib/stores';
import { WEBUI_NAME, user, workspaceActions } from '$lib/stores';
import {
deleteKnowledgeById,
searchKnowledgeBases,
@@ -38,6 +38,7 @@
name: string;
description?: string;
updated_at: number;
file_count?: number;
write_access?: boolean;
meta?: any;
user?: {
@@ -192,6 +193,59 @@
}
};
const openKnowledge = (item: KnowledgeListItem) => {
if (item?.meta?.document) {
toast.error(
$i18n.t(
'Only collections can be edited, create a new knowledge base to edit/add documents.'
)
);
return;
}
goto(`/workspace/knowledge/${item.id}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const getKnowledgeMetaPreview = (item: KnowledgeListItem) => {
const fileCount =
item.file_count !== undefined
? item.file_count === 1
? $i18n.t('1 file')
: $i18n.t('{{count}} files', { count: item.file_count })
: null;
if (!item?.meta) return [fileCount, item.description].filter(Boolean).join(' · ');
if (item.meta.source === 'external') {
return [
fileCount,
item.meta.external?.provider,
item.meta.external?.source?.name,
item.meta.external?.auth_mode,
item.description
]
.filter(Boolean)
.join(' · ');
}
const metadata = Object.entries(item.meta)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return `${key}: ${value}`;
}
return key;
})
.join(' · ');
return [fileCount, metadata, item.description].filter(Boolean).join(' · ');
};
onMount(async () => {
viewOption = localStorage?.workspaceViewOption || '';
sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
@@ -297,102 +351,124 @@
{#if items !== null && total !== null}
{#if (items ?? []).length !== 0}
<div class="my-1 grid grid-cols-1 gap-x-2 gap-y-0.5 lg:grid-cols-2">
{#each items as item}
<button
class="flex w-full cursor-pointer rounded-xl px-2 py-1 text-left transition hover:bg-gray-50/60 dark:hover:bg-gray-850/40"
on:click={() => {
if (item?.meta?.document) {
toast.error(
$i18n.t(
'Only collections can be edited, create a new knowledge base to edit/add documents.'
)
);
} else {
goto(`/workspace/knowledge/${item.id}`);
}
}}
>
<div class="w-full min-w-0">
<div class="flex-1 self-center justify-between">
<div class="flex h-7 items-center justify-between">
<div class=" flex gap-2 items-center justify-between w-full">
{#if item?.meta?.source === 'external'}
<div>
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<div class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left">
{$i18n.t('Title')}
</div>
<div class="hidden w-44 shrink-0 md:block"></div>
<div class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right">
{$i18n.t('Updated at')}
</div>
</div>
<div class="grid gap-y-0.5">
{#each items as item}
{@const metaPreview = getKnowledgeMetaPreview(item)}
<div
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
role="button"
tabindex="0"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openKnowledge(item);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openKnowledge(item);
}
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip
content={item?.description ?? item.name}
className="min-w-0"
placement="top-start"
>
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{item.name}
</div>
</Tooltip>
{#if item?.meta?.source === 'external'}
<Badge
type="muted"
content={item?.meta?.external?.provider ?? $i18n.t('Connected')}
/>
</div>
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{:else}
<div>
<Badge type="success" content={$i18n.t('Collection')} />
</div>
{/if}
{/if}
{#if !item?.write_access && item?.meta?.source !== 'external'}
<div>
{#if !item?.write_access && item?.meta?.source !== 'external'}
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{/if}
</div>
{/if}
{#if item?.write_access || $user?.role === 'admin'}
<div class="flex items-center gap-2">
<div class=" flex self-center">
<ItemMenu
onExport={$user?.role === 'admin'
? () => {
exportHandler(item);
}
: null}
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
</div>
{/if}
</div>
<div class="flex items-center justify-between gap-2 px-0.5">
<Tooltip content={item?.description ?? item.name}>
<div class="flex min-w-0 items-center gap-2">
<div class="line-clamp-1 text-sm font-normal capitalize">{item.name}</div>
</div>
</Tooltip>
<div class="flex shrink-0 items-center gap-2">
<Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
<div class=" text-xs text-gray-500 line-clamp-1 hidden sm:block">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>
</Tooltip>
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={item?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
item?.user?.name ?? item?.user?.email ?? $i18n.t('Deleted User')
)
})}
<Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs(item.updated_at * 1000).fromNow()}
</div>
</Tooltip>
</div>
</div>
{#if metaPreview}
<Tooltip content={metaPreview} className="min-w-0" placement="top-start">
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{metaPreview}
</div>
</Tooltip>
{/if}
</div>
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={item?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
item?.user?.name ?? item?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
{#if item?.write_access || $user?.role === 'admin'}
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
<ItemMenu
onExport={$user?.role === 'admin'
? () => {
exportHandler(item);
}
: null}
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
{/if}
</div>
</button>
{/each}
{/each}
</div>
</div>
{#if !allItemsLoaded}
@@ -15,6 +15,11 @@
export let onClose: Function = () => {};
let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script>
<Dropdown
@@ -29,12 +34,13 @@
<Tooltip content={$i18n.t('More')}>
<slot
><button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('More Options')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
show = true;
show = !show;
}}
>
<EllipsisHorizontal className="size-4" />
@@ -49,6 +55,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
onExport();
closeMenu();
}}
>
<Download className="size-3.5" />
@@ -60,6 +67,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
dispatch('delete');
closeMenu();
}}
>
<GarbageBin className="size-3.5" />
+266 -198
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { marked } from 'marked';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import Sortable from 'sortablejs';
@@ -10,6 +10,7 @@
import { onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
dayjs.extend(relativeTime);
import {
WEBUI_NAME,
@@ -20,7 +21,7 @@
user,
workspaceActions
} from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
createNewModel,
deleteModelById,
@@ -50,6 +51,7 @@
import EyeSlash from '../icons/EyeSlash.svelte';
import Eye from '../icons/Eye.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@@ -78,6 +80,8 @@
let query = '';
let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let page = 1;
let models = null;
@@ -109,10 +113,36 @@
]);
}
$: if (loaded && page !== undefined && selectedTag !== undefined && viewOption !== undefined) {
$: if (
loaded &&
page !== undefined &&
selectedTag !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
getModelList();
}
const setSortKey = (key: string) => {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
}
};
const openModel = (model) => {
if (model.write_access) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const getModelList = async () => {
if (!loaded) return;
@@ -122,8 +152,8 @@
query,
viewOption,
selectedTag,
null,
null,
sortKey,
sortDirection,
page
).catch((error) => {
toast.error(`${error}`);
@@ -601,216 +631,254 @@
{#if models !== null}
{#if (models ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2" id="model-list">
{#each models as model (model.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex w-full rounded-xl px-2 py-1 transition {model.write_access
? 'cursor-pointer hover:bg-gray-50/60 dark:hover:bg-gray-850/40'
: ''}"
id="model-item-{model.id}"
on:click={() => {
if (model.write_access) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
}}
<div class="my-1" id="model-list">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
<div class="flex group/item min-w-0 gap-2.5 w-full">
<div class="self-center">
<div class="flex">
<div
class="{model.is_active
? ''
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-xl"
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile"
class="size-8 rounded-xl object-cover"
loading="lazy"
decoding="async"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
</div>
{$i18n.t('Title')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
</div>
<div class="grid gap-y-0.5">
{#each models as model (model.id)}
<div
class="group flex min-h-8 w-full items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left {model.write_access
? 'cursor-pointer'
: ''}"
id="model-item-{model.id}"
role="button"
tabindex={model.write_access ? 0 : -1}
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openModel(model);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openModel(model);
}
}}
>
<div class="mr-1 shrink-0 self-center">
<div class="{model.is_active ? '' : 'opacity-50 dark:opacity-50'} bg-transparent">
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt=""
class="size-7 rounded-lg object-cover"
loading="lazy"
decoding="async"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
</div>
</div>
<div class="flex min-w-0 w-full flex-1 self-center pr-1">
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
<div class="min-w-0 flex-1 w-full">
<div class="flex min-w-0 items-center justify-between w-full gap-2">
<Tooltip
content={model.name}
className="min-w-0 flex-1"
placement="top-start"
>
<a
class="line-clamp-1 text-sm font-normal capitalize hover:underline"
href={`/?models=${encodeURIComponent(model.id)}`}
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={model.name} className="min-w-0" placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{model.name}
</a>
</div>
</Tooltip>
<div class="flex shrink-0 items-center gap-1">
{#if !model.write_access}
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{/if}
<div class="flex {model.is_active ? '' : 'text-gray-500'}">
<div class="flex items-center gap-0.5">
{#if shiftKey && model.write_access}
<Tooltip
content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}
>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-white dark:hover:bg-white/5"
type="button"
aria-label={model?.meta?.hidden
? $i18n.t('Show')
: $i18n.t('Hide')}
on:click={(e) => {
e.stopPropagation();
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-white dark:hover:bg-white/5"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.stopPropagation();
deleteModelHandler(model);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<ModelMenu
user={$user}
{model}
writeAccess={model.write_access}
editHandler={() => {
goto(
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<div
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-white dark:hover:bg-white/5"
>
<EllipsisHorizontal className="size-4" />
</div>
</ModelMenu>
{/if}
</div>
</div>
{#if model.write_access}
<button
on:click={(e) => {
e.stopPropagation();
}}
>
<Tooltip
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelById(localStorage.token, model.id);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
</button>
{/if}
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
>
{model.id}
</div>
</div>
<div class="flex min-w-0 items-center gap-1 pr-2 text-xs text-gray-500">
<Tooltip
content={model?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
)
})}
<Tooltip content={dayjs(model.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs(model.updated_at * 1000).fromNow()}
</div>
</Tooltip>
<div class="shrink-0">·</div>
<Tooltip
content={marked.parse(model?.meta?.description ?? model.id)}
className="min-w-0 text-left"
placement="top-start"
>
<div class="flex min-w-0 gap-1 overflow-hidden">
<div class="line-clamp-1 min-w-0">
{#if (model?.meta?.description ?? '').trim()}
{model?.meta?.description}
{:else}
{model.id}
{/if}
</div>
</div>
</Tooltip>
{#if !model.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
{/if}
</div>
</div>
<Tooltip
content={(model?.meta?.description ?? '').trim() ||
model.base_model_id ||
$i18n.t('No description')}
className="min-w-0"
placement="top-start"
>
<div
class="truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{(model?.meta?.description ?? '').trim() ||
model.base_model_id ||
$i18n.t('No description')}
</div>
</Tooltip>
</div>
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={model?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey && model.write_access}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash className="size-4" />
{:else}
<Eye className="size-4" />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="ml-0.5 flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteModelHandler(model);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<ModelMenu
user={$user}
{model}
writeAccess={model.write_access}
editHandler={() => {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<div
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
>
<EllipsisHorizontal className="size-4" />
</div>
</ModelMenu>
{#if model.write_access}
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelById(localStorage.token, model.id);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
{#if total > 30}
+23 -24
View File
@@ -504,7 +504,7 @@
{:else if (prompts ?? []).length !== 0}
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-2 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
@@ -575,39 +575,21 @@
/{prompt.command}
</div>
{#if !prompt.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
{/if}
</div>
<div
class="hidden max-w-44 shrink-0 truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={prompt?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
prompt?.user?.name ?? prompt?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
<div class="shrink-0">
<Tooltip
content={dayjs((prompt.updated_at ?? prompt.created_at) * 1000).format(
'LLLL'
)}
>
<div
class="min-w-0 truncate text-right text-xs text-gray-400 dark:text-gray-600"
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs((prompt.updated_at ?? prompt.created_at) * 1000).fromNow()}
</div>
</Tooltip>
{#if !prompt.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
{/if}
</div>
</div>
@@ -622,6 +604,23 @@
{/if}
</div>
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={prompt?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
prompt?.user?.name ?? prompt?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
+220 -117
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
@@ -33,6 +37,8 @@
import Switch from '../common/Switch.svelte';
import SkillMenu from './Skills/SkillMenu.svelte';
import Pagination from '../common/Pagination.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
let shiftKey = false;
let loaded = false;
@@ -52,6 +58,9 @@
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openSkillMenuId: string | null = null;
let page = 1;
$: if (loaded) {
@@ -93,12 +102,17 @@
loading = true;
try {
const res = await getSkillItems(localStorage.token, query, viewOption, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
const res = await getSkillItems(
localStorage.token,
query,
viewOption,
page,
sortKey,
sortDirection
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
filteredItems = res.items;
@@ -124,10 +138,33 @@
};
// Immediate response to page/filter changes
$: if (loaded && page && viewOption !== undefined) {
$: if (
loaded &&
page &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
loadSkillItems();
}
const setSortKey = (key: string) => {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
}
};
const openSkill = (skill) => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const cloneHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
@@ -343,141 +380,207 @@
<Spinner className="size-5" />
</div>
{:else if (filteredItems ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2">
{#each filteredItems as skill}
<Tooltip content={skill?.description ?? skill?.id}>
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Title')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as skill (skill.id)}
<div
class="flex w-full rounded-xl px-2 py-1 text-left transition {skill.write_access
? 'cursor-pointer hover:bg-gray-50/60 dark:hover:bg-gray-850/40'
: 'cursor-not-allowed opacity-60'}"
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
role="button"
tabindex="0"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openSkill(skill);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openSkill(skill);
}
}}
>
{#if skill.write_access}
<a
class="flex w-full min-w-0 flex-1 cursor-pointer"
href={`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`}
>
<div class="flex min-w-0 items-center text-left">
<div class="min-w-0 flex-1 self-center">
<Tooltip content={skill.id} placement="top-start">
<div class="flex min-w-0 items-center gap-2">
<div class="line-clamp-1 text-sm">
{skill.name}
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={skill.id} className="min-w-0" placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{skill.name}
</div>
</Tooltip>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500">
<Tooltip
content={skill?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
>
/{skill.id}
</div>
</div>
</div>
</a>
{:else}
<div class="flex w-full min-w-0 flex-1">
<div class="flex w-full min-w-0 items-center text-left">
<div class="w-full min-w-0 flex-1 self-center">
<div class="flex items-center justify-between w-full gap-2">
<Tooltip content={skill.id} placement="top-start">
<div class="flex min-w-0 items-center gap-2">
<div class="line-clamp-1 text-sm">
{skill.name}
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
</div>
</Tooltip>
<Tooltip
content={dayjs((skill.updated_at ?? skill.created_at) * 1000).format(
'LLLL'
)}
>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs((skill.updated_at ?? skill.created_at) * 1000).fromNow()}
</div>
</Tooltip>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
{#if !skill.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500">
<Tooltip
content={skill?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
</div>
{/if}
</div>
</div>
{#if skill.description}
<Tooltip content={skill.description} className="min-w-0" placement="top-start">
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{skill.description}
</div>
</Tooltip>
{/if}
</div>
{/if}
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={skill?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
{#if skill.write_access}
<div class="flex flex-row gap-0.5 self-center">
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={() => {
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(skill);
}}
>
<GarbageBin />
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<SkillMenu
editHandler={() => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
}}
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={() => {
exportHandler(skill);
}}
deleteHandler={async () => {
selectedSkill = skill;
showDeleteConfirm = true;
}}
onClose={() => {}}
>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
>
<EllipsisHorizontal className="size-4" />
</button>
</SkillMenu>
{/if}
<button on:click|stopPropagation|preventDefault>
<Tooltip content={skill.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={skill.is_active}
on:change={async () => {
toggleSkillById(localStorage.token, skill.id);
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<SkillMenu
show={openSkillMenuId === skill.id}
editHandler={() => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
}}
/>
</Tooltip>
</button>
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={() => {
exportHandler(skill);
}}
deleteHandler={async () => {
selectedSkill = skill;
showDeleteConfirm = true;
}}
onClose={() => {
openSkillMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Skill Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openSkillMenuId = openSkillMenuId === skill.id ? null : skill.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</SkillMenu>
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={skill.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={skill.is_active}
on:change={async () => {
toggleSkillById(localStorage.token, skill.id);
}}
/>
</Tooltip>
</button>
</div>
{/if}
</div>
{/if}
</div>
</Tooltip>
{/each}
{/each}
</div>
</div>
{#if total > 30}
@@ -17,7 +17,12 @@
export let deleteHandler: Function;
export let onClose: Function;
let show = false;
export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script>
<Dropdown
@@ -38,6 +43,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
editHandler();
closeMenu();
}}
>
<svg
@@ -62,6 +68,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
cloneHandler();
closeMenu();
}}
>
<DocumentDuplicate className="size-3.5" />
@@ -73,6 +80,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
exportHandler();
closeMenu();
}}
>
<Download className="size-3.5" />
@@ -86,6 +94,7 @@
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
deleteHandler();
closeMenu();
}}
>
<GarbageBin className="size-3.5" />
+209 -111
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
@@ -36,6 +40,8 @@
import ViewSelector from './common/ViewSelector.svelte';
import CommunityDiscover from './common/CommunityDiscover.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
let shiftKey = false;
let loaded = false;
@@ -58,6 +64,9 @@
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openToolMenuId: string | null = null;
let showImportModal = false;
@@ -110,12 +119,17 @@
}, 300);
};
$: if (tools && viewOption !== undefined) {
$: if (
tools &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
setFilteredItems();
}
const setFilteredItems = () => {
filteredItems = tools.filter((t) => {
const filtered = tools.filter((t) => {
if (query === '' && viewOption === '') return true;
const lowerQuery = query.toLowerCase();
return (
@@ -128,6 +142,33 @@
(viewOption === 'shared' && t.user_id !== $user?.id))
);
});
filteredItems = [...filtered].sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1;
if (sortKey === 'name') {
return direction * (a.name ?? '').localeCompare(b.name ?? '');
}
return direction * ((a.updated_at ?? 0) - (b.updated_at ?? 0));
});
};
const setSortKey = (key: string) => {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
}
};
const openTool = (tool) => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const shareHandler = async (tool) => {
@@ -329,127 +370,173 @@
</div>
{#if (filteredItems ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2">
{#each filteredItems as tool}
<Tooltip content={tool?.meta?.description ?? tool?.id}>
<div class="my-1">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
>
{$i18n.t('Title')}
{#if sortKey === 'name'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
type="button"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{#if sortDirection === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
{/if}
</button>
</div>
<div class="grid gap-y-0.5">
{#each filteredItems as tool}
<div
class="flex w-full rounded-xl px-2 py-1 text-left transition {tool.write_access
? 'cursor-pointer hover:bg-gray-50/60 dark:hover:bg-gray-850/40'
class="group flex min-h-8 w-full items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left {tool.write_access
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'}"
role="button"
tabindex={tool.write_access ? 0 : -1}
on:click={(e) => {
if (!tool.write_access || shouldIgnoreRowClick(e.target)) return;
openTool(tool);
}}
on:keydown={(e) => {
if (!tool.write_access || e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTool(tool);
}
}}
>
{#if tool.write_access}
<a
class="flex w-full min-w-0 flex-1 cursor-pointer"
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`}
>
<div class="flex min-w-0 items-center text-left">
<div class="min-w-0 flex-1 self-center">
<Tooltip content={tool.id} placement="top-start">
<div class="flex min-w-0 items-center gap-2">
<div class="line-clamp-1 text-sm">
{tool.name}
</div>
{#if tool?.meta?.manifest?.version}
<div class=" text-gray-500 text-xs font-normal shrink-0">
v{tool?.meta?.manifest?.version ?? ''}
</div>
{/if}
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip content={tool.id} className="min-w-0" placement="top-start">
<div
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{tool.name}
</div>
</Tooltip>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500">
<Tooltip
content={tool?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
{#if tool?.meta?.manifest?.version}
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
>
v{tool?.meta?.manifest?.version ?? ''}
</div>
</div>
</div>
</div>
</a>
{:else}
<div class="flex w-full min-w-0 flex-1">
<div class="flex w-full min-w-0 items-center text-left">
<div class="w-full min-w-0 flex-1 self-center">
<div class="flex items-center justify-between w-full gap-2">
<Tooltip content={tool.id} placement="top-start">
<div class="flex min-w-0 items-center gap-2">
<div class="line-clamp-1 text-sm">
{tool.name}
</div>
{#if tool?.meta?.manifest?.version}
<div class=" text-gray-500 text-xs font-normal shrink-0">
v{tool?.meta?.manifest?.version ?? ''}
</div>
{/if}
</div>
</Tooltip>
{/if}
<Tooltip content={dayjs(tool.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
>
{dayjs(tool.updated_at * 1000).fromNow()}
</div>
</Tooltip>
{#if !tool.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500">
<Tooltip
content={tool?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
</div>
{/if}
</div>
</div>
{#if tool?.meta?.description}
<Tooltip
content={tool?.meta?.description}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{tool?.meta?.description}
</div>
</Tooltip>
{/if}
</div>
{/if}
</div>
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={tool?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
</div>
{#if tool.write_access}
<div class="flex flex-row gap-0.5 self-center">
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Delete')}
on:click={() => {
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(tool);
}}
>
<GarbageBin />
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
{#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support">
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Support')}
on:click={() => {
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool;
showManifestModal = true;
}}
>
<Heart />
<Heart className="size-4" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Valves')}
on:click={() => {
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool;
showValvesModal = true;
}}
@@ -476,38 +563,49 @@
</button>
</Tooltip>
<ToolMenu
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
onClose={() => {}}
>
<button
class="self-center w-fit rounded-lg p-1 text-sm hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
<div class="ml-0.5 flex shrink-0 flex-row items-center self-center">
<ToolMenu
show={openToolMenuId === tool.id}
editHandler={() => {
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
}}
cloneHandler={() => {
cloneHandler(tool);
}}
exportHandler={() => {
exportHandler(tool);
}}
deleteHandler={async () => {
selectedTool = tool;
showDeleteConfirm = true;
}}
onClose={() => {
openToolMenuId = null;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</ToolMenu>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
type="button"
aria-label={$i18n.t('Tool Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openToolMenuId = openToolMenuId === tool.id ? null : tool.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</ToolMenu>
</div>
{/if}
</div>
{/if}
</div>
</Tooltip>
{/each}
{/each}
</div>
</div>
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
@@ -20,7 +20,12 @@
export let deleteHandler: Function;
export let onClose: Function;
let show = false;
export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script>
<Dropdown
@@ -42,6 +47,7 @@
draggable="false"
on:click={() => {
editHandler();
closeMenu();
}}
>
<svg
@@ -68,6 +74,7 @@
draggable="false"
on:click={() => {
shareHandler();
closeMenu();
}}
>
<Share className="size-3.5" />
@@ -80,6 +87,7 @@
draggable="false"
on:click={() => {
cloneHandler();
closeMenu();
}}
>
<DocumentDuplicate className="size-3.5" />
@@ -93,6 +101,7 @@
draggable="false"
on:click={() => {
exportHandler();
closeMenu();
}}
>
<Download className="size-3.5" />
@@ -108,6 +117,7 @@
draggable="false"
on:click={() => {
deleteHandler();
closeMenu();
}}
>
<GarbageBin className="size-3.5" />
@@ -10,7 +10,7 @@
export let description = '';
</script>
<div class="mt-6 px-2">
<div class="mt-6 px-2 pb-8">
<div class="mb-0.5 text-[11px] font-normal text-gray-400 dark:text-gray-600">
{$i18n.t('Made by Open WebUI Community')}
</div>