Explorar o código

feat: external tool server support frontend

Timothy Jaeryang Baek hai 4 meses
pai
achega
d9b6d78d5c

+ 14 - 10
backend/open_webui/utils/middleware.py

@@ -215,6 +215,7 @@ async def chat_completion_tools_handler(
                                     "id": str(uuid4()),
                                     "tool": tool,
                                     "params": tool_function_params,
+                                    "server": tool.get("server", {}),
                                     "session_id": metadata.get("session_id", None),
                                 },
                             }
@@ -787,10 +788,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     # Server side tools
     tool_ids = metadata.get("tool_ids", None)
     # Client side tools
-    tool_specs = form_data.get("tool_specs", None)
+    tool_servers = form_data.get("tool_servers", None)
 
     log.debug(f"{tool_ids=}")
-    log.debug(f"{tool_specs=}")
+    log.debug(f"{tool_servers=}")
 
     tools_dict = {}
 
@@ -808,14 +809,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
         )
         log.info(f"{tools_dict=}")
 
-    if tool_specs:
-        for tool in tool_specs:
-            callable = tool.pop("callable", None)
-            tools_dict[tool["name"]] = {
-                "direct": True,
-                "callable": callable,
-                "spec": tool,
-            }
+    if tool_servers:
+        for tool_server in tool_servers:
+            tool_specs = tool_server.pop("specs", [])
+
+            for tool in tool_specs:
+                tools_dict[tool["name"]] = {
+                    "spec": tool,
+                    "direct": True,
+                    "server": tool_server,
+                }
 
     if tools_dict:
         if metadata.get("function_calling") == "native":
