refac
This commit is contained in:
1 parent
060648f939
commit
8fbfd14a8b
4 files changed
+317
-29
No files matched your search
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user