feat: channel webhooks

This commit is contained in:
Timothy Jaeryang Baek
2026-01-09 02:30:15 +04:00
parent 48bdb3f266
commit cd296fcf0d
10 changed files with 1012 additions and 12 deletions

View File

@@ -763,3 +763,155 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
return res;
};
// Webhook API functions
type WebhookForm = {
name: string;
profile_image_url?: string;
};
export const getChannelWebhooks = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createChannelWebhook = async (
token: string = '',
channel_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/update`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/delete`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View File

@@ -119,8 +119,8 @@
if (type === 'message') {
if ((data?.parent_id ?? null) === null) {
const tempId = data?.temp_id ?? null;
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => m?.temp_id !== tempId)];
const tempId = data?.temp_id ?? null;
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => !tempId || m?.temp_id !== tempId)];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);

View File

@@ -131,8 +131,9 @@
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
showUserProfile={messageIdx === 0 ||
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.user?.id !== message.user?.id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message !== null}
onDelete={() => {

View File

@@ -245,7 +245,7 @@
/>
{:else}
<img
src={`${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
src={message.reply_to_message.user?.role === 'webhook' ? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image` : `${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
@@ -277,10 +277,10 @@
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
/>
{:else}
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user.id}/profile/image`}
src={message.user?.role === 'webhook' ? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image` : `${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Clipboard from '$lib/components/icons/Clipboard.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
export let webhook;
export let expanded = false;
export let onClick = () => {};
export let onDelete = () => {};
export let onUpdate = (changes: { name: string; profile_image_url: string }) => {};
let name = webhook.name;
let image = webhook.profile_image_url || '';
// Notify parent when changes occur
$: if (name !== webhook.name || image !== (webhook.profile_image_url || '')) {
onUpdate({ name: name.trim() || webhook.name, profile_image_url: image });
}
let filesInputElement;
let inputFiles;
const handleImageUpload = () => {
if (!inputFiles?.length) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = `${event.target?.result}`;
const fileType = inputFiles[0]?.type;
if (['image/gif', 'image/webp'].includes(fileType)) {
image = dataUrl;
} else {
const tempImage = new Image();
tempImage.src = dataUrl;
tempImage.onload = () => {
const canvas = document.createElement('canvas');
const canvasSize = 100;
canvas.width = canvasSize;
canvas.height = canvasSize;
const context = canvas.getContext('2d');
const aspectRatio = tempImage.width / tempImage.height;
const scaledWidth = aspectRatio > 1 ? canvasSize * aspectRatio : canvasSize;
const scaledHeight = aspectRatio > 1 ? canvasSize : canvasSize / aspectRatio;
const offsetX = (canvasSize - scaledWidth) / 2;
const offsetY = (canvasSize - scaledHeight) / 2;
context.drawImage(tempImage, offsetX, offsetY, scaledWidth, scaledHeight);
image = canvas.toDataURL('image/webp', 0.8);
};
}
inputFiles = null;
};
reader.readAsDataURL(inputFiles[0]);
};
const copyUrl = () => {
navigator.clipboard.writeText(
`${WEBUI_API_BASE_URL}/channels/webhooks/${webhook.id}/${webhook.token}`
);
toast.success($i18n.t('Copied'));
};
</script>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
accept="image/*"
on:change={handleImageUpload}
/>
<div class="text-xs -mx-1">
<!-- Row -->
<button
type="button"
class="w-full flex items-center gap-3 px-3.5 py-3 hover:bg-gray-50 dark:hover:bg-gray-900 rounded-xl transition"
on:click={onClick}
>
<img
src={image || `${WEBUI_BASE_URL}/static/favicon.png`}
class="rounded-full size-8 object-cover flex-shrink-0"
alt=""
/>
<div class="flex-1 text-left min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">
{name}
</div>
<div class="text-gray-500 text-xs">
{$i18n.t('Created on {{date}}', {
date: dayjs(webhook.created_at / 1000000).format('MMM D, YYYY')
})}
{#if webhook.user?.name}
{$i18n.t('by {{name}}', { name: webhook.user.name })}
{/if}
</div>
</div>
<ChevronDown
className="size-3.5 text-gray-400 transition-transform duration-200 {expanded
? 'rotate-180'
: ''}"
/>
</button>
<!-- Expanded -->
{#if expanded}
<div class="mt-1 mb-3 px-3.5 py-3 border border-gray-100 dark:border-gray-850 rounded-2xl">
<div class="flex items-center gap-3">
<button
type="button"
class="shrink-0 rounded-xl overflow-hidden hover:opacity-80 transition"
on:click={() => filesInputElement.click()}
>
<img
src={image || `${WEBUI_BASE_URL}/static/favicon.png`}
class="size-8 object-cover"
alt=""
/>
</button>
<div class="flex-1">
<div class=" text-gray-500 text-xs">{$i18n.t('Name')}</div>
<input
type="text"
class="w-full text-sm bg-transparent outline-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={name}
placeholder={$i18n.t('Webhook Name')}
/>
</div>
<div class="flex items-center gap-1">
<Tooltip content={$i18n.t('Copy URL')}>
<button
type="button"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={copyUrl}
>
<Clipboard className="size-4 text-gray-500" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
type="button"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={onDelete}
>
<GarbageBin className="size-4 text-gray-500" />
</button>
</Tooltip>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import {
getChannelWebhooks,
createChannelWebhook,
updateChannelWebhook,
deleteChannelWebhook
} from '$lib/apis/channels';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import WebhookItem from './WebhookItem.svelte';
const i18n = getContext('i18n');
export let show = false;
export let channel = null;
let webhooks = [];
let isLoading = false;
let isSaving = false;
let showDeleteConfirmDialog = false;
let selectedWebhookId = null;
// Track pending changes from child components
let pendingChanges: { [webhookId: string]: { name: string; profile_image_url: string } } = {};
const loadWebhooks = async () => {
isLoading = true;
try {
webhooks = await getChannelWebhooks(localStorage.token, channel.id);
} catch {
webhooks = [];
}
isLoading = false;
};
const createHandler = async () => {
isSaving = true;
try {
const newWebhook = await createChannelWebhook(localStorage.token, channel.id, {
name: 'New Webhook'
});
if (newWebhook) {
webhooks = [...webhooks, newWebhook];
selectedWebhookId = newWebhook.id;
}
} catch (error) {
toast.error(`${error}`);
}
isSaving = false;
};
const saveHandler = async () => {
isSaving = true;
try {
for (const [webhookId, changes] of Object.entries(pendingChanges)) {
await updateChannelWebhook(localStorage.token, channel.id, webhookId, changes);
}
pendingChanges = {};
await loadWebhooks();
toast.success($i18n.t('Saved'));
} catch (error) {
toast.error(`${error}`);
}
isSaving = false;
};
const deleteHandler = async () => {
if (!selectedWebhookId) return;
try {
await deleteChannelWebhook(localStorage.token, channel.id, selectedWebhookId);
webhooks = webhooks.filter((webhook) => webhook.id !== selectedWebhookId);
toast.success($i18n.t('Deleted'));
} catch (error) {
toast.error(`${error}`);
}
selectedWebhookId = null;
showDeleteConfirmDialog = false;
};
$: if (show && channel) {
loadWebhooks();
selectedWebhookId = null;
pendingChanges = {};
}
</script>
<ConfirmDialog bind:show={showDeleteConfirmDialog} on:confirm={deleteHandler} />
{#if channel}
<Modal size="sm" bind:show>
<div>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="flex w-full justify-between items-center mr-3">
<div class="self-center text-base flex gap-1.5 items-center">
<div>{$i18n.t('Webhooks')}</div>
<span class="text-sm text-gray-500">{webhooks.length}</span>
</div>
<button
type="button"
class="px-3 py-1.5 gap-1 rounded-xl bg-gray-100/50 dark:bg-gray-850/50 text-black dark:text-white transition font-medium text-xs flex items-center justify-center"
on:click={createHandler}
disabled={isSaving}
>
<Plus className="size-3.5" />
<span>{$i18n.t('New Webhook')}</span>
</button>
</div>
<button class="self-center" on:click={() => (show = false)}>
<XMark className="size-5" />
</button>
</div>
<div class="flex flex-col w-full px-4 pb-4 dark:text-gray-200">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
saveHandler();
}}
>
{#if isLoading}
<div class="flex justify-center py-10">
<Spinner className="size-5" />
</div>
{:else if webhooks.length > 0}
<div class="w-full py-2">
{#each webhooks as webhook (webhook.id)}
<WebhookItem
{webhook}
expanded={selectedWebhookId === webhook.id}
onClick={() => {
selectedWebhookId = selectedWebhookId === webhook.id ? null : webhook.id;
}}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
onUpdate={(changes) => {
pendingChanges[webhook.id] = changes;
}}
/>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-8 px-10">
{$i18n.t('No webhooks yet')}
</div>
{/if}
<div class="flex justify-end text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {isSaving
? 'cursor-not-allowed'
: ''}"
type="submit"
disabled={isSaving}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</Modal>
{/if}

View File

@@ -17,6 +17,7 @@
import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
import Visibility from '$lib/components/workspace/common/Visibility.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import WebhooksModal from '$lib/components/channel/WebhooksModal.svelte';
export let show = false;
export let onSubmit: Function = () => {};
@@ -97,6 +98,7 @@
}
let showDeleteConfirmDialog = false;
let showWebhooksModal = false;
const deleteHandler = async () => {
showDeleteConfirmDialog = false;
@@ -126,7 +128,7 @@
};
</script>
<Modal size="sm" bind:show>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" text-lg font-medium self-center">
@@ -247,6 +249,22 @@
</div>
{/if}
{#if edit}
<div class="flex w-full mt-2 items-center justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Webhooks')}</div>
<button
class="text-xs bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden text-left"
type="button"
on:click={() => {
showWebhooksModal = true;
}}
>
{$i18n.t('Manage')}
</button>
</div>
{/if}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
@@ -294,3 +312,5 @@
deleteHandler();
}}
/>
<WebhooksModal bind:show={showWebhooksModal} {channel} />