Browse Source

Merge pull request #2585 from open-webui/banners

feat: banners
Timothy Jaeryang Baek 11 months ago
parent
commit
3a737af190

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

@@ -23,6 +23,7 @@ from config import (
     WEBHOOK_URL,
     WEBHOOK_URL,
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     JWT_EXPIRES_IN,
     JWT_EXPIRES_IN,
+    WEBUI_BANNERS,
     AppConfig,
     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.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
 app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
 app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
 app.state.config.WEBHOOK_URL = WEBHOOK_URL
 app.state.config.WEBHOOK_URL = WEBHOOK_URL
+app.state.config.BANNERS = WEBUI_BANNERS
 
 
 
 
 app.state.MODELS = {}
 app.state.MODELS = {}

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

@@ -8,6 +8,8 @@ from pydantic import BaseModel
 import time
 import time
 import uuid
 import uuid
 
 
+from config import BannerModel
+
 from apps.webui.models.users import Users
 from apps.webui.models.users import Users
 
 
 from utils.utils import (
 from utils.utils import (
@@ -57,3 +59,31 @@ async def set_global_default_suggestions(
     data = form_data.model_dump()
     data = form_data.model_dump()
     request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"]
     request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"]
     return request.app.state.config.DEFAULT_PROMPT_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 base64 import b64encode
 from bs4 import BeautifulSoup
 from bs4 import BeautifulSoup
 from typing import TypeVar, Generic, Union
 from typing import TypeVar, Generic, Union
+from pydantic import BaseModel
+from typing import Optional
 
 
 from pathlib import Path
 from pathlib import Path
 import json
 import json
@@ -566,6 +568,22 @@ WEBHOOK_URL = PersistentConfig(
 
 
 ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true"
 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
 # WEBUI_SECRET_KEY
 ####################################
 ####################################

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

@@ -1,4 +1,5 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
 import { WEBUI_API_BASE_URL } from '$lib/constants';
+import type { Banner } from '$lib/types';
 
 
 export const setDefaultModels = async (token: string, models: string) => {
 export const setDefaultModels = async (token: string, models: string) => {
 	let error = null;
 	let error = null;
@@ -59,3 +60,60 @@ export const setDefaultPromptSuggestions = async (token: string, promptSuggestio
 
 
 	return res;
 	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 General from './Settings/General.svelte';
 	import Users from './Settings/Users.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');
 	const i18n = getContext('i18n');
 
 
 	export let show = false;
 	export let show = false;
@@ -117,24 +120,63 @@
 					</div>
 					</div>
 					<div class=" self-center">{$i18n.t('Database')}</div>
 					<div class=" self-center">{$i18n.t('Database')}</div>
 				</button>
 				</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>
 			<div class="flex-1 md:min-h-[380px]">
 			<div class="flex-1 md:min-h-[380px]">
 				{#if selectedTab === 'general'}
 				{#if selectedTab === 'general'}
 					<General
 					<General
 						saveHandler={() => {
 						saveHandler={() => {
 							show = false;
 							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 						}}
 					/>
 					/>
 				{:else if selectedTab === 'users'}
 				{:else if selectedTab === 'users'}
 					<Users
 					<Users
 						saveHandler={() => {
 						saveHandler={() => {
 							show = false;
 							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 						}}
 					/>
 					/>
 				{:else if selectedTab === 'db'}
 				{:else if selectedTab === 'db'}
 					<Database
 					<Database
 						saveHandler={() => {
 						saveHandler={() => {
 							show = false;
 							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}
 				{/if}

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

@@ -15,7 +15,8 @@
 		settings,
 		settings,
 		showSidebar,
 		showSidebar,
 		tags as _tags,
 		tags as _tags,
-		WEBUI_NAME
+		WEBUI_NAME,
+		banners
 	} from '$lib/stores';
 	} from '$lib/stores';
 	import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils';
 	import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils';
 
 
@@ -40,6 +41,7 @@
 	import { queryMemory } from '$lib/apis/memories';
 	import { queryMemory } from '$lib/apis/memories';
 	import type { Writable } from 'svelte/store';
 	import type { Writable } from 'svelte/store';
 	import type { i18n as i18nType } from 'i18next';
 	import type { i18n as i18nType } from 'i18next';
+	import Banner from '../common/Banner.svelte';
 
 
 	const i18n: Writable<i18nType> = getContext('i18n');
 	const i18n: Writable<i18nType> = getContext('i18n');
 
 
@@ -1004,6 +1006,32 @@
 			{chat}
 			{chat}
 			{initNewChat}
 			{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="flex flex-col flex-auto">
 			<div
 			<div
 				class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full"
 				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 { APP_NAME } from '$lib/constants';
 import { type Writable, writable } from 'svelte/store';
 import { type Writable, writable } from 'svelte/store';
 import type { GlobalModelConfig, ModelConfig } from '$lib/apis';
 import type { GlobalModelConfig, ModelConfig } from '$lib/apis';
+import type { Banner } from '$lib/types';
 
 
 // Backend
 // Backend
 export const WEBUI_NAME = writable(APP_NAME);
 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 settings: Writable<Settings> = writable({});
 
 
 export const showSidebar = writable(false);
 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,
 		prompts,
 		documents,
 		documents,
 		tags,
 		tags,
+		banners,
 		showChangelog,
 		showChangelog,
 		config
 		config
 	} from '$lib/stores';
 	} from '$lib/stores';
@@ -33,6 +34,7 @@
 	import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
 	import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
 	import ChangelogModal from '$lib/components/ChangelogModal.svelte';
 	import ChangelogModal from '$lib/components/ChangelogModal.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import { getBanners } from '$lib/apis/configs';
 
 
 	const i18n = getContext('i18n');
 	const i18n = getContext('i18n');
 
 
@@ -82,6 +84,9 @@
 				(async () => {
 				(async () => {
 					documents.set(await getDocs(localStorage.token));
 					documents.set(await getDocs(localStorage.token));
 				})(),
 				})(),
+				(async () => {
+					banners.set(await getBanners(localStorage.token));
+				})(),
 				(async () => {
 				(async () => {
 					tags.set(await getAllChatTags(localStorage.token));
 					tags.set(await getAllChatTags(localStorage.token));
 				})()
 				})()