This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-10 01:38:32 -06:00
1 parent 060648f939
commit 8fbfd14a8b
4 files changed
+317 -29

No files matched your search

+199
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import io
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -11,6 +12,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Callable, Iterator, Optional, Sequence, Union from typing import Callable, Iterator, Optional, Sequence, Union
from urllib.parse import unquote, urlparse
import tiktoken import tiktoken
from fastapi import ( from fastapi import (
@@ -46,6 +48,8 @@ from open_webui.config import (
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ( from open_webui.env import (
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_SESSION_SSL,
DEVICE_TYPE, DEVICE_TYPE,
DOCKER, DOCKER,
RAG_EMBEDDING_TIMEOUT, RAG_EMBEDDING_TIMEOUT,
@@ -71,6 +75,7 @@ from open_webui.retrieval.utils import (
get_embedding_function, get_embedding_function,
get_model_path, get_model_path,
get_reranking_function, get_reranking_function,
is_youtube_url,
query_collection, query_collection,
query_collection_with_hybrid_search, query_collection_with_hybrid_search,
query_doc, query_doc,
@@ -91,6 +96,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.google_pse import search_google_pse from open_webui.retrieval.web.google_pse import search_google_pse
from open_webui.retrieval.web.jina_search import search_jina from open_webui.retrieval.web.jina_search import search_jina
from open_webui.retrieval.web.kagi import search_kagi from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
# Web search engines # Web search engines
from open_webui.retrieval.web.main import SearchResult from open_webui.retrieval.web.main import SearchResult
@@ -443,6 +449,16 @@ class ProcessUrlForm(CollectionNameForm):
url: str url: str
class ProcessUrlResponse(BaseModel):
status: bool
type: str
name: str
url: str
collection_name: str | None = None
content: str | None = None
file: dict | None = None
class SearchForm(BaseModel): class SearchForm(BaseModel):
queries: list[str] queries: list[str]
@@ -2127,6 +2143,189 @@ async def process_text(
) )
async def _fetch_url(url: str, max_size_mb: int | str | None) -> dict:
await asyncio.to_thread(validate_url, url)
max_bytes = None
if max_size_mb:
try:
max_bytes = int(max_size_mb) * 1024 * 1024
except (TypeError, ValueError):
max_bytes = None
async with get_ssrf_safe_session() as session:
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
content_disposition = response.headers.get('Content-Disposition', '')
content_length = response.headers.get('Content-Length')
base_content_type = content_type.split(';')[0].strip().lower()
is_attachment = content_disposition.split(';')[0].strip().lower() == 'attachment'
chunks = []
total = 0
iterator = response.content.iter_chunked(64 * 1024)
first_chunk = await anext(iterator, b'')
if not is_attachment and base_content_type in {'text/html', 'application/xhtml+xml'}:
return {'kind': 'web'}
if not is_attachment and base_content_type in {'', 'application/octet-stream', 'binary/octet-stream'}:
sample = first_chunk[:4096].lstrip().lower()
if sample.startswith(
(b'<!doctype html', b'<html', b'<head', b'<body', b'<?xml')
) or b'<html' in sample[:1024]:
return {'kind': 'web'}
if max_bytes and content_length:
try:
if int(content_length) > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'),
)
except ValueError:
pass
if first_chunk:
chunks.append(first_chunk)
total += len(first_chunk)
if max_bytes and total > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'),
)
async for chunk in iterator:
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if max_bytes and total > max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=ERROR_MESSAGES.FILE_TOO_LARGE(size=f'{max_size_mb} MB'),
)
data = b''.join(chunks)
image_mime = None
try:
from PIL import Image
image = Image.open(io.BytesIO(data))
image.verify()
image_mime = Image.MIME.get(image.format) if image.format else None
except Exception:
image_mime = None
if base_content_type.startswith('image/') and image_mime is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Invalid image content'),
)
filename = ''
filename_star = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition, re.IGNORECASE)
filename_plain = re.search(r'filename="?([^";]+)"?', content_disposition, re.IGNORECASE)
if filename_star:
filename = unquote(filename_star.group(1))
elif filename_plain:
filename = filename_plain.group(1)
if not filename:
filename = os.path.basename(urlparse(url).path)
filename = os.path.basename(filename or 'download')
resolved_content_type = (
image_mime or base_content_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
)
if not os.path.splitext(filename)[1]:
filename = f'{filename}{mimetypes.guess_extension(resolved_content_type) or ".bin"}'
return {
'kind': 'file',
'data': data,
'filename': filename,
'content_type': resolved_content_type,
}
@router.post('/process/url', response_model=ProcessUrlResponse)
async def process_url(
request: Request,
form_data: ProcessUrlForm,
process: bool = Query(True, description='Whether to process and save the content'),
user=Depends(get_verified_user),
):
try:
if is_youtube_url(form_data.url):
result = await process_web(request, form_data, process=process, user=user)
return {
'status': True,
'type': 'youtube',
'name': form_data.url,
'url': form_data.url,
'collection_name': result.get('collection_name'),
'content': result.get('content'),
}
config = await get_retrieval_config()
url_result = await _fetch_url(form_data.url, config.FILE_MAX_SIZE)
if url_result['kind'] == 'web':
result = await process_web(request, form_data, process=process, user=user)
return {
'status': True,
'type': 'web',
'name': form_data.url,
'url': form_data.url,
'collection_name': result.get('collection_name'),
'content': result.get('content'),
}
from open_webui.routers.files import upload_file_handler
is_image = url_result['content_type'].startswith('image/')
file = UploadFile(
file=io.BytesIO(url_result['data']),
filename=url_result['filename'],
headers={'content-type': url_result['content_type']},
)
uploaded_file = await upload_file_handler(
request,
file=file,
metadata={'source_url': form_data.url},
process=process and not is_image,
process_in_background=False,
user=user,
)
file_data = uploaded_file.model_dump() if hasattr(uploaded_file, 'model_dump') else uploaded_file
file_id = file_data.get('id') if isinstance(file_data, dict) else None
if file_id:
refreshed_file = await Files.get_file_by_id(file_id)
if refreshed_file:
file_data = refreshed_file.model_dump()
return {
'status': True,
'type': 'image' if is_image else 'file',
'name': url_result['filename'],
'url': form_data.url,
'collection_name': (file_data.get('meta') or {}).get('collection_name'),
'file': file_data,
}
except HTTPException:
raise
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing URL'),
)
@router.post('/process/youtube') @router.post('/process/youtube')
@router.post('/process/web') @router.post('/process/web')
async def process_web( async def process_web(
+42
View File
@@ -330,6 +330,48 @@ export const processYoutubeVideo = async (token: string, url: string) => {
return res; return res;
}; };
export const processUrl = async (
token: string,
url: string,
collection_name: string | null = null,
process: boolean = true
) => {
let error = null;
const searchParams = new URLSearchParams();
if (!process) {
searchParams.append('process', 'false');
}
const res = await fetch(`${RETRIEVAL_API_BASE_URL}/process/url?${searchParams.toString()}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
url,
collection_name
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const processWeb = async ( export const processWeb = async (
token: string, token: string,
collection_name: string, collection_name: string,
+34 -10
View File
@@ -62,7 +62,6 @@
processDetails, processDetails,
removeAllDetails, removeAllDetails,
getCodeBlockContents, getCodeBlockContents,
isYoutubeUrl,
displayFileHandler displayFileHandler
} from '$lib/utils'; } from '$lib/utils';
import { AudioQueue } from '$lib/utils/audio'; import { AudioQueue } from '$lib/utils/audio';
@@ -82,7 +81,7 @@
updateChatFolderIdById updateChatFolderIdById
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import { generateOpenAIChatCompletion } from '$lib/apis/openai'; import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval'; import { processUrl, processWebSearch } from '$lib/apis/retrieval';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users'; import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import { import {
generateQueries, generateQueries,
@@ -1560,24 +1559,49 @@
for (const fileItem of fileItems) { for (const fileItem of fileItems) {
try { try {
const res = isYoutubeUrl(fileItem.url) const res = await processUrl(localStorage.token, fileItem.url);
? await processYoutubeVideo(localStorage.token, fileItem.url)
: await processWeb(localStorage.token, '', fileItem.url);
if (res) { if (res) {
const uploadedFile = res.file;
fileItem.status = 'uploaded'; fileItem.status = 'uploaded';
fileItem.name = res.name ?? fileItem.name;
fileItem.collection_name = res.collection_name; fileItem.collection_name = res.collection_name;
fileItem.file = {
...res.file, if (res.type === 'image' && uploadedFile) {
...fileItem.file fileItem.type = 'image';
}; fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.url = `${uploadedFile.id}`;
fileItem.content_type = uploadedFile.meta?.content_type;
fileItem.size = uploadedFile.meta?.size;
} else if (res.type === 'file' && uploadedFile) {
fileItem.type = 'file';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.url = `${uploadedFile.id}`;
fileItem.content_type = uploadedFile.meta?.content_type;
fileItem.size = uploadedFile.meta?.size;
fileItem.collection_name =
res.collection_name ?? uploadedFile.meta?.collection_name ?? uploadedFile.collection_name;
} else {
fileItem.type = 'text';
fileItem.file = {
data: {
content: res.content
},
meta: {
name: res.name ?? fileItem.name,
source: res.url ?? fileItem.url
}
};
}
} }
files = [...files]; files = [...files];
} catch (e) { } catch (e) {
fileItem.status = 'error'; fileItem.status = 'error';
fileItem.error = `${e}`; fileItem.error = `${e}`;
files = files.filter((f) => f.name !== url); files = files.filter((f) => f.name !== fileItem.name);
toast.error(`${e}`); toast.error(`${e}`);
} }
} }
@@ -46,9 +46,9 @@
syncKnowledgeCleanup, syncKnowledgeCleanup,
testExternalKnowledgeRetrieval testExternalKnowledgeRetrieval
} from '$lib/apis/knowledge'; } from '$lib/apis/knowledge';
import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval'; import { processUrl } from '$lib/apis/retrieval';
import { blobToFile, isYoutubeUrl, copyToClipboard } from '$lib/utils'; import { blobToFile, copyToClipboard } from '$lib/utils';
import { computeFileHash } from '$lib/utils/hash'; import { computeFileHash } from '$lib/utils/hash';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
@@ -307,6 +307,11 @@
}; };
const uploadWeb = async (urls) => { const uploadWeb = async (urls) => {
if (!knowledge) {
toast.error($i18n.t('Knowledge base not found.'));
return;
}
if (!Array.isArray(urls)) { if (!Array.isArray(urls)) {
urls = [urls]; urls = [urls];
} }
@@ -329,29 +334,47 @@
for (const fileItem of newFileItems) { for (const fileItem of newFileItems) {
try { try {
console.log(fileItem); console.log(fileItem);
const res = await processWeb(localStorage.token, '', fileItem.url, false).catch((e) => { const res = await processUrl(localStorage.token, fileItem.url).catch((e) => {
console.error('Error processing web URL:', e); console.error('Error processing URL:', e);
return null; return null;
}); });
if (res) { if (res) {
console.log(res); console.log(res);
const file = createFileFromText( let uploadedFile = res.file;
// Use URL as filename, sanitized
fileItem.url
.replace(/[^a-z0-9]/gi, '_')
.toLowerCase()
.slice(0, 50),
res.content
);
const uploadedFile = await uploadFile(localStorage.token, file, { if (res.type === 'web' || res.type === 'youtube') {
knowledge_id: knowledge.id, const file = createFileFromText(
directory_id: currentDirectoryId // Use URL as filename, sanitized
}).catch((e) => { fileItem.url
toast.error(`${e}`); .replace(/[^a-z0-9]/gi, '_')
return null; .toLowerCase()
}); .slice(0, 50),
res.content ?? ''
);
uploadedFile = await uploadFile(localStorage.token, file, {
knowledge_id: knowledge.id,
directory_id: currentDirectoryId,
source_url: fileItem.url
}).catch((e) => {
toast.error(`${e}`);
return null;
});
} else if (uploadedFile?.id) {
const linkedKnowledge = await addFileToKnowledgeById(
localStorage.token,
knowledge.id,
uploadedFile.id,
currentDirectoryId
).catch((e) => {
toast.error(`${e}`);
return null;
});
if (!linkedKnowledge) {
uploadedFile = null;
}
}
if (uploadedFile) { if (uploadedFile) {
console.log(uploadedFile); console.log(uploadedFile);