This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-17 04:48:21 -04:00
1 parent 56f2cb5302
commit e5e2cd7876
12 files changed
+1219 -686

No files matched your search

+10
View File
@@ -149,6 +149,7 @@ class KnowledgeDirectoryForm(BaseModel):
#################### ####################
class KnowledgeUserModel(KnowledgeModel): class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
file_count: int | None = None
class KnowledgeResponse(KnowledgeModel): class KnowledgeResponse(KnowledgeModel):
@@ -322,6 +323,14 @@ class KnowledgeTable:
knowledge_ids = [kb.id for kb, _ in items] knowledge_ids = [kb.id for kb, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) 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 = [] knowledge_bases = []
for knowledge_base, user in items: for knowledge_base, user in items:
@@ -336,6 +345,7 @@ class KnowledgeTable:
) )
).model_dump(), ).model_dump(),
'user': (UserModel.model_validate(user).model_dump() if user else None), '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"> <script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores'; import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
@@ -39,6 +43,8 @@
import { capitalizeFirstLetter } from '$lib/utils'; import { capitalizeFirstLetter } from '$lib/utils';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import SplitCreateButton from '../common/SplitCreateButton.svelte'; import SplitCreateButton from '../common/SplitCreateButton.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -49,6 +55,9 @@
let tagsContainerElement: HTMLDivElement; let tagsContainerElement: HTMLDivElement;
let viewOption = ''; let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openFunctionMenuId: string | null = null;
let query = ''; let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>; 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(); setFilteredItems();
} }
@@ -109,7 +124,32 @@
(viewOption === 'created' && f.user_id === $user?.id) || (viewOption === 'created' && f.user_id === $user?.id) ||
(viewOption === 'shared' && 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 shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => { const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
@@ -407,173 +447,275 @@
</div> </div>
{#if (filteredItems ?? []).length !== 0} {#if (filteredItems ?? []).length !== 0}
<div class="my-1 gap-x-2 gap-y-0.5 grid lg:grid-cols-2"> <div class="my-1">
{#each filteredItems as func (func.id)} <div
<div class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
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" >
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
type="button"
on:click={() => setSortKey('name')}
> >
<a {$i18n.t('Title')}
class=" flex flex-1 space-x-3.5 cursor-pointer w-full" {#if sortKey === 'name'}
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`} {#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 min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class=" flex-1 self-center pl-1"> <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<Tooltip content={func.id} placement="top-start"> <div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class=" flex items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div <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} {func.type}
</div> </div>
<div class="line-clamp-1 text-sm"> <Tooltip content={func.id} className="min-w-0" placement="top-start">
{func.name} <div
</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} {#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 ?? ''} v{func?.meta?.manifest?.version ?? ''}
</div> </div>
{/if} {/if}
</div>
</Tooltip>
<div class="flex gap-1.5 px-1"> <Tooltip content={dayjs(func.updated_at * 1000).format('LLLL')}>
<div class="text-xs text-gray-500 shrink-0"> <div
<Tooltip class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
content={func?.user?.email ?? $i18n.t('Deleted User')} >
className="flex shrink-0" {dayjs(func.updated_at * 1000).fromNow()}
placement="top-start" </div>
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip> </Tooltip>
</div> </div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
</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>
</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')}> <div
<button 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"
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" <Tooltip
on:click={() => { content={func?.user?.email ?? $i18n.t('Deleted User')}
selectedFunction = func; className="min-w-0"
showValvesModal = true; placement="top-start"
}}
>
<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={() => {}}
> >
<button <div class="truncate">
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" {capitalizeFirstLetter(
type="button" func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
> )}
<EllipsisHorizontal className="size-5" /> </div>
</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
)
);
}}
/>
</Tooltip> </Tooltip>
</div> </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>
</div> {/each}
{/each} </div>
</div> </div>
{:else} {:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24"> <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; export let onClose: Function;
let show = false; export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script> </script>
<Dropdown <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" 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={() => { on:click={() => {
editHandler(); editHandler();
closeMenu();
}} }}
> >
<svg <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" 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={() => { on:click={() => {
shareHandler(); shareHandler();
closeMenu();
}} }}
> >
<Share /> <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" 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={() => { on:click={() => {
cloneHandler(); cloneHandler();
closeMenu();
}} }}
> >
<DocumentDuplicate /> <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" 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={() => { on:click={() => {
exportHandler(); exportHandler();
closeMenu();
}} }}
> >
<Download /> <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" 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={() => { on:click={() => {
deleteHandler(); deleteHandler();
closeMenu();
}} }}
> >
<GarbageBin strokeWidth="2" /> <GarbageBin strokeWidth="2" />
+159 -83
View File
@@ -10,7 +10,7 @@
const i18n = getContext<Writable<i18nType>>('i18n'); const i18n = getContext<Writable<i18nType>>('i18n');
import { WEBUI_NAME, knowledge, user, workspaceActions } from '$lib/stores'; import { WEBUI_NAME, user, workspaceActions } from '$lib/stores';
import { import {
deleteKnowledgeById, deleteKnowledgeById,
searchKnowledgeBases, searchKnowledgeBases,
@@ -38,6 +38,7 @@
name: string; name: string;
description?: string; description?: string;
updated_at: number; updated_at: number;
file_count?: number;
write_access?: boolean; write_access?: boolean;
meta?: any; meta?: any;
user?: { 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 () => { onMount(async () => {
viewOption = localStorage?.workspaceViewOption || ''; viewOption = localStorage?.workspaceViewOption || '';
sourceOption = localStorage?.workspaceKnowledgeSourceOption || ''; sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
@@ -297,102 +351,124 @@
{#if items !== null && total !== null} {#if items !== null && total !== null}
{#if (items ?? []).length !== 0} {#if (items ?? []).length !== 0}
<div class="my-1 grid grid-cols-1 gap-x-2 gap-y-0.5 lg:grid-cols-2"> <div class="my-1">
{#each items as item} <div
<button class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
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={() => { <div class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left">
if (item?.meta?.document) { {$i18n.t('Title')}
toast.error( </div>
$i18n.t(
'Only collections can be edited, create a new knowledge base to edit/add documents.' <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">
} else { {$i18n.t('Updated at')}
goto(`/workspace/knowledge/${item.id}`); </div>
} </div>
}}
> <div class="grid gap-y-0.5">
<div class="w-full min-w-0"> {#each items as item}
<div class="flex-1 self-center justify-between"> {@const metaPreview = getKnowledgeMetaPreview(item)}
<div class="flex h-7 items-center justify-between"> <div
<div class=" flex gap-2 items-center justify-between w-full"> class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
{#if item?.meta?.source === 'external'} role="button"
<div> 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 <Badge
type="muted" type="muted"
content={item?.meta?.external?.provider ?? $i18n.t('Connected')} content={item?.meta?.external?.provider ?? $i18n.t('Connected')}
/> />
</div>
<div>
<Badge type="muted" content={$i18n.t('Read Only')} /> <Badge type="muted" content={$i18n.t('Read Only')} />
</div> {/if}
{:else}
<div>
<Badge type="success" content={$i18n.t('Collection')} />
</div>
{/if}
{#if !item?.write_access && item?.meta?.source !== 'external'} {#if !item?.write_access && item?.meta?.source !== 'external'}
<div>
<Badge type="muted" content={$i18n.t('Read Only')} /> <Badge type="muted" content={$i18n.t('Read Only')} />
</div> {/if}
{/if}
</div>
{#if item?.write_access || $user?.role === 'admin'} <Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
<div class="flex items-center gap-2"> <div
<div class=" flex self-center"> class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
<ItemMenu >
onExport={$user?.role === 'admin' {dayjs(item.updated_at * 1000).fromNow()}
? () => { </div>
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> </Tooltip>
</div> </div>
</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> </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> </div>
</button> {/each}
{/each} </div>
</div> </div>
{#if !allItemsLoaded} {#if !allItemsLoaded}
@@ -15,6 +15,11 @@
export let onClose: Function = () => {}; export let onClose: Function = () => {};
let show = false; let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script> </script>
<Dropdown <Dropdown
@@ -29,12 +34,13 @@
<Tooltip content={$i18n.t('More')}> <Tooltip content={$i18n.t('More')}>
<slot <slot
><button ><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" type="button"
aria-label={$i18n.t('More Options')} aria-label={$i18n.t('More Options')}
on:click={(e) => { on:click={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
show = true; show = !show;
}} }}
> >
<EllipsisHorizontal className="size-4" /> <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" 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={() => { on:click={() => {
onExport(); onExport();
closeMenu();
}} }}
> >
<Download className="size-3.5" /> <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" 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={() => { on:click={() => {
dispatch('delete'); dispatch('delete');
closeMenu();
}} }}
> >
<GarbageBin className="size-3.5" /> <GarbageBin className="size-3.5" />
+266 -198
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { marked } from 'marked'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
@@ -10,6 +10,7 @@
import { onMount, getContext, tick } from 'svelte'; import { onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
dayjs.extend(relativeTime);
import { import {
WEBUI_NAME, WEBUI_NAME,
@@ -20,7 +21,7 @@
user, user,
workspaceActions workspaceActions
} from '$lib/stores'; } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import { import {
createNewModel, createNewModel,
deleteModelById, deleteModelById,
@@ -50,6 +51,7 @@
import EyeSlash from '../icons/EyeSlash.svelte'; import EyeSlash from '../icons/EyeSlash.svelte';
import Eye from '../icons/Eye.svelte'; import Eye from '../icons/Eye.svelte';
import ChevronDown from '../icons/ChevronDown.svelte'; import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@@ -78,6 +80,8 @@
let query = ''; let query = '';
let viewOption = ''; let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let page = 1; let page = 1;
let models = null; 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(); 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 () => { const getModelList = async () => {
if (!loaded) return; if (!loaded) return;
@@ -122,8 +152,8 @@
query, query,
viewOption, viewOption,
selectedTag, selectedTag,
null, sortKey,
null, sortDirection,
page page
).catch((error) => { ).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
@@ -601,216 +631,254 @@
{#if models !== null} {#if models !== null}
{#if (models ?? []).length !== 0} {#if (models ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2" id="model-list"> <div class="my-1" id="model-list">
{#each models as model (model.id)} <div
<!-- svelte-ignore a11y_no_static_element_interactions --> class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
<!-- svelte-ignore a11y_click_events_have_key_events --> >
<div <button
class="flex w-full rounded-xl px-2 py-1 transition {model.write_access class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
? 'cursor-pointer hover:bg-gray-50/60 dark:hover:bg-gray-850/40' type="button"
: ''}" on:click={() => setSortKey('name')}
id="model-item-{model.id}"
on:click={() => {
if (model.write_access) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
}}
> >
<div class="flex group/item min-w-0 gap-2.5 w-full"> {$i18n.t('Title')}
<div class="self-center"> {#if sortKey === 'name'}
<div class="flex"> {#if sortDirection === 'asc'}
<div <ChevronUp className="size-2" />
class="{model.is_active {:else}
? '' <ChevronDown className="size-2" />
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-xl" {/if}
> {/if}
<img </button>
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile" <div class="hidden w-44 shrink-0 md:block"></div>
class="size-8 rounded-xl object-cover"
loading="lazy" <button
decoding="async" class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
on:error={(e) => { type="button"
e.target.src = '/favicon.png'; on:click={() => setSortKey('updated_at')}
}} >
/> {$i18n.t('Updated at')}
</div> {#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> </div>
<div class="flex min-w-0 w-full flex-1 self-center pr-1"> <div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group"> <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="min-w-0 flex-1 w-full"> <div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 items-center justify-between w-full gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<Tooltip <Tooltip content={model.name} className="min-w-0" placement="top-start">
content={model.name} <div
className="min-w-0 flex-1" class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
placement="top-start"
>
<a
class="line-clamp-1 text-sm font-normal capitalize hover:underline"
href={`/?models=${encodeURIComponent(model.id)}`}
> >
{model.name} {model.name}
</a> </div>
</Tooltip> </Tooltip>
<div class="flex shrink-0 items-center gap-1"> <div
{#if !model.write_access} class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
<div> >
<Badge type="muted" content={$i18n.t('Read Only')} /> {model.id}
</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> </div>
</div>
<div class="flex min-w-0 items-center gap-1 pr-2 text-xs text-gray-500"> <Tooltip content={dayjs(model.updated_at * 1000).format('LLLL')}>
<Tooltip <div
content={model?.user?.email ?? $i18n.t('Deleted User')} class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
className="flex shrink-0" >
placement="top-start" {dayjs(model.updated_at * 1000).fromNow()}
>
<div class="shrink-0">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div> </div>
</Tooltip> </Tooltip>
<div class="shrink-0">·</div> {#if !model.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
<Tooltip {/if}
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>
</div> </div>
</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> </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>
</div> {/each}
{/each} </div>
</div> </div>
{#if total > 30} {#if total > 30}
+23 -24
View File
@@ -504,7 +504,7 @@
{:else if (prompts ?? []).length !== 0} {:else if (prompts ?? []).length !== 0}
<div class="my-1"> <div class="my-1">
<div <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 <button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left" class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
@@ -575,39 +575,21 @@
/{prompt.command} /{prompt.command}
</div> </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 <Tooltip
content={dayjs((prompt.updated_at ?? prompt.created_at) * 1000).format( content={dayjs((prompt.updated_at ?? prompt.created_at) * 1000).format(
'LLLL' 'LLLL'
)} )}
> >
<div <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()} {dayjs((prompt.updated_at ?? prompt.created_at) * 1000).fromNow()}
</div> </div>
</Tooltip> </Tooltip>
{#if !prompt.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} />
{/if}
</div> </div>
</div> </div>
@@ -622,6 +604,23 @@
{/if} {/if}
</div> </div>
</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"> <div class="ml-2 flex shrink-0 flex-row items-center self-center">
{#if shiftKey} {#if shiftKey}
<Tooltip content={$i18n.t('Delete')}> <Tooltip content={$i18n.t('Delete')}>
+220 -117
View File
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { onMount, getContext, tick, onDestroy } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -33,6 +37,8 @@
import Switch from '../common/Switch.svelte'; import Switch from '../common/Switch.svelte';
import SkillMenu from './Skills/SkillMenu.svelte'; import SkillMenu from './Skills/SkillMenu.svelte';
import Pagination from '../common/Pagination.svelte'; import Pagination from '../common/Pagination.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
let shiftKey = false; let shiftKey = false;
let loaded = false; let loaded = false;
@@ -52,6 +58,9 @@
let tagsContainerElement: HTMLDivElement; let tagsContainerElement: HTMLDivElement;
let viewOption = ''; let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openSkillMenuId: string | null = null;
let page = 1; let page = 1;
$: if (loaded) { $: if (loaded) {
@@ -93,12 +102,17 @@
loading = true; loading = true;
try { try {
const res = await getSkillItems(localStorage.token, query, viewOption, page).catch( const res = await getSkillItems(
(error) => { localStorage.token,
toast.error(`${error}`); query,
return null; viewOption,
} page,
); sortKey,
sortDirection
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) { if (res) {
filteredItems = res.items; filteredItems = res.items;
@@ -124,10 +138,33 @@
}; };
// Immediate response to page/filter changes // Immediate response to page/filter changes
$: if (loaded && page && viewOption !== undefined) { $: if (
loaded &&
page &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
loadSkillItems(); 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 cloneHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => { const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
@@ -343,141 +380,207 @@
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
{:else if (filteredItems ?? []).length !== 0} {:else if (filteredItems ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2"> <div class="my-1">
{#each filteredItems as skill} <div
<Tooltip content={skill?.description ?? skill?.id}> 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 <div
class="flex w-full rounded-xl px-2 py-1 text-left transition {skill.write_access class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
? 'cursor-pointer hover:bg-gray-50/60 dark:hover:bg-gray-850/40' role="button"
: 'cursor-not-allowed opacity-60'}" 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} <div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<a <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
class="flex w-full min-w-0 flex-1 cursor-pointer" <div class="flex min-w-0 items-center gap-2 overflow-hidden">
href={`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`} <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="flex min-w-0 items-center text-left"> <div
<div class="min-w-0 flex-1 self-center"> class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
<Tooltip content={skill.id} placement="top-start"> >
<div class="flex min-w-0 items-center gap-2"> {skill.name}
<div class="line-clamp-1 text-sm">
{skill.name}
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
</div> </div>
</Tooltip> </Tooltip>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500"> <div
<Tooltip class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
content={skill?.user?.email ?? $i18n.t('Deleted User')} >
className="flex shrink-0" /{skill.id}
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
</div> </div>
</div>
</div> <Tooltip
</a> content={dayjs((skill.updated_at ?? skill.created_at) * 1000).format(
{:else} 'LLLL'
<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
<div class="flex items-center justify-between w-full gap-2"> class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
<Tooltip content={skill.id} placement="top-start"> >
<div class="flex min-w-0 items-center gap-2"> {dayjs((skill.updated_at ?? skill.created_at) * 1000).fromNow()}
<div class="line-clamp-1 text-sm"> </div>
{skill.name} </Tooltip>
</div>
{#if !skill.is_active} {#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} /> <Badge type="muted" content={$i18n.t('Inactive')} />
{/if} {/if}
</div>
</Tooltip> {#if !skill.write_access}
<Badge type="muted" content={$i18n.t('Read Only')} /> <Badge type="muted" content={$i18n.t('Read Only')} />
</div> {/if}
<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>
</div> </div>
</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> </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} {#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} {#if shiftKey}
<Tooltip content={$i18n.t('Delete')}> <Tooltip content={$i18n.t('Delete')}>
<button <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" type="button"
aria-label={$i18n.t('Delete')} aria-label={$i18n.t('Delete')}
on:click={() => { on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(skill); deleteHandler(skill);
}} }}
> >
<GarbageBin /> <GarbageBin className="size-4" />
</button> </button>
</Tooltip> </Tooltip>
{:else} {:else}
<SkillMenu <div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
editHandler={() => { <SkillMenu
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`); show={openSkillMenuId === skill.id}
}} editHandler={() => {
cloneHandler={() => { goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
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);
}} }}
/> cloneHandler={() => {
</Tooltip> cloneHandler(skill);
</button> }}
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> </div>
{/if} {/if}
</div> </div>
</Tooltip> {/each}
{/each} </div>
</div> </div>
{#if total > 30} {#if total > 30}
@@ -17,7 +17,12 @@
export let deleteHandler: Function; export let deleteHandler: Function;
export let onClose: Function; export let onClose: Function;
let show = false; export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script> </script>
<Dropdown <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" 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={() => { on:click={() => {
editHandler(); editHandler();
closeMenu();
}} }}
> >
<svg <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" 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={() => { on:click={() => {
cloneHandler(); cloneHandler();
closeMenu();
}} }}
> >
<DocumentDuplicate className="size-3.5" /> <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" 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={() => { on:click={() => {
exportHandler(); exportHandler();
closeMenu();
}} }}
> >
<Download className="size-3.5" /> <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" 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={() => { on:click={() => {
deleteHandler(); deleteHandler();
closeMenu();
}} }}
> >
<GarbageBin className="size-3.5" /> <GarbageBin className="size-3.5" />
+209 -111
View File
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
dayjs.extend(relativeTime);
import { onMount, getContext, tick, onDestroy } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -36,6 +40,8 @@
import ViewSelector from './common/ViewSelector.svelte'; import ViewSelector from './common/ViewSelector.svelte';
import CommunityDiscover from './common/CommunityDiscover.svelte'; import CommunityDiscover from './common/CommunityDiscover.svelte';
import Badge from '$lib/components/common/Badge.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 shiftKey = false;
let loaded = false; let loaded = false;
@@ -58,6 +64,9 @@
let tagsContainerElement: HTMLDivElement; let tagsContainerElement: HTMLDivElement;
let viewOption = ''; let viewOption = '';
let sortKey = 'updated_at';
let sortDirection = 'desc';
let openToolMenuId: string | null = null;
let showImportModal = false; let showImportModal = false;
@@ -110,12 +119,17 @@
}, 300); }, 300);
}; };
$: if (tools && viewOption !== undefined) { $: if (
tools &&
viewOption !== undefined &&
sortKey !== undefined &&
sortDirection !== undefined
) {
setFilteredItems(); setFilteredItems();
} }
const setFilteredItems = () => { const setFilteredItems = () => {
filteredItems = tools.filter((t) => { const filtered = tools.filter((t) => {
if (query === '' && viewOption === '') return true; if (query === '' && viewOption === '') return true;
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
return ( return (
@@ -128,6 +142,33 @@
(viewOption === 'shared' && t.user_id !== $user?.id)) (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) => { const shareHandler = async (tool) => {
@@ -329,127 +370,173 @@
</div> </div>
{#if (filteredItems ?? []).length !== 0} {#if (filteredItems ?? []).length !== 0}
<div class="my-1 grid gap-x-2 gap-y-0.5 lg:grid-cols-2"> <div class="my-1">
{#each filteredItems as tool} <div
<Tooltip content={tool?.meta?.description ?? tool?.id}> 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 <div
class="flex w-full rounded-xl px-2 py-1 text-left transition {tool.write_access 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 hover:bg-gray-50/60 dark:hover:bg-gray-850/40' ? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'}" : '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} <div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<a <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
class="flex w-full min-w-0 flex-1 cursor-pointer" <div class="flex min-w-0 items-center gap-2 overflow-hidden">
href={`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`} <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="flex min-w-0 items-center text-left"> <div
<div class="min-w-0 flex-1 self-center"> class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
<Tooltip content={tool.id} placement="top-start"> >
<div class="flex min-w-0 items-center gap-2"> {tool.name}
<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> </div>
</Tooltip> </Tooltip>
<div class="px-0.5">
<div class="shrink-0 text-xs text-gray-500"> {#if tool?.meta?.manifest?.version}
<Tooltip <div
content={tool?.user?.email ?? $i18n.t('Deleted User')} class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
className="flex shrink-0" >
placement="top-start" v{tool?.meta?.manifest?.version ?? ''}
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
tool?.user?.name ?? tool?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div> </div>
</div> {/if}
</div>
</div> <Tooltip content={dayjs(tool.updated_at * 1000).format('LLLL')}>
</a> <div
{:else} class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
<div class="flex w-full min-w-0 flex-1"> >
<div class="flex w-full min-w-0 items-center text-left"> {dayjs(tool.updated_at * 1000).fromNow()}
<div class="w-full min-w-0 flex-1 self-center"> </div>
<div class="flex items-center justify-between w-full gap-2"> </Tooltip>
<Tooltip content={tool.id} placement="top-start">
<div class="flex min-w-0 items-center gap-2"> {#if !tool.write_access}
<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>
<Badge type="muted" content={$i18n.t('Read Only')} /> <Badge type="muted" content={$i18n.t('Read Only')} />
</div> {/if}
<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>
</div> </div>
</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> </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} {#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} {#if shiftKey}
<Tooltip content={$i18n.t('Delete')}> <Tooltip content={$i18n.t('Delete')}>
<button <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" type="button"
aria-label={$i18n.t('Delete')} aria-label={$i18n.t('Delete')}
on:click={() => { on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteHandler(tool); deleteHandler(tool);
}} }}
> >
<GarbageBin /> <GarbageBin className="size-4" />
</button> </button>
</Tooltip> </Tooltip>
{:else} {:else}
{#if tool?.meta?.manifest?.funding_url ?? false} {#if tool?.meta?.manifest?.funding_url ?? false}
<Tooltip content="Support"> <Tooltip content="Support">
<button <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" type="button"
aria-label={$i18n.t('Support')} aria-label={$i18n.t('Support')}
on:click={() => { on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool; selectedTool = tool;
showManifestModal = true; showManifestModal = true;
}} }}
> >
<Heart /> <Heart className="size-4" />
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
<Tooltip content={$i18n.t('Valves')}> <Tooltip content={$i18n.t('Valves')}>
<button <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" type="button"
aria-label={$i18n.t('Valves')} aria-label={$i18n.t('Valves')}
on:click={() => { on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectedTool = tool; selectedTool = tool;
showValvesModal = true; showValvesModal = true;
}} }}
@@ -476,38 +563,49 @@
</button> </button>
</Tooltip> </Tooltip>
<ToolMenu <div class="ml-0.5 flex shrink-0 flex-row items-center self-center">
editHandler={() => { <ToolMenu
goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`); show={openToolMenuId === tool.id}
}} editHandler={() => {
shareHandler={() => { goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
shareHandler(tool); }}
}} shareHandler={() => {
cloneHandler={() => { shareHandler(tool);
cloneHandler(tool); }}
}} cloneHandler={() => {
exportHandler={() => { cloneHandler(tool);
exportHandler(tool); }}
}} exportHandler={() => {
deleteHandler={async () => { exportHandler(tool);
selectedTool = tool; }}
showDeleteConfirm = true; deleteHandler={async () => {
}} selectedTool = tool;
onClose={() => {}} showDeleteConfirm = true;
> }}
<button onClose={() => {
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" openToolMenuId = null;
type="button" }}
> >
<EllipsisHorizontal className="size-4" /> <button
</button> class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
</ToolMenu> 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} {/if}
</div> </div>
{/if} {/if}
</div> </div>
</Tooltip> {/each}
{/each} </div>
</div> </div>
{:else} {:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24"> <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 deleteHandler: Function;
export let onClose: Function; export let onClose: Function;
let show = false; export let show = false;
const closeMenu = () => {
show = false;
onClose();
};
</script> </script>
<Dropdown <Dropdown
@@ -42,6 +47,7 @@
draggable="false" draggable="false"
on:click={() => { on:click={() => {
editHandler(); editHandler();
closeMenu();
}} }}
> >
<svg <svg
@@ -68,6 +74,7 @@
draggable="false" draggable="false"
on:click={() => { on:click={() => {
shareHandler(); shareHandler();
closeMenu();
}} }}
> >
<Share className="size-3.5" /> <Share className="size-3.5" />
@@ -80,6 +87,7 @@
draggable="false" draggable="false"
on:click={() => { on:click={() => {
cloneHandler(); cloneHandler();
closeMenu();
}} }}
> >
<DocumentDuplicate className="size-3.5" /> <DocumentDuplicate className="size-3.5" />
@@ -93,6 +101,7 @@
draggable="false" draggable="false"
on:click={() => { on:click={() => {
exportHandler(); exportHandler();
closeMenu();
}} }}
> >
<Download className="size-3.5" /> <Download className="size-3.5" />
@@ -108,6 +117,7 @@
draggable="false" draggable="false"
on:click={() => { on:click={() => {
deleteHandler(); deleteHandler();
closeMenu();
}} }}
> >
<GarbageBin className="size-3.5" /> <GarbageBin className="size-3.5" />
@@ -10,7 +10,7 @@
export let description = ''; export let description = '';
</script> </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"> <div class="mb-0.5 text-[11px] font-normal text-gray-400 dark:text-gray-600">
{$i18n.t('Made by Open WebUI Community')} {$i18n.t('Made by Open WebUI Community')}
</div> </div>