4 کامیت‌ها d40c710354 ... 2c59a28860

نویسنده SHA1 پیام تاریخ
  Timothy Jaeryang Baek 2c59a28860 refac: styling 2 روز پیش
  Timothy Jaeryang Baek f20723ca54 refac 2 روز پیش
  Timothy Jaeryang Baek a2a2bafdf6 enh/refac: url input handling 2 روز پیش
  Timothy Jaeryang Baek ce83276fa4 refac/fix: switch 2 روز پیش

+ 7 - 0
backend/open_webui/retrieval/loaders/youtube.py

@@ -157,3 +157,10 @@ class YoutubeLoader:
             f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed."
         )
         raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
+
+    async def aload(self) -> Generator[Document, None, None]:
+        """Asynchronously load YouTube transcripts into `Document` objects."""
+        import asyncio
+
+        loop = asyncio.get_event_loop()
+        return await loop.run_in_executor(None, self.load)

+ 39 - 1
backend/open_webui/retrieval/utils.py

@@ -6,6 +6,7 @@ import requests
 import hashlib
 from concurrent.futures import ThreadPoolExecutor
 import time
+import re
 
 from urllib.parse import quote
 from huggingface_hub import snapshot_download
@@ -16,6 +17,7 @@ from langchain_core.documents import Document
 from open_webui.config import VECTOR_DB
 from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
 
+
 from open_webui.models.users import UserModel
 from open_webui.models.files import Files
 from open_webui.models.knowledge import Knowledges
@@ -27,6 +29,9 @@ from open_webui.retrieval.vector.main import GetResult
 from open_webui.utils.access_control import has_access
 from open_webui.utils.misc import get_message_list
 
