mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-27 06:46:30 -05:00
Resolved conflicts in ~47 files. Kept local customizations and integrated upstream security fixes, channels streaming, async migration, and new features. Backend - RAG / vector / audio / video / storage - retrieval.py, knowledge.py: kept two-namespace deletion pattern, expandable tree, Drive sync hooks, get_namespace_for_collection helper - pinecone.py + retrieval/vector/utils.py: kept namespace isolation, retry logic, PINECONE_DIMENSION PersistentConfig - retrieval/utils.py + loaders/main.py: kept RobustCSVLoader, transcript segments; took upstream Excel/PPTX loaders - audio.py: preserved results[idx] chunk ordering, HTTPException re-raise, audio/* + video/* MIME support - storage/provider.py: kept caching + signed URL changes Backend - core - main.py: combined local schedulers (temp file cleanup, Gmail sync, Drive sync) with upstream scheduler_worker_loop; kept gmail.router - config.py: kept Alembic multi-head cleanup + upgrade-to-heads, expanded GOOGLE_OAUTH_SCOPE (Gmail + Drive readonly), merged GOOGLE_OAUTH_AUTHORIZE_PARAMS, kept local RAG defaults + VIDEO_AUDIO_CHUNKING_STRATEGY - env.py: kept local WEBUI_NAME branding (no '(Open WebUI)' suffix) - middleware.py: combined UserUsages.record_usage with upstream outlet_filter_handler - oauth.py: awaited update_session_by_id, kept verbose token-refresh errors and trigger_gmail_sync_if_needed - pdf_generator.py: kept ReportLab implementation with custom fonts - task.py: made prompt_improvement_template async-aware - routers/tasks.py: awaited prompt_improvement_template - utils/models.py: kept missing_function_ids tracking - utils/misc.py: kept broader audio/video MIME support Backend - models - users.py: added Float import, kept UserSpendLimitForm, gmail_sync default, UserUsages cleanup; took upstream profile_image_url validator and async delete pattern - chats.py: kept JSONB indexes, archived/pinned, custom indexes, PG17 JSON_TABLE search optimization; took upstream async migration Backend - routers (upstream security fixes + local hooks) - channels.py: took upstream get_filtered_models + CHAT_COMPLETION_HANDLER; kept process_message_knowledge for RAG context injection - chats.py, files.py, users.py, memories.py, openai.py, auths.py: took upstream mass-assignment, SSRF, ownership, image URL validation fixes; kept spend-limit and external user ID hooks Alembic migrations - Renamed upstream e1f2a3b4c5d6_add_is_pinned_to_note.py revision id to e2f3a4b5c6d7 to resolve collision with our merge_all_heads migration - Added f7e8d9c0b1a2_merge_post_v0_9_5.py merging e1f2a3b4c5d6 + a0b1c2d3e4f5 into a single head Frontend (Svelte) - Chat.svelte: kept previousChatIdProp dedup + messageQueue for in-flight messages - CitationModal.svelte: kept WEBUI_BASE_URL import alongside upstream config + injectCsp - ContentRenderer.svelte: kept csv/json artifact detection alongside upstream tool-call/reasoning rendering - MarkdownTokens.svelte: kept tableToCSV alongside new GROUPABLE_DETAIL helpers - Placeholder.svelte: kept mobile-friendly model selection layout - channel/Messages/Message.svelte: re-inserted Knowledge Sources display; took upstream timestamp formatting - channel/MessageInput/InputMenu.svelte: removed bits-ui, kept knowledge attach tabs - workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte: kept Connect Google Drive entry, normalized button styling - layout/Navbar/Menu.svelte + layout/Sidebar/ChatMenu.svelte: kept ReportLab backend PDF + Word doc export, layered upstream client-side PDF as alternative Translations + assets - en-US/translation.json: merged new upstream keys, kept local PPTX, Gmail sync, video chapters, spend-limit strings - favicons left as-is (kept ours) Dependencies - requirements.txt + pyproject.toml: harmonized redis 7.4.0, kept unstructured[all-docs] extra, took upstream pydub 0.25.1, ddgs 9.11.4 Dockerfile - Adopted upstream ENV UV_LINK_MODE=copy for QEMU arm64 cross-builds; kept local CUDA + model pre-download blocks Version bump - package.json + pyproject.toml -> 0.9.5 - CHANGELOG.md: added Fork Merge Summary section Made-with: Cursor
223 lines
6.7 KiB
Python
223 lines
6.7 KiB
Python
"""
|
|
Async Utilities
|
|
|
|
Shared utilities for async operations including:
|
|
- Concurrent execution with semaphore control
|
|
- Parallel batch processing
|
|
- Rate-limited async operations
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import (
|
|
Any,
|
|
Awaitable,
|
|
Callable,
|
|
Dict,
|
|
List,
|
|
Optional,
|
|
Tuple,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T")
|
|
R = TypeVar("R")
|
|
|
|
|
|
async def gather_with_concurrency(
|
|
tasks: List[Awaitable[T]],
|
|
max_concurrent: int = 10,
|
|
return_exceptions: bool = True,
|
|
) -> List[Union[T, Exception]]:
|
|
"""
|
|
Execute async tasks with controlled concurrency.
|
|
|
|
This is a common pattern for limiting parallel operations to prevent
|
|
resource exhaustion (memory, connections, API rate limits).
|
|
|
|
Args:
|
|
tasks: List of awaitable tasks to execute
|
|
max_concurrent: Maximum number of concurrent tasks (default: 10)
|
|
return_exceptions: If True, exceptions are returned in results.
|
|
If False, first exception is raised.
|
|
|
|
Returns:
|
|
List of results in the same order as input tasks.
|
|
Failed tasks return Exception objects if return_exceptions=True.
|
|
|
|
Example:
|
|
>>> async def fetch(url):
|
|
... async with aiohttp.ClientSession() as session:
|
|
... async with session.get(url) as resp:
|
|
... return await resp.text()
|
|
>>> urls = ["http://example.com"] * 100
|
|
>>> tasks = [fetch(url) for url in urls]
|
|
>>> results = await gather_with_concurrency(tasks, max_concurrent=10)
|
|
"""
|
|
if not tasks:
|
|
return []
|
|
|
|
if max_concurrent <= 0:
|
|
max_concurrent = len(tasks) # Unlimited
|
|
|
|
semaphore = asyncio.Semaphore(max_concurrent)
|
|
|
|
async def limited_task(task: Awaitable[T]) -> T:
|
|
async with semaphore:
|
|
return await task
|
|
|
|
limited_tasks = [limited_task(task) for task in tasks]
|
|
return await asyncio.gather(*limited_tasks, return_exceptions=return_exceptions)
|
|
|
|
|
|
async def process_batch_parallel(
|
|
items: List[T],
|
|
processor: Callable[[T], Awaitable[R]],
|
|
max_concurrent: int = 5,
|
|
on_progress: Optional[Callable[[int, int], Awaitable[None]]] = None,
|
|
yield_interval: int = 0,
|
|
yield_delay: float = 0.0,
|
|
) -> Tuple[List[R], List[Dict[str, Any]]]:
|
|
"""
|
|
Process a batch of items in parallel with controlled concurrency.
|
|
|
|
Provides a clean abstraction for parallel processing with:
|
|
- Semaphore-based concurrency control
|
|
- Error isolation (one failure doesn't stop others)
|
|
- Progress callbacks
|
|
- Periodic yields to keep event loop responsive
|
|
|
|
Args:
|
|
items: List of items to process
|
|
processor: Async function to process each item
|
|
max_concurrent: Maximum parallel processors (default: 5)
|
|
on_progress: Optional async callback(processed_count, total_count)
|
|
yield_interval: Yield to event loop every N items (0 = disabled)
|
|
yield_delay: Delay in seconds when yielding (default: 0)
|
|
|
|
Returns:
|
|
Tuple of (successful_results, errors)
|
|
- successful_results: List of successful processor outputs
|
|
- errors: List of dicts with {index, item, error}
|
|
|
|
Example:
|
|
>>> async def embed_text(text):
|
|
... return await embedding_service.embed(text)
|
|
>>> texts = ["Hello", "World", "Test"]
|
|
>>> results, errors = await process_batch_parallel(
|
|
... items=texts,
|
|
... processor=embed_text,
|
|
... max_concurrent=5
|
|
... )
|
|
"""
|
|
if not items:
|
|
return [], []
|
|
|
|
successful_results: List[R] = []
|
|
errors: List[Dict[str, Any]] = []
|
|
processed_count = 0
|
|
total_count = len(items)
|
|
|
|
semaphore = asyncio.Semaphore(max_concurrent)
|
|
|
|
async def process_with_semaphore(index: int, item: T) -> Tuple[int, Optional[R], Optional[Exception]]:
|
|
async with semaphore:
|
|
try:
|
|
result = await processor(item)
|
|
return index, result, None
|
|
except Exception as e:
|
|
return index, None, e
|
|
|
|
# Create all tasks
|
|
tasks = [process_with_semaphore(i, item) for i, item in enumerate(items)]
|
|
|
|
# Process with gather (maintains order via index)
|
|
results = await asyncio.gather(*tasks, return_exceptions=False)
|
|
|
|
# Sort by index and collect results
|
|
for index, result, error in sorted(results, key=lambda x: x[0]):
|
|
processed_count += 1
|
|
|
|
if error:
|
|
errors.append(
|
|
{
|
|
"index": index,
|
|
"item": items[index],
|
|
"error": error,
|
|
}
|
|
)
|
|
logger.debug(f"Item {index} failed: {error}")
|
|
else:
|
|
successful_results.append(result)
|
|
|
|
# Progress callback
|
|
if on_progress:
|
|
await on_progress(processed_count, total_count)
|
|
|
|
# Periodic yield to event loop
|
|
if yield_interval > 0 and processed_count % yield_interval == 0:
|
|
await asyncio.sleep(yield_delay)
|
|
|
|
return successful_results, errors
|
|
|
|
|
|
async def retry_async(
|
|
func: Callable[[], Awaitable[T]],
|
|
max_retries: int = 3,
|
|
base_delay: float = 1.0,
|
|
max_delay: float = 30.0,
|
|
exponential: bool = True,
|
|
retryable_exceptions: Tuple[type, ...] = (Exception,),
|
|
) -> T:
|
|
"""
|
|
Retry an async function with exponential backoff.
|
|
|
|
Args:
|
|
func: Async function to retry (takes no arguments)
|
|
max_retries: Maximum retry attempts (default: 3)
|
|
base_delay: Initial delay in seconds (default: 1.0)
|
|
max_delay: Maximum delay cap in seconds (default: 30.0)
|
|
exponential: Use exponential backoff (default: True)
|
|
retryable_exceptions: Tuple of exception types to retry on
|
|
|
|
Returns:
|
|
Result of successful function call
|
|
|
|
Raises:
|
|
Last exception if all retries fail
|
|
|
|
Example:
|
|
>>> async def flaky_api_call():
|
|
... return await api.call()
|
|
>>> result = await retry_async(
|
|
... flaky_api_call,
|
|
... max_retries=3,
|
|
... retryable_exceptions=(aiohttp.ClientError,)
|
|
... )
|
|
"""
|
|
import random
|
|
|
|
last_exception = None
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
return await func()
|
|
except retryable_exceptions as e:
|
|
last_exception = e
|
|
|
|
if attempt < max_retries - 1:
|
|
if exponential:
|
|
delay = min(max_delay, base_delay * (2**attempt) + random.uniform(0, 1))
|
|
else:
|
|
delay = base_delay
|
|
|
|
logger.debug(f"Retry {attempt + 1}/{max_retries} after {delay:.1f}s: {e}")
|
|
await asyncio.sleep(delay)
|
|
else:
|
|
logger.warning(f"All {max_retries} retries failed: {e}")
|
|
|
|
raise last_exception
|