Selaa lähdekoodia

Merge pull request #2585 from open-webui/banners

feat: banners
Timothy Jaeryang Baek 11 kuukautta sitten
vanhempi
commit
3a737af190

+ 2 - 0
backend/apps/webui/main.py

@@ -23,6 +23,7 @@ from config import (
     WEBHOOK_URL,
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     JWT_EXPIRES_IN,
+    WEBUI_BANNERS,
     AppConfig,
 )
 
@@ -40,6 +41,7 @@ app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
 app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
 app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
 app.state.config.WEBHOOK_URL = WEBHOOK_URL
+app.state.config.BANNERS = WEBUI_BANNERS
 
 
 app.state.MODELS = {}

+ 30 - 0
backend/apps/webui/routers/configs.py

@@ -8,6 +8,8 @@ from pydantic import BaseModel
 import time
 import uuid
 
+from config import BannerModel
+
 from apps.webui.models.users import Users
 
 from utils.utils import (
@@ -57,3 +59,31 @@ async def set_global_default_suggestions(
     data = form_data.model_dump()
     request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"]
     return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS
+
+
+############################
+# SetBanners
+############################
+
+
+class SetBannersForm(BaseModel):
+    banners: List[BannerModel]
+
+
+@router.post("/banners", response_model=List[BannerModel])
+async def set_banners(
+    request: Request,
+    form_data: SetBannersForm,
+    user=Depends(get_admin_user),
+):
+    data = form_data.model_dump()
+    request.app.state.config.BANNERS = data["banners"]
+    return request.app.state.config.BANNERS
+
+
+@router.get("/banners", response_model=List[BannerModel])
+async def get_banners(
+    request: Request,
+    user=Depends(get_current_user),
+):
+    return request.app.state.config.BANNERS

+ 18 - 0
backend/config.py

@@ -8,6 +8,8 @@ from chromadb import Settings
 from base64 import b64encode
 from bs4 import BeautifulSoup
 from typing import TypeVar, Generic, Union
+from pydantic import BaseModel
+from typing import Optional
 
 from pathlib import Path
 import json
@@ -566,6 +568,22 @@ WEBHOOK_URL = PersistentConfig(
 
 ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true"
 
+
+class BannerModel(BaseModel):
+    id: str
+    type: str
+    title: Optional[str] = None
+    content: str
+    dismissible: bool
+    timestamp: int
+
+
+WEBUI_BANNERS = PersistentConfig(
+    "WEBUI_BANNERS",
+    "ui.banners",
+    [BannerModel(**banner) for banner in json.loads("[]")],
+)
+
 ####################################
 # WEBUI_SECRET_KEY
 ####################################

+ 58 - 0
src/lib/apis/configs/index.ts

@@ -1,4 +1,5 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
+import type { Banner } from '$lib/types';
 
 export const setDefaultModels = async (token: string, models: string) => {
 	let error = null;
@@ -59,3 +60,60 @@ export const setDefaultPromptSuggestions = async (token: string, promptSuggestio
 
 	return res;
 };
+
+export const getBanners = async (token: string): Promise<Banner[]> => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/banners`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const setBanners = async (token: string, banners: Banner[]) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/banners`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			banners: banners
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 136 - 0
src/lib/components/admin/Settings/Banners.svelte

@@ -0,0 +1,136 @@
+<script lang="ts">
+	import { v4 as uuidv4 } from 'uuid';
+
+	import { getContext, onMount } from 'svelte';
+	import { banners as _banners } from '$lib/stores';
+	import type { Banner } from '$lib/types';
+
+	import { getBanners, setBanners } from '$lib/apis/configs';
+
+	import type { Writable } from 'svelte/store';
+	import type { i18n as i18nType } from 'i18next';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Switch from '$lib/components/common/Switch.svelte';
+	const i18n: Writable<i18nType> = getContext('i18n');
+
+	export let saveHandler: Function;
+
+	let banners: Banner[] = [];
+
+	onMount(async () => {
+		banners = await getBanners(localStorage.token);
+	});
+
+	const updateBanners = async () => {
+		_banners.set(await setBanners(localStorage.token, banners));
+	};
+</script>
+
+<form
+	class="flex flex-col h-full justify-between space-y-3 text-sm"
+	on:submit|preventDefault={async () => {
+		updateBanners();
+		saveHandler();
+	}}
+>
+	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
+		<div class=" space-y-3 pr-1.5">
+			<div class="flex w-full justify-between mb-2">
+				<div class=" self-center text-sm font-semibold">
+					{$i18n.t('Banners')}
+				</div>
+
+				<button
+					class="p-1 px-3 text-xs flex rounded transition"
+					type="button"
+					on:click={() => {
+						if (banners.length === 0 || banners.at(-1).content !== '') {
+							banners = [
+								...banners,
+								{
+									id: uuidv4(),
+									type: '',
+									title: '',
+									content: '',
+									dismissible: true,
+									timestamp: Math.floor(Date.now() / 1000)
+								}
+							];
+						}
+					}}
+				>
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						viewBox="0 0 20 20"
+						fill="currentColor"
+						class="w-4 h-4"
+					>
+						<path
+							d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
+						/>
+					</svg>
+				</button>
+			</div>
+			<div class="flex flex-col space-y-1">
+				{#each banners as banner, bannerIdx}
+					<div class=" flex justify-between">
+						<div class="flex flex-row flex-1 border rounded-xl dark:border-gray-800">
+							<select
+								class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-none"
+								bind:value={banner.type}
+							>
+								{#if banner.type == ''}
+									<option value="" selected disabled class="">{$i18n.t('Type')}</option>
+								{/if}
+								<option value="info">{$i18n.t('Info')}</option>
+								<option value="warning">{$i18n.t('Warning')}</option>
+								<option value="error">{$i18n.t('Error')}</option>
+								<option value="success">{$i18n.t('Success')}</option>
+							</select>
+
+							<input
+								class="px-3 py-1.5 text-xs w-full bg-transparent outline-none"
+								placeholder={$i18n.t('Content')}
+								bind:value={banner.content}
+							/>
+
+							<div class="relative top-1.5 -left-2">
+								<Tooltip content="Dismissible" className="flex h-fit items-center">
+									<Switch bind:state={banner.dismissible} />
+								</Tooltip>
+							</div>
+						</div>
+
+						<button
+							class="px-2"
+							type="button"
+							on:click={() => {
+								banners.splice(bannerIdx, 1);
+								banners = banners;
+							}}
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+								/>
+							</svg>
+						</button>
+					</div>
+				{/each}
+			</div>
+		</div>
+	</div>
+	<div class="flex justify-end pt-3 text-sm font-medium">
+		<button
+			class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
+			type="submit"
+		>
+			Save
+		</button>
+	</div>
+</form>

+ 42 - 0
src/lib/components/admin/SettingsModal.svelte

@@ -6,6 +6,9 @@
 	import General from './Settings/General.svelte';
 	import Users from './Settings/Users.svelte';
 
+	import Banners from '$lib/components/admin/Settings/Banners.svelte';
+	import { toast } from 'svelte-sonner';
+
 	const i18n = getContext('i18n');
 
 	export let show = false;
@@ -117,24 +120,63 @@
 					</div>
 					<div class=" self-center">{$i18n.t('Database')}</div>
 				</button>
+
+				<button
+					class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
+					'banners'
+						? 'bg-gray-200 dark:bg-gray-700'
+						: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
+					on:click={() => {
+						selectedTab = 'banners';
+					}}
+				>
+					<div class=" self-center mr-2">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 24 24"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								d="M5.85 3.5a.75.75 0 0 0-1.117-1 9.719 9.719 0 0 0-2.348 4.876.75.75 0 0 0 1.479.248A8.219 8.219 0 0 1 5.85 3.5ZM19.267 2.5a.75.75 0 1 0-1.118 1 8.22 8.22 0 0 1 1.987 4.124.75.75 0 0 0 1.48-.248A9.72 9.72 0 0 0 19.266 2.5Z"
+							/>
+							<path
+								fill-rule="evenodd"
+								d="M12 2.25A6.75 6.75 0 0 0 5.25 9v.75a8.217 8.217 0 0 1-2.119 5.52.75.75 0 0 0 .298 1.206c1.544.57 3.16.99 4.831 1.243a3.75 3.75 0 1 0 7.48 0 24.583 24.583 0 0 0 4.83-1.244.75.75 0 0 0 .298-1.205 8.217 8.217 0 0 1-2.118-5.52V9A6.75 6.75 0 0 0 12 2.25ZM9.75 18c0-.034 0-.067.002-.1a25.05 25.05 0 0 0 4.496 0l.002.1a2.25 2.25 0 1 1-4.5 0Z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center">{$i18n.t('Banners')}</div>
+				</button>
 			</div>
 			<div class="flex-1 md:min-h-[380px]">
 				{#if selectedTab === 'general'}
 					<General
 						saveHandler={() => {
 							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{:else if selectedTab === 'users'}
 					<Users
 						saveHandler={() => {
 							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{:else if selectedTab === 'db'}
 					<Database
 						saveHandler={() => {
 							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
+						}}
+					/>
+				{:else if selectedTab === 'banners'}
+					<Banners
+						saveHandler={() => {
+							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{/if}

+ 29 - 1
src/lib/components/chat/Chat.svelte

@@ -15,7 +15,8 @@
 		settings,
 		showSidebar,
 		tags as _tags,
-		WEBUI_NAME
+		WEBUI_NAME,
+		banners
 	} from '$lib/stores';
 	import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils';
 
@@ -40,6 +41,7 @@
 	import { queryMemory } from '$lib/apis/memories';
 	import type { Writable } from 'svelte/store';
 	import type { i18n as i18nType } from 'i18next';
+	import Banner from '../common/Banner.svelte';
 
 	const i18n: Writable<i18nType> = getContext('i18n');
 
@@ -1004,6 +1006,32 @@
 			{chat}
 			{initNewChat}
 		/>
+
+		{#if $banners.length > 0}
+			<div class="absolute top-16 w-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}">
+				<div class=" flex flex-col gap-1 w-full">
+					{#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner}
+						<Banner
+							{banner}
+							on:dismiss={(e) => {
+								const bannerId = e.detail;
+
+								localStorage.setItem(
+									'dismissedBannerIds',
+									JSON.stringify(
+										[
+											bannerId,
+											...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
+										].filter((id) => $banners.find((b) => b.id === id))
+									)
+								);
+							}}
+						/>
+					{/each}
+				</div>
+			</div>
+		{/if}
+
 		<div class="flex flex-col flex-auto">
 			<div
 				class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full"

+ 125 - 0
src/lib/components/common/Banner.svelte

@@ -0,0 +1,125 @@
+<script lang="ts">
+	import type { Banner } from '$lib/types';
+	import { onMount, createEventDispatcher } from 'svelte';
+	import { fade } from 'svelte/transition';
+
+	const dispatch = createEventDispatcher();
+
+	export let banner: Banner = {
+		id: '',
+		type: 'info',
+		title: '',
+		content: '',
+		url: '',
+		dismissable: true,
+		timestamp: Math.floor(Date.now() / 1000)
+	};
+
+	export let dismissed = false;
+
+	let mounted = false;
+
+	const classNames: Record<string, string> = {
+		info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
+		success: 'bg-green-500/20 text-green-700 dark:text-green-200',
+		warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
+		error: 'bg-red-500/20 text-red-700 dark:text-red-200'
+	};
+
+	const dismiss = (id) => {
+		dismissed = true;
+		dispatch('dismiss', id);
+	};
+
+	onMount(() => {
+		mounted = true;
+	});
+</script>
+
+{#if !dismissed}
+	{#if mounted}
+		<div
+			class=" top-0 left-0 right-0 p-2 mx-4 px-3 flex justify-center items-center relative rounded-xl border border-gray-100 dark:border-gray-850 text-gray-800 dark:text-gary-100 bg-white dark:bg-gray-900 backdrop-blur-xl z-40"
+			transition:fade={{ delay: 100, duration: 300 }}
+		>
+			<div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5">
+				<div class="flex justify-between self-start">
+					<div
+						class=" text-xs font-black {classNames[banner.type] ??
+							classNames['info']}  w-fit px-2 rounded uppercase line-clamp-1 mr-0.5"
+					>
+						{banner.type}
+					</div>
+
+					{#if banner.url}
+						<div class="flex md:hidden group w-fit md:items-center">
+							<a
+								class="text-gray-700 dark:text-white text-xs font-bold underline"
+								href="/assets/files/whitepaper.pdf"
+								target="_blank">Learn More</a
+							>
+
+							<div
+								class=" ml-1 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white"
+							>
+								<!--  -->
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 16 16"
+									fill="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										fill-rule="evenodd"
+										d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
+										clip-rule="evenodd"
+									/>
+								</svg>
+							</div>
+						</div>
+					{/if}
+				</div>
+
+				<div class="flex-1 text-xs text-gray-700 dark:text-white">
+					{banner.content}
+				</div>
+			</div>
+
+			{#if banner.url}
+				<div class="hidden md:flex group w-fit md:items-center">
+					<a
+						class="text-gray-700 dark:text-white text-xs font-bold underline"
+						href="/"
+						target="_blank">Learn More</a
+					>
+
+					<div class=" ml-1 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white">
+						<!--  -->
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 16 16"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+				</div>
+			{/if}
+			<div class="flex self-start">
+				{#if banner.dismissible}
+					<button
+						on:click={() => {
+							dismiss(banner.id);
+						}}
+						class=" -mt-[3px] ml-1.5 mr-1 text-gray-400 dark:hover:text-white h-1">&times;</button
+					>
+				{/if}
+			</div>
+		</div>
+	{/if}
+{/if}

+ 3 - 0
src/lib/stores/index.ts

@@ -1,6 +1,7 @@
 import { APP_NAME } from '$lib/constants';
 import { type Writable, writable } from 'svelte/store';
 import type { GlobalModelConfig, ModelConfig } from '$lib/apis';
+import type { Banner } from '$lib/types';
 
 // Backend
 export const WEBUI_NAME = writable(APP_NAME);
@@ -36,6 +37,8 @@ export const documents = writable([
 	}
 ]);
 
+export const banners: Writable<Banner[]> = writable([]);
+
 export const settings: Writable<Settings> = writable({});
 
 export const showSidebar = writable(false);

+ 9 - 0
src/lib/types/index.ts

@@ -0,0 +1,9 @@
+export type Banner = {
+	id: string;
+	type: string;
+	title?: string;
+	content: string;
+	url?: string;
+	dismissible?: boolean;
+	timestamp: number;
+};

+ 5 - 0
src/routes/(app)/+layout.svelte

@@ -22,6 +22,7 @@
 		prompts,
 		documents,
 		tags,
+		banners,
 		showChangelog,
 		config
 	} from '$lib/stores';
@@ -33,6 +34,7 @@
 	import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
 	import ChangelogModal from '$lib/components/ChangelogModal.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import { getBanners } from '$lib/apis/configs';
 
 	const i18n = getContext('i18n');
 
@@ -82,6 +84,9 @@
 				(async () => {
 					documents.set(await getDocs(localStorage.token));
 				})(),
+				(async () => {
+					banners.set(await getBanners(localStorage.token));
+				})(),
 				(async () => {
 					tags.set(await getAllChatTags(localStorage.token));
 				})()