@@ -1823,6 +1826,7 @@ async def process_chat_response(
                                                 "id": str(uuid4()),
                                                 "tool": tool,
                                                 "params": tool_function_params,
+                                                "server": tool.get("server", {}),
                                                 "session_id": metadata.get(
                                                     "session_id", None
                                                 ),

+ 3 - 2
backend/open_webui/utils/tools.py

@@ -91,10 +91,11 @@ def get_tools(
 
             # TODO: This needs to be a pydantic model
             tool_dict = {
-                "toolkit_id": tool_id,
-                "callable": callable,
                 "spec": spec,
+                "callable": callable,
+                "toolkit_id": tool_id,
                 "pydantic_model": function_to_pydantic_model(callable),
+                # Misc info
                 "file_handler": hasattr(module, "file_handler") and module.file_handler,
                 "citation": hasattr(module, "citation") and module.citation,
             }

+ 212 - 0
src/lib/components/AddServerModal.svelte

@@ -0,0 +1,212 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { models } from '$lib/stores';
+	import { verifyOpenAIConnection } from '$lib/apis/openai';
+	import { verifyOllamaConnection } from '$lib/apis/ollama';
+
+	import Modal from '$lib/components/common/Modal.svelte';
+	import Plus from '$lib/components/icons/Plus.svelte';
+	import Minus from '$lib/components/icons/Minus.svelte';
+	import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
+	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Switch from '$lib/components/common/Switch.svelte';
+	import Tags from './common/Tags.svelte';
+
+	export let onSubmit: Function = () => {};
+	export let onDelete: Function = () => {};
+
+	export let show = false;
+	export let edit = false;
+
+	export let connection = null;
+
+	let url = '';
+	let key = '';
+	let enable = true;
+
+	let loading = false;
+
+	const submitHandler = async () => {
+		loading = true;
+
+		const connection = {
+			url,
+			key,
+			config: {
+				enable: enable
+			}
+		};
+
+		await onSubmit(connection);
+
+		loading = false;
+		show = false;
+
+		url = '';
+		key = '';
+		enable = true;
+	};
+
+	const init = () => {
+		if (connection) {
+			url = connection.url;
+			key = connection.key;
+
+			enable = connection.config?.enable ?? true;
+		}
+	};
+
+	$: if (show) {
+		init();
+	}
+
+	onMount(() => {
+		init();
+	});
+</script>
+
+<Modal size="sm" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
+			<div class=" text-lg font-medium self-center font-primary">
+				{#if edit}
+					{$i18n.t('Edit Connection')}
+				{:else}
+					{$i18n.t('Add Connection')}
+				{/if}
+			</div>
+			<button
+				class="self-center"
+				on:click={() => {
+					show = false;
+				}}
+			>
+				<svg
+					xmlns="http://www.w3.org/2000/svg"
+					viewBox="0 0 20 20"
+					fill="currentColor"
+					class="w-5 h-5"
+				>
+					<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>
+
+		<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
+			<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
+				<form
+					class="flex flex-col w-full"
+					on:submit={(e) => {
+						e.preventDefault();
+						submitHandler();
+					}}
+				>
+					<div class="px-1">
+						<div class="flex gap-2">
+							<div class="flex flex-col w-full">
+								<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('URL')}</div>
+
+								<div class="flex-1">
+									<input
+										class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+										type="text"
+										bind:value={url}
+										placeholder={$i18n.t('API Base URL')}
+										autocomplete="off"
+										required
+									/>
+								</div>
+							</div>
+
+							<div class="flex flex-col shrink-0 self-end">
+								<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
+									<Switch bind:state={enable} />
+								</Tooltip>
+							</div>
+						</div>
+
+						<div class="text-xs text-gray-500 mt-1">
+							{$i18n.t(`WebUI will make requests to "{{URL}}/openapi.json"`, {
+								URL: url
+							})}
+						</div>
+
+						<div class="flex gap-2 mt-2">
+							<div class="flex flex-col w-full">
+								<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Key')}</div>
+
+								<div class="flex-1">
+									<SensitiveInput
+										className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+										bind:value={key}
+										placeholder={$i18n.t('API Key')}
+										required={false}
+									/>
+								</div>
+							</div>
+						</div>
+					</div>
+
+					<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
+						{#if edit}
+							<button
+								class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
+								type="button"
+								on:click={() => {
+									onDelete();
+									show = false;
+								}}
+							>
+								{$i18n.t('Delete')}
+							</button>
+						{/if}
+
+						<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 {loading
+								? ' cursor-not-allowed'
+								: ''}"
+							type="submit"
+							disabled={loading}
+						>
+							{$i18n.t('Save')}
+
+							{#if loading}
+								<div class="ml-2 self-center">
+									<svg
+										class=" w-4 h-4"
+										viewBox="0 0 24 24"
+										fill="currentColor"
+										xmlns="http://www.w3.org/2000/svg"
+										><style>
+											.spinner_ajPY {
+												transform-origin: center;
+												animation: spinner_AtaB 0.75s infinite linear;
+											}
+											@keyframes spinner_AtaB {
+												100% {
+													transform: rotate(360deg);
+												}
+											}
+										</style><path
+											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+											opacity=".25"
+										/><path
+											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+											class="spinner_ajPY"
+										/></svg
+									>
+								</div>
+							{/if}
+						</button>
+					</div>
+				</form>
+			</div>
+		</div>
+	</div>
+</Modal>

+ 7 - 0
src/lib/components/chat/Chat.svelte

@@ -119,6 +119,9 @@
 	let imageGenerationEnabled = false;
 	let webSearchEnabled = false;
 	let codeInterpreterEnabled = false;
+
+	let toolServers = [];
+
 	let chat = null;
 	let tags = [];
 
@@ -191,6 +194,8 @@
 		setToolIds();
 	}
 
+	$: toolServers = ($settings?.toolServers ?? []).filter((server) => server?.config?.enable);
+
 	const setToolIds = async () => {
 		if (!$tools) {
 			tools.set(await getTools(localStorage.token));
@@ -2033,6 +2038,7 @@
 								bind:codeInterpreterEnabled
 								bind:webSearchEnabled
 								bind:atSelectedModel
+								{toolServers}
 								transparentBackground={$settings?.backgroundImageUrl ?? false}
 								{stopResponse}
 								{createMessagePair}
@@ -2086,6 +2092,7 @@
 								bind:webSearchEnabled
 								bind:atSelectedModel
 								transparentBackground={$settings?.backgroundImageUrl ?? false}
+								{toolServers}
 								{stopResponse}
 								{createMessagePair}
 								on:upload={async (e) => {

+ 45 - 6
src/lib/components/chat/MessageInput.svelte

@@ -68,6 +68,8 @@
 	export let prompt = '';
 	export let files = [];
 
+	export let toolServers = [];
+
 	export let selectedToolIds = [];
 
 	export let imageGenerationEnabled = false;
@@ -1175,14 +1177,14 @@
 														<button
 															on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
 															type="button"
-															class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
+															class="px-1.5 @lg:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
 															($settings?.webSearch ?? false) === 'always'
 																? 'bg-blue-100 dark:bg-blue-500/20 text-blue-500 dark:text-blue-400'
 																: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'}"
 														>
 															<GlobeAlt className="size-5" strokeWidth="1.75" />
 															<span
-																class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
+																class="hidden @lg:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
 																>{$i18n.t('Web Search')}</span
 															>
 														</button>
@@ -1195,13 +1197,13 @@
 															on:click|preventDefault={() =>
 																(imageGenerationEnabled = !imageGenerationEnabled)}
 															type="button"
-															class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
+															class="px-1.5 @lg:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
 																? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
 																: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
 														>
 															<Photo className="size-5" strokeWidth="1.75" />
 															<span
-																class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
+																class="hidden @lg:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
 																>{$i18n.t('Image')}</span
 															>
 														</button>
@@ -1214,13 +1216,13 @@
 															on:click|preventDefault={() =>
 																(codeInterpreterEnabled = !codeInterpreterEnabled)}
 															type="button"
-															class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {codeInterpreterEnabled
+															class="px-1.5 @lg:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {codeInterpreterEnabled
 																? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
 																: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
 														>
 															<CommandLine className="size-5" strokeWidth="1.75" />
 															<span
-																class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
+																class="hidden @lg:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
 																>{$i18n.t('Code Interpreter')}</span
 															>
 														</button>
@@ -1231,6 +1233,43 @@
 									</div>
 
 									<div class="self-end flex space-x-1 mr-1 shrink-0">
+										{#if toolServers.length > 0}
+											<Tooltip
+												content={$i18n.t('{{COUNT}} Available Tool Servers', {
+													COUNT: toolServers.length
+												})}
+											>
+												<div
+													class="translate-y-[1.5px] flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-lg px-1.5 py-0.5 mr-0.5 self-center border border-gray-100 dark:border-gray-800"
+													aria-label="Available Tool Servers"
+												>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														fill="none"
+														viewBox="0 0 24 24"
+														stroke-width="1.5"
+														stroke="currentColor"
+														class="size-3"
+													>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z"
+														/>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M4.867 19.125h.008v.008h-.008v-.008Z"
+														/>
+													</svg>
+
+													<span class="text-xs">
+														{toolServers.length}
+													</span>
+												</div>
+											</Tooltip>
+										{/if}
+
 										{#if !history?.currentId || history.messages[history.currentId]?.done == true}
 											<Tooltip content={$i18n.t('Record voice')}>
 												<button

+ 3 - 0
src/lib/components/chat/Placeholder.svelte

@@ -38,6 +38,8 @@
 	export let codeInterpreterEnabled = false;
 	export let webSearchEnabled = false;
 
+	export let toolServers = [];
+
 	let models = [];
 
 	const selectSuggestionPrompt = async (p) => {
@@ -196,6 +198,7 @@
 					bind:codeInterpreterEnabled
 					bind:webSearchEnabled
 					bind:atSelectedModel
+					{toolServers}
 					{transparentBackground}
 					{stopResponse}
 					{createMessagePair}

+ 115 - 0
src/lib/components/chat/Settings/Tools.svelte

@@ -0,0 +1,115 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
+	import { getModels as _getModels } from '$lib/apis';
+
+	const dispatch = createEventDispatcher();
+	const i18n = getContext('i18n');
+
+	import { models, settings, user } from '$lib/stores';
+
+	import Switch from '$lib/components/common/Switch.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Plus from '$lib/components/icons/Plus.svelte';
+	import Connection from './Tools/Connection.svelte';
+
+	import AddServerModal from '$lib/components/AddServerModal.svelte';
+
+	export let saveSettings: Function;
+
+	let servers = null;
+	let showConnectionModal = false;
+
+	const addConnectionHandler = async (server) => {
+		servers = [...servers, server];
+		await updateHandler();
+	};
+
+	const updateHandler = async () => {
+		await saveSettings({
+			toolServers: servers
+		});
+	};
+
+	onMount(async () => {
+		servers = $settings?.toolServers ?? [];
+	});
+</script>
+
+<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
+
+<form
+	class="flex flex-col h-full justify-between text-sm"
+	on:submit|preventDefault={() => {
+		updateHandler();
+	}}
+>
+	<div class=" overflow-y-scroll scrollbar-hidden h-full">
+		{#if servers !== null}
+			<div class="">
+				<div class="pr-1.5">
+					<div class="">
+						<div class="flex justify-between items-center mb-0.5">
+							<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
+
+							<Tooltip content={$i18n.t(`Add Connection`)}>
+								<button
+									class="px-1"
+									on:click={() => {
+										showConnectionModal = true;
+									}}
+									type="button"
+								>
+									<Plus />
+								</button>
+							</Tooltip>
+						</div>
+
+						<div class="flex flex-col gap-1.5">
+							{#each servers as server, idx}
+								<Connection
+									bind:url={server.url}
+									bind:key={server.key}
+									bind:config={server.config}
+									onSubmit={() => {
+										updateHandler();
+									}}
+									onDelete={() => {
+										servers = servers.filter((_, i) => i !== idx);
+										updateHandler();
+									}}
+								/>
+							{/each}
+						</div>
+					</div>
+
+					<div class="my-1.5">
+						<div class="text-xs text-gray-500">
+							{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
+							<br />
+							{$i18n.t(
+								'CORS must be properly configured by the provider to allow requests from Open WebUI.'
+							)}
+						</div>
+					</div>
+				</div>
+			</div>
+		{:else}
+			<div class="flex h-full justify-center">
+				<div class="my-auto">
+					<Spinner className="size-6" />
+				</div>
+			</div>
+		{/if}
+	</div>
+
+	<div class="flex justify-end pt-3 text-sm font-medium">
+		<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"
+			type="submit"
+		>
+			{$i18n.t('Save')}
+		</button>
+	</div>
+</form>

+ 96 - 0
src/lib/components/chat/Settings/Tools/Connection.svelte

@@ -0,0 +1,96 @@
+<script lang="ts">
+	import { getContext, tick } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
+	import Cog6 from '$lib/components/icons/Cog6.svelte';
+	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import AddServerModal from '$lib/components/AddServerModal.svelte';
+
+	export let onDelete = () => {};
+	export let onSubmit = () => {};
+
+	export let pipeline = false;
+
+	export let url = '';
+	export let key = '';
+	export let config = {};
+
+	let showConfigModal = false;
+	let showDeleteConfirmDialog = false;
+</script>
+
+<AddServerModal
+	edit
+	direct
+	bind:show={showConfigModal}
+	connection={{
+		url,
+		key,
+		config
+	}}
+	onDelete={() => {
+		showDeleteConfirmDialog = true;
+	}}
+	onSubmit={(connection) => {
+		url = connection.url;
+		key = connection.key;
+		config = connection.config;
+		onSubmit(connection);
+	}}
+/>
+
+<ConfirmDialog
+	bind:show={showDeleteConfirmDialog}
+	on:confirm={() => {
+		onDelete();
+		showConfigModal = false;
+	}}
+/>
+
+<div class="flex w-full gap-2 items-center">
+	<Tooltip
+		className="w-full relative"
+		content={$i18n.t(`WebUI will make requests to "{{URL}}/openapi.json"`, {
+			URL: url
+		})}
+		placement="top-start"
+	>
+		{#if !(config?.enable ?? true)}
+			<div
+				class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
+			></div>
+		{/if}
+		<div class="flex w-full">
+			<div class="flex-1 relative">
+				<input
+					class=" outline-hidden w-full bg-transparent {pipeline ? 'pr-8' : ''}"
+					placeholder={$i18n.t('API Base URL')}
+					bind:value={url}
+					autocomplete="off"
+				/>
+			</div>
+
+			<SensitiveInput
+				inputClassName=" outline-hidden bg-transparent w-full"
+				placeholder={$i18n.t('API Key')}
+				bind:value={key}
+			/>
+		</div>
+	</Tooltip>
+
+	<div class="flex gap-1">
+		<Tooltip content={$i18n.t('Configure')} className="self-start">
+			<button
+				class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
+				on:click={() => {
+					showConfigModal = true;
+				}}
+				type="button"
+			>
+				<Cog6 />
+			</button>
+		</Tooltip>
+	</div>
+</div>

+ 41 - 0
src/lib/components/chat/SettingsModal.svelte

@@ -17,6 +17,7 @@
 	import Personalization from './Settings/Personalization.svelte';
 	import Search from '../icons/Search.svelte';
 	import Connections from './Settings/Connections.svelte';
+	import Tools from './Settings/Tools.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -127,6 +128,11 @@
 			title: 'Connections',
 			keywords: []
 		},
+		{
+			id: 'tools',
+			title: 'Tools',
+			keywords: []
+		},
 		{
 			id: 'personalization',
 			title: 'Personalization',
@@ -481,6 +487,34 @@
 									<div class=" self-center">{$i18n.t('Connections')}</div>
 								</button>
 							{/if}
+						{:else if tabId === 'tools'}
+							{#if $user.role === 'admin' || ($user.role === 'user' && $config?.features?.enable_direct_tools)}
+								<button
+									class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
+									'tools'
+										? ''
+										: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
+									on:click={() => {
+										selectedTab = 'tools';
+									}}
+								>
+									<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
+												fill-rule="evenodd"
+												d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									</div>
+									<div class=" self-center">{$i18n.t('Tools')}</div>
+								</button>
+							{/if}
 						{:else if tabId === 'personalization'}
 							<button
 								class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition {selectedTab ===
@@ -661,6 +695,13 @@
 							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
+				{:else if selectedTab === 'tools'}
+					<Tools
+						saveSettings={async (updated) => {
+							await saveSettings(updated);
+							toast.success($i18n.t('Settings saved successfully!'));
+						}}
+					/>
 				{:else if selectedTab === 'personalization'}
 					<Personalization
 						{saveSettings}