+from open_webui.retrieval.web.utils import get_web_loader
+from open_webui.retrieval.loaders.youtube import YoutubeLoader
+
 
 from open_webui.env import (
     SRC_LOG_LEVELS,
@@ -49,6 +54,33 @@ from langchain_core.callbacks import CallbackManagerForRetrieverRun
 from langchain_core.retrievers import BaseRetriever
 
 
+def is_youtube_url(url: str) -> bool:
+    youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$"
+    return re.match(youtube_regex, url) is not None
+
+
+def get_loader(request, url: str):
+    if is_youtube_url(url):
+        return YoutubeLoader(
+            url,
+            language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
+            proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
+        )
+    else:
+        return get_web_loader(
+            url,
+            verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
+            requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
+        )
+
+
+def get_content_from_url(request, url: str) -> str:
+    loader = get_loader(request, url)
+    docs = loader.load()
+    content = " ".join([doc.page_content for doc in docs])
+    return content, docs
+
+
 class VectorSearchRetriever(BaseRetriever):
     collection_name: Any
     embedding_function: Any
@@ -571,6 +603,13 @@ def get_sources_from_items(
                         "metadatas": [[{"file_id": chat.id, "name": chat.title}]],
                     }
 
+        elif item.get("type") == "url":
+            content, docs = get_content_from_url(request, item.get("url"))
+            if docs:
+                query_result = {
+                    "documents": [[content]],
+                    "metadatas": [[{"url": item.get("url"), "name": item.get("url")}]],
+                }
         elif item.get("type") == "file":
             if (
                 item.get("context") == "full"
@@ -736,7 +775,6 @@ def get_sources_from_items(
                     sources.append(source)
         except Exception as e:
             log.exception(e)
-
     return sources
 
 

+ 6 - 28
backend/open_webui/routers/retrieval.py

@@ -71,6 +71,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl
 from open_webui.retrieval.web.external import search_external
 
 from open_webui.retrieval.utils import (
+    get_content_from_url,
     get_embedding_function,
     get_reranking_function,
     get_model_path,
@@ -1691,33 +1692,6 @@ def process_text(
         )
 
 
-def is_youtube_url(url: str) -> bool:
-    youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$"
-    return re.match(youtube_regex, url) is not None
-
-
-def get_loader(request, url: str):
-    if is_youtube_url(url):
-        return YoutubeLoader(
-            url,
-            language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
-            proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
-        )
-    else:
-        return get_web_loader(
-            url,
-            verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
-            requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
-        )
-
-
-def get_content_from_url(request, url: str) -> str:
-    loader = get_loader(request, url)
-    docs = loader.load()
-    content = " ".join([doc.page_content for doc in docs])
-    return content, docs
-
-
 @router.post("/process/youtube")
 @router.post("/process/web")
 def process_web(
@@ -1733,7 +1707,11 @@ def process_web(
 
         if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
             save_docs_to_vector_db(
-                request, docs, collection_name, overwrite=True, user=user
+                request,
+                docs,
+                collection_name,
+                overwrite=True,
+                user=user,
             )
         else:
             collection_name = None

+ 22 - 12
backend/open_webui/utils/middleware.py

@@ -40,7 +40,10 @@ from open_webui.routers.tasks import (
     generate_image_prompt,
     generate_chat_tags,
 )
-from open_webui.routers.retrieval import process_web_search, SearchForm
+from open_webui.routers.retrieval import (
+    process_web_search,
+    SearchForm,
+)
 from open_webui.routers.images import (
     load_b64_image_data,
     image_generations,
@@ -76,6 +79,7 @@ from open_webui.utils.task import (
 )
 from open_webui.utils.misc import (
     deep_update,
+    extract_urls,
     get_message_list,
     add_or_update_system_message,
     add_or_update_user_message,
@@ -823,7 +827,11 @@ async def chat_completion_files_handler(
 
     if files := body.get("metadata", {}).get("files", None):
         # Check if all files are in full context mode
-        all_full_context = all(item.get("context") == "full" for item in files)
+        all_full_context = all(
+            item.get("context") == "full"
+            for item in files
+            if item.get("type") == "file"
+        )
 
         queries = []
         if not all_full_context:
@@ -855,10 +863,6 @@ async def chat_completion_files_handler(
             except:
                 pass
 
-        if len(queries) == 0:
-            queries = [get_last_user_message(body["messages"])]
-
-        if not all_full_context:
             await __event_emitter__(
                 {
                     "type": "status",
@@ -870,6 +874,9 @@ async def chat_completion_files_handler(
                 }
             )
 
+        if len(queries) == 0:
+            queries = [get_last_user_message(body["messages"])]
+
         try:
             # Offload get_sources_from_items to a separate thread
             loop = asyncio.get_running_loop()
@@ -908,7 +915,6 @@ async def chat_completion_files_handler(
         log.debug(f"rag_contexts:sources: {sources}")
 
         unique_ids = set()
-
         for source in sources or []:
             if not source or len(source.keys()) == 0:
                 continue
@@ -927,7 +933,6 @@ async def chat_completion_files_handler(
                 unique_ids.add(_id)
 
         sources_count = len(unique_ids)
-
         await __event_emitter__(
             {
                 "type": "status",
@@ -1170,8 +1175,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     tool_ids = form_data.pop("tool_ids", None)
     files = form_data.pop("files", None)
 
-    # Remove files duplicates
-    if files:
+    prompt = get_last_user_message(form_data["messages"])
+    urls = extract_urls(prompt)
+
+    if files or urls:
+        if not files:
+            files = []
+        files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
+
+        # Remove duplicate files based on their content
         files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
 
     metadata = {
@@ -1372,8 +1384,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                     )
 
         context_string = context_string.strip()
-
-        prompt = get_last_user_message(form_data["messages"])
         if prompt is None:
             raise Exception("No user message found")
 

+ 8 - 0
backend/open_webui/utils/misc.py

@@ -531,3 +531,11 @@ def throttle(interval: float = 10.0):
         return wrapper
 
     return decorator
+
+
+def extract_urls(text: str) -> list[str]:
+    # Regex pattern to match URLs
+    url_pattern = re.compile(
+        r"(https?://[^\s]+)", re.IGNORECASE
+    )  # Matches http and https URLs
+    return url_pattern.findall(text)

+ 5 - 1
src/lib/components/admin/Users/UserList/EditUserModal.svelte

@@ -87,7 +87,11 @@
 					<div class=" px-5 pt-3 pb-5 w-full">
 						<div class="flex self-center w-full">
 							<div class=" self-start h-full mr-6">
-								<UserProfileImage bind:profileImageUrl={_user.profile_image_url} user={_user} />
+								<UserProfileImage
+									imageClassName="size-14"
+									bind:profileImageUrl={_user.profile_image_url}
+									user={_user}
+								/>
 							</div>
 
 							<div class=" flex-1">

+ 12 - 9
src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte

@@ -68,17 +68,20 @@
 				}}
 			>
 				<div class="flex items-start gap-2">
-					<div class="pt-3 px-1">
-						<span class="relative flex size-1.5 rounded-full justify-center items-center">
-							{#if status?.done === false}
+					{#if history.length > 1}
+						<div class="pt-3 px-1">
+							<span class="relative flex size-1.5 rounded-full justify-center items-center">
+								{#if status?.done === false}
+									<span
+										class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-500 dark:bg-gray-300 opacity-75"
+									></span>
+								{/if}
 								<span
-									class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-500 dark:bg-gray-300 opacity-75"
+									class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300"
 								></span>
-							{/if}
-							<span class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300"
-							></span>
-						</span>
-					</div>
+							</span>
+						</div>
+					{/if}
 					<StatusItem {status} />
 				</div>
 			</button>

+ 3 - 1
src/lib/components/chat/Settings/Account/UserProfileImage.svelte

@@ -12,6 +12,8 @@
 	export let profileImageUrl;
 	export let user = null;
 
+	export let imageClassName = 'size-14 md:size-18';
+
 	let profileImageInputElement;
 </script>
 
@@ -89,7 +91,7 @@
 			<img
 				src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
 				alt="profile"
-				class=" rounded-full size-14 md:size-18 object-cover"
+				class=" rounded-full {imageClassName} object-cover"
 			/>
 
 			<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">

+ 7 - 3
src/lib/components/common/Switch.svelte

@@ -1,7 +1,9 @@
 <script lang="ts">
-	import { createEventDispatcher, tick, getContext } from 'svelte';
 	import { Switch } from 'bits-ui';
+
+	import { createEventDispatcher, tick, getContext } from 'svelte';
 	import { settings } from '$lib/stores';
+
 	import Tooltip from './Tooltip.svelte';
 	export let state = true;
 	export let id = '';
@@ -10,8 +12,6 @@
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
-
-	$: dispatch('change', state);
 </script>
 
 <Tooltip
@@ -28,6 +28,10 @@
 			: 'outline outline-1 outline-gray-100 dark:outline-gray-800'} {state
 			? ' bg-emerald-500 dark:bg-emerald-700'
 			: 'bg-gray-200 dark:bg-transparent'}"
+		onCheckedChange={async () => {
+			await tick();
+			dispatch('change', state);
+		}}
 	>
 		<Switch.Thumb
 			class="pointer-events-none block size-3 shrink-0 rounded-full bg-white transition-transform data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0 data-[state=unchecked]:shadow-mini "

+ 92 - 125
src/lib/components/workspace/Models.svelte

@@ -233,17 +233,102 @@
 	/>
 
 	<div class="flex flex-col gap-1 mt-1.5">
+		<input
+			id="models-import-input"
+			bind:this={modelsImportInputElement}
+			bind:files={importFiles}
+			type="file"
+			accept=".json"
+			hidden
+			on:change={() => {
+				console.log(importFiles);
+
+				let reader = new FileReader();
+				reader.onload = async (event) => {
+					let savedModels = JSON.parse(event.target.result);
+					console.log(savedModels);
+
+					for (const model of savedModels) {
+						if (model?.info ?? false) {
+							if ($_models.find((m) => m.id === model.id)) {
+								await updateModelById(localStorage.token, model.id, model.info).catch((error) => {
+									return null;
+								});
+							} else {
+								await createNewModel(localStorage.token, model.info).catch((error) => {
+									return null;
+								});
+							}
+						} else {
+							if (model?.id && model?.name) {
+								await createNewModel(localStorage.token, model).catch((error) => {
+									return null;
+								});
+							}
+						}
+					}
+
+					await _models.set(
+						await getModels(
+							localStorage.token,
+							$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+						)
+					);
+					models = await getWorkspaceModels(localStorage.token);
+				};
+
+				reader.readAsText(importFiles[0]);
+			}}
+		/>
 		<div class="flex justify-between items-center">
-			<div class="flex items-center md:self-center text-xl font-medium px-0.5">
-				{$i18n.t('Models')}
-				<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
-				<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
-					>{filteredModels.length}</span
+			<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
+				<div>
+					{$i18n.t('Models')}
+				</div>
+
+				<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
+					{filteredModels.length}
+				</div>
+			</div>
+
+			<div class="flex w-full justify-end gap-1.5">
+				{#if $user?.role === 'admin'}
+					<button
+						class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
+						on:click={() => {
+							modelsImportInputElement.click();
+						}}
+					>
+						<div class=" self-center font-medium line-clamp-1">
+							{$i18n.t('Import')}
+						</div>
+					</button>
+
+					{#if models.length}
+						<button
+							class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
+							on:click={async () => {
+								downloadModels(models);
+							}}
+						>
+							<div class=" self-center font-medium line-clamp-1">
+								{$i18n.t('Export')}
+							</div>
+						</button>
+					{/if}
+				{/if}
+				<a
+					class=" px-2 py-1 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
+					href="/workspace/models/create"
 				>
+					<Plus className="size-3" strokeWidth="2.5" />
+
+					<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Model')}</div>
+				</a>
 			</div>
 		</div>
 
-		<div class=" flex flex-1 items-center w-full space-x-2">
+		<div class=" flex flex-1 items-center w-full space-x-2 py-0.5">
 			<div class="flex flex-1 items-center">
 				<div class=" self-center ml-1 mr-3">
 					<Search className="size-3.5" />
@@ -267,21 +352,12 @@
 					</div>
 				{/if}
 			</div>
-
-			<div>
-				<a
-					class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
-					href="/workspace/models/create"
-				>
-					<Plus className="size-3.5" />
-				</a>
-			</div>
 		</div>
 	</div>
 
 	{#if tags.length > 0}
 		<div
-			class=" flex w-full bg-transparent overflow-x-auto scrollbar-none"
+			class=" flex w-full bg-transparent overflow-x-auto scrollbar-none -mx-2"
 			on:wheel={(e) => {
 				if (e.deltaY !== 0) {
 					e.preventDefault();
@@ -494,115 +570,6 @@
 		{/each}
 	</div>
 
-	{#if $user?.role === 'admin'}
-		<div class=" flex justify-end w-full mb-3">
-			<div class="flex space-x-1">
-				<input
-					id="models-import-input"
-					bind:this={modelsImportInputElement}
-					bind:files={importFiles}
-					type="file"
-					accept=".json"
-					hidden
-					on:change={() => {
-						console.log(importFiles);
-
-						let reader = new FileReader();
-						reader.onload = async (event) => {
-							let savedModels = JSON.parse(event.target.result);
-							console.log(savedModels);
-
-							for (const model of savedModels) {
-								if (model?.info ?? false) {
-									if ($_models.find((m) => m.id === model.id)) {
-										await updateModelById(localStorage.token, model.id, model.info).catch(
-											(error) => {
-												return null;
-											}
-										);
-									} else {
-										await createNewModel(localStorage.token, model.info).catch((error) => {
-											return null;
-										});
-									}
-								} else {
-									if (model?.id && model?.name) {
-										await createNewModel(localStorage.token, model).catch((error) => {
-											return null;
-										});
-									}
-								}
-							}
-
-							await _models.set(
-								await getModels(
-									localStorage.token,
-									$config?.features?.enable_direct_connections &&
-										($settings?.directConnections ?? null)
-								)
-							);
-							models = await getWorkspaceModels(localStorage.token);
-						};
-
-						reader.readAsText(importFiles[0]);
-					}}
-				/>
-
-				<button
-					class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
-					on:click={() => {
-						modelsImportInputElement.click();
-					}}
-				>
-					<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Models')}</div>
-
-					<div class=" self-center">
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="w-3.5 h-3.5"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
-								clip-rule="evenodd"
-							/>
-						</svg>
-					</div>
-				</button>
-
-				{#if models.length}
-					<button
-						class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
-						on:click={async () => {
-							downloadModels(models);
-						}}
-					>
-						<div class=" self-center mr-2 font-medium line-clamp-1">
-							{$i18n.t('Export Models')} ({models.length})
-						</div>
-
-						<div class=" self-center">
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								viewBox="0 0 16 16"
-								fill="currentColor"
-								class="w-3.5 h-3.5"
-							>
-								<path
-									fill-rule="evenodd"
-									d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
-									clip-rule="evenodd"
-								/>
-							</svg>
-						</div>
-					</button>
-				{/if}
-			</div>
-		</div>
-	{/if}
-
 	{#if $config?.features.enable_community_sharing}
 		<div class=" my-16">
 			<div class=" text-xl font-medium mb-1 line-clamp-1">