mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-05 02:41:34 +00:00
Merge remote-tracking branch 'upstream/dev' into fix/user-shell-tools-stable
# Conflicts: # src/routes/+layout.svelte
This commit is contained in:
commit
7c9d24baf3
88 changed files with 2872 additions and 2074 deletions
|
|
@ -25,8 +25,15 @@ ENABLE_KNOWLEDGE_FILE_RETENTION=false
|
|||
# Comma-separated chunk metadata keys to expose to the model alongside retrieved content.
|
||||
RAG_SOURCE_METADATA_KEYS=''
|
||||
|
||||
# Set to false to disable workspace Tools and Functions.
|
||||
# Master switch for internal Tools/Functions and external OpenAPI/MCP/Open Terminal plugins.
|
||||
# All plugin switches require a restart; the master overrides all feature switches.
|
||||
ENABLE_PLUGINS=true
|
||||
# Set false to disable workspace Tools and their dependency installation.
|
||||
ENABLE_TOOLS=true
|
||||
# Set false to disable Functions (filters, pipes, actions, event functions) and their dependencies.
|
||||
ENABLE_FUNCTIONS=true
|
||||
# Set false to disable external tools and terminals, including personal direct connections.
|
||||
ENABLE_TOOL_SERVERS=true
|
||||
|
||||
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||
FORWARDED_ALLOW_IPS='*'
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
|
||||
INVALID_TOKEN = 'Your session has expired or the token is invalid. Please sign in again.'
|
||||
INVALID_CRED = 'The email or password provided is incorrect. Please check for typos and try logging in again.'
|
||||
OAUTH_LOGIN_FAILED = 'Sign-in with your identity provider failed. Please contact your administrator for assistance.'
|
||||
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
|
||||
INCORRECT_PASSWORD = 'The password provided is incorrect. Please check for typos and try again.'
|
||||
INVALID_TRUSTED_HEADER = (
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ ENABLE_DB_MIGRATIONS = os.getenv('ENABLE_DB_MIGRATIONS', 'True').lower() == 'tru
|
|||
# Swap the JSON encoder/decoder used across the app (HTTP request bodies, JSONResponse
|
||||
# bodies, upstream provider responses, socket.io payloads) from the stdlib `json` module
|
||||
# to orjson. Faster, but stricter: see open_webui/utils/json_codec.py for the differences.
|
||||
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'False').lower() == 'true'
|
||||
ENABLE_ORJSON = os.getenv('ENABLE_ORJSON', 'True').lower() == 'true'
|
||||
|
||||
|
||||
# Function to parse each section
|
||||
|
|
@ -1192,6 +1192,10 @@ VIEW_FILE_DEFAULT_MAX_CHARS = _int_env('VIEW_FILE_DEFAULT_MAX_CHARS', 10_000)
|
|||
####################################
|
||||
|
||||
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
|
||||
# Deployment controls: the master switch always overrides all feature switches.
|
||||
ENABLE_TOOLS = ENABLE_PLUGINS and os.getenv('ENABLE_TOOLS', 'True').lower() == 'true'
|
||||
ENABLE_FUNCTIONS = ENABLE_PLUGINS and os.getenv('ENABLE_FUNCTIONS', 'True').lower() == 'true'
|
||||
ENABLE_TOOL_SERVERS = ENABLE_PLUGINS and os.getenv('ENABLE_TOOL_SERVERS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
|
||||
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import uuid
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import ENABLE_PLUGINS, VERSION
|
||||
from open_webui.models.config import Config
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from open_webui.env import ENABLE_FUNCTIONS, VERSION
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
|
|
@ -1103,7 +1104,7 @@ class SocketSessionEventSink:
|
|||
async def dispatch_event_functions(
|
||||
app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None
|
||||
) -> None:
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return
|
||||
|
||||
from open_webui.models.functions import Functions
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from starlette.responses import Response, StreamingResponse
|
|||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FUNCTIONS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
|
|
@ -69,7 +69,7 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
|
|||
|
||||
|
||||
async def get_function_models(request):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return []
|
||||
|
||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
|
|||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return JSONCodec.dumps(value) if value is not None else None
|
||||
return JSONCodec.dumps(value, ensure_ascii=False) if value is not None else None
|
||||
|
||||
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
|
||||
return JSONCodec.loads(value) if value is not None else None
|
||||
|
|
@ -265,7 +265,7 @@ def _json_codec_kwargs(kwargs: dict) -> dict:
|
|||
Unlike ``JSONField``, those serialize through the engine, which otherwise uses
|
||||
stdlib ``json``. With ``ENABLE_ORJSON`` off JSONCodec is stdlib ``json`` anyway.
|
||||
"""
|
||||
kwargs.setdefault('json_serializer', JSONCodec.dumps)
|
||||
kwargs.setdefault('json_serializer', lambda value: JSONCodec.dumps(value, ensure_ascii=False))
|
||||
kwargs.setdefault('json_deserializer', JSONCodec.loads)
|
||||
return kwargs
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,7 @@ from open_webui.config import (
|
|||
seed_registered_defaults,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES, TASKS
|
||||
from open_webui.utils.recurrence import RecurrenceEvaluationTimeout
|
||||
from open_webui.env import (
|
||||
USE_SLIM,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AUDIT_EXCLUDED_PATHS,
|
||||
AUDIT_INCLUDED_PATHS,
|
||||
|
|
@ -88,6 +86,7 @@ from open_webui.env import (
|
|||
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
ENABLE_EASTER_EGGS,
|
||||
ENABLE_FUNCTIONS,
|
||||
# OAuth Back-Channel Logout
|
||||
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
|
||||
ENABLE_OTEL,
|
||||
|
|
@ -98,6 +97,8 @@ from open_webui.env import (
|
|||
ENABLE_SCIM,
|
||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE,
|
||||
ENABLE_TOOL_SERVERS,
|
||||
ENABLE_TOOLS,
|
||||
ENABLE_VERSION_UPDATE_CHECK,
|
||||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
|
|
@ -113,6 +114,7 @@ from open_webui.env import (
|
|||
RESET_CONFIG_ON_START,
|
||||
SAFE_MODE,
|
||||
SCIM_TOKEN,
|
||||
USE_SLIM,
|
||||
VERSION,
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL,
|
||||
WEBSOCKET_MANAGER,
|
||||
|
|
@ -193,6 +195,7 @@ from open_webui.socket.main import (
|
|||
periodic_session_pool_cleanup,
|
||||
periodic_usage_pool_cleanup,
|
||||
redis_event_listener,
|
||||
sio,
|
||||
)
|
||||
from open_webui.socket.main import (
|
||||
app as socket_app,
|
||||
|
|
@ -221,6 +224,7 @@ from open_webui.utils.auth import (
|
|||
get_http_authorization_cred,
|
||||
get_license_data,
|
||||
get_verified_user,
|
||||
is_valid_token,
|
||||
)
|
||||
from open_webui.utils.chat import (
|
||||
chat_completed as chat_completed_handler,
|
||||
|
|
@ -269,6 +273,7 @@ from open_webui.utils.oauth import (
|
|||
resolve_oauth_client_info,
|
||||
)
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.recurrence import RecurrenceEvaluationTimeout
|
||||
from open_webui.utils.redis import get_redis_client
|
||||
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
|
||||
from open_webui.utils.tool_approval import (
|
||||
|
|
@ -397,6 +402,9 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
app.state.redis_event_listener = asyncio.create_task(redis_event_listener())
|
||||
# socket.io only starts listening on its first connect; event call answers need it earlier
|
||||
sio.manager_initialized = True
|
||||
sio.manager.initialize()
|
||||
|
||||
app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup())
|
||||
app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup())
|
||||
|
|
@ -430,7 +438,9 @@ async def lifespan(app: FastAPI):
|
|||
log.warning(f'Failed to pre-fetch models at startup: {e}')
|
||||
|
||||
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
|
||||
if len(await Config.get('tool_server.connections', []) or []) > 0:
|
||||
if ENABLE_TOOL_SERVERS and (
|
||||
await Config.get('tool_server.connections', []) or await Config.get('terminal_server.connections', [])
|
||||
):
|
||||
mock_request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
|
|
@ -497,7 +507,7 @@ async def lifespan(app: FastAPI):
|
|||
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
|
||||
|
||||
|
||||
# Opt-in (ENABLE_ORJSON): orjson for request-body parsing and JSONResponse bodies;
|
||||
# ENABLE_ORJSON: orjson for request-body parsing and JSONResponse bodies;
|
||||
# response_model routes keep FastAPI's Pydantic fast path either way.
|
||||
apply_orjson_http_json()
|
||||
|
||||
|
|
@ -629,7 +639,7 @@ async def initialize_runtime_config(app: FastAPI):
|
|||
migrate_access_control(connection.get('config', {}))
|
||||
await Config.upsert({'tool_server.connections': connections})
|
||||
|
||||
for tool_server_connection in connections:
|
||||
for tool_server_connection in connections if ENABLE_TOOL_SERVERS else []:
|
||||
if tool_server_connection.get('type', 'openapi') == 'mcp':
|
||||
server_id = (tool_server_connection.get('info') or {}).get('id')
|
||||
auth_type = tool_server_connection.get('auth_type', 'none')
|
||||
|
|
@ -1001,6 +1011,7 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend
|
|||
data=payload,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if not r.ok:
|
||||
errors.append({'url_idx': idx, 'error': await r.text()})
|
||||
|
|
@ -1040,6 +1051,7 @@ async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depend
|
|||
json={'model': actual_model},
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if not r.ok:
|
||||
detail = await r.text()
|
||||
|
|
@ -2242,7 +2254,7 @@ async def get_app_config(request: Request):
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid token',
|
||||
)
|
||||
if data is not None and 'id' in data:
|
||||
if data is not None and 'id' in data and await is_valid_token(data, request.app.state.redis):
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
|
||||
onboarding = False
|
||||
|
|
@ -2347,8 +2359,12 @@ async def get_app_config(request: Request):
|
|||
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
'enable_easter_eggs': ENABLE_EASTER_EGGS,
|
||||
'enable_direct_connections': config.get('direct.enable'),
|
||||
'enable_direct_integrations': config.get('direct.integrations.enable', False),
|
||||
'enable_direct_integrations': ENABLE_TOOL_SERVERS
|
||||
and config.get('direct.integrations.enable', False),
|
||||
'enable_plugins': ENABLE_PLUGINS,
|
||||
'enable_tools': ENABLE_TOOLS,
|
||||
'enable_functions': ENABLE_FUNCTIONS,
|
||||
'enable_tool_servers': ENABLE_TOOL_SERVERS,
|
||||
'enable_folders': config.get('folders.enable'),
|
||||
'folder_max_file_count': config.get('folders.max_file_count'),
|
||||
'enable_channels': config.get('channels.enable'),
|
||||
|
|
@ -2673,6 +2689,9 @@ except Exception as e:
|
|||
|
||||
|
||||
async def register_client(request, client_id: str) -> bool:
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
server_type, server_id = client_id.split(':', 1)
|
||||
|
||||
connection = None
|
||||
|
|
@ -2775,6 +2794,9 @@ async def oauth_client_authorize(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
# ensure_valid_client_registration
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
client = await oauth_client_manager.get_client(client_id)
|
||||
client_info = await oauth_client_manager.get_client_info(client_id)
|
||||
if client is None or client_info is None:
|
||||
|
|
@ -2816,6 +2838,9 @@ async def oauth_client_callback(
|
|||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
return await oauth_client_manager.handle_callback(
|
||||
request,
|
||||
client_id=client_id,
|
||||
|
|
|
|||
|
|
@ -244,6 +244,15 @@ class AutomationTable:
|
|||
await db.commit()
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def update_last_run_at(self, id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
row = await db.get(Automation, id)
|
||||
if not row:
|
||||
return None
|
||||
row.last_run_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def clear_folder_ids(
|
||||
self,
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -660,11 +660,13 @@ class ChatTable:
|
|||
db: AsyncSession | None = None,
|
||||
) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
from open_webui.utils.access_control.folders import has_folder_write_access
|
||||
|
||||
# Validate folder_id references — clear any that don't exist
|
||||
folder_ids = {f.folder_id for f in chat_import_forms if f.folder_id}
|
||||
existing = set()
|
||||
for fid in folder_ids:
|
||||
if await Folders.get_folder_by_id_and_user_id(fid, user_id, db=session):
|
||||
if await has_folder_write_access(user_id, fid, db=session):
|
||||
existing.add(fid)
|
||||
|
||||
cleared = 0
|
||||
|
|
@ -2583,11 +2585,11 @@ class ChatTable:
|
|||
if not file_ids:
|
||||
return None
|
||||
|
||||
chat_message_file_ids = {
|
||||
item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db)
|
||||
}
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(ChatFile.file_id).filter_by(chat_id=chat_id))
|
||||
chat_file_ids = set(result.scalars().all())
|
||||
# Remove duplicates and existing file_ids
|
||||
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids})
|
||||
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_file_ids})
|
||||
if not file_ids:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,29 @@ import os
|
|||
import numpy as np
|
||||
import torch
|
||||
from colbert.infra import ColBERTConfig
|
||||
from colbert.modeling import base_colbert, hf_colbert
|
||||
from colbert.modeling.checkpoint import Checkpoint
|
||||
from open_webui.retrieval.models.base_reranker import BaseReranker
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def class_factory(name_or_path: str) -> type:
|
||||
hf_colbert_class = hf_colbert.class_factory(name_or_path)
|
||||
|
||||
# colbert-ai never calls post_init, which transformers 5 needs to finish setting up the model
|
||||
class HF_ColBERT(hf_colbert_class):
|
||||
def __init__(self, config: PretrainedConfig, colbert_config: ColBERTConfig) -> None:
|
||||
super().__init__(config, colbert_config)
|
||||
self.post_init()
|
||||
|
||||
return HF_ColBERT
|
||||
|
||||
|
||||
base_colbert.class_factory = class_factory
|
||||
|
||||
|
||||
class ColBERT(BaseReranker):
|
||||
def __init__(self, name, **kwargs) -> None:
|
||||
log.info('ColBERT: Loading model %s', name)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ NOTE: This vector database integration is community-supported and maintained on
|
|||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable, Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.config import (
|
||||
|
|
@ -18,24 +20,32 @@ from open_webui.config import (
|
|||
MILVUS_TOKEN,
|
||||
MILVUS_URI,
|
||||
)
|
||||
from open_webui.env import ENABLE_DB_MIGRATIONS
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, process_metadata
|
||||
from open_webui.retrieval.vector.utils import iter_filter_conditions, merge_hybrid_search_results, process_metadata
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from pymilvus import DataType
|
||||
from pymilvus import CollectionSchema, DataType, Function, FunctionType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.client.types import LoadState
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Milvus caps stored text length (here the chunk lives under the JSON `data`
|
||||
# Milvus caps stored text length (here the chunk lives in `text` or the JSON `data`
|
||||
# field). Clamp long chunks before insert so one oversized chunk can't fail the
|
||||
# whole batch and leave the file with zero embeddings.
|
||||
MILVUS_TEXT_MAX_LENGTH = 65535
|
||||
# Milvus cannot add BM25 to an existing collection, so migration copies each one into a new collection.
|
||||
BM25_STAGING_SUFFIX = '_bm25_staging'
|
||||
# Even rows at Milvus's field size limits keep a batch this size below its 64 MB message limit.
|
||||
BM25_BACKFILL_BATCH_SIZE = 128
|
||||
BM25_BACKFILL_WORKERS = 8
|
||||
BM25_BACKFILL_INSERTS_IN_FLIGHT = 4
|
||||
_SAFE_METADATA_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
|
||||
|
||||
|
||||
|
|
@ -68,6 +78,163 @@ def _metadata_exprs(filter: Optional[dict]) -> list[str]:
|
|||
return exprs
|
||||
|
||||
|
||||
def _chunk_text(entity: dict) -> Optional[str]:
|
||||
return entity['text'] if 'text' in entity else entity.get('data', {}).get('text')
|
||||
|
||||
|
||||
def _truncate_text(text: str) -> str:
|
||||
return text.encode()[:MILVUS_TEXT_MAX_LENGTH].decode(errors='ignore')
|
||||
|
||||
|
||||
def _bm25_rows(rows: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'vector': row['vector'],
|
||||
'text': row['data']['text'],
|
||||
'metadata': row['metadata'],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _add_bm25_fields(schema: CollectionSchema):
|
||||
schema.add_field(field_name='sparse', datatype=DataType.SPARSE_FLOAT_VECTOR)
|
||||
schema.add_function(
|
||||
Function(
|
||||
name='text_bm25',
|
||||
function_type=FunctionType.BM25,
|
||||
input_field_names=['text'],
|
||||
output_field_names=['sparse'],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _supports_bm25(client: Client) -> bool:
|
||||
return tuple(int(part) for part in re.findall(r'\d+', client.get_server_version())[:2]) >= (2, 5)
|
||||
|
||||
|
||||
def _has_bm25_field(client: Client, collection: str) -> bool:
|
||||
return any(field['name'] == 'sparse' for field in client.describe_collection(collection)['fields'])
|
||||
|
||||
|
||||
def _pending_bm25_collections(client: Client, collections: Iterable[str], output_fields: list[str]) -> dict[str, int]:
|
||||
"""Finishes or clears an interrupted run, then maps each collection left to migrate to its dimension."""
|
||||
existing_collections = set(client.list_collections())
|
||||
pending_collections = {}
|
||||
for collection in collections:
|
||||
staging_collection = f'{collection}{BM25_STAGING_SUFFIX}'
|
||||
if staging_collection in existing_collections:
|
||||
if collection not in existing_collections:
|
||||
# An earlier start dropped the original after a complete copy but stopped before this rename.
|
||||
client.rename_collection(staging_collection, collection)
|
||||
client.load_collection(collection)
|
||||
continue
|
||||
client.drop_collection(staging_collection)
|
||||
if collection not in existing_collections:
|
||||
continue
|
||||
fields = client.describe_collection(collection)['fields']
|
||||
field_names = {field['name'] for field in fields}
|
||||
if 'sparse' not in field_names and field_names.issuperset(output_fields):
|
||||
pending_collections[collection] = next(
|
||||
field['params']['dim'] for field in fields if field['name'] == 'vector'
|
||||
)
|
||||
return pending_collections
|
||||
|
||||
|
||||
def _backfill_bm25_collections(
|
||||
client: Client,
|
||||
collections: Iterable[str],
|
||||
create_bm25_collection: Callable[[str, int], None],
|
||||
output_fields: list[str],
|
||||
to_bm25_rows: Callable[[list[dict]], list[dict]],
|
||||
):
|
||||
if not _supports_bm25(client):
|
||||
log.info('Milvus has no BM25 (needs 2.5+), native hybrid search stays off.')
|
||||
return
|
||||
pending_collections = _pending_bm25_collections(client, collections, output_fields)
|
||||
if not pending_collections:
|
||||
return
|
||||
|
||||
log.info('Migrating %s Milvus collections to native hybrid search.', len(pending_collections))
|
||||
# Milvus Lite (a local .db file) breaks under concurrent collection changes.
|
||||
max_workers = 1 if MILVUS_URI.endswith('.db') else BM25_BACKFILL_WORKERS
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
copies = [
|
||||
executor.submit(
|
||||
_copy_to_bm25_collection,
|
||||
client,
|
||||
collection,
|
||||
dimension,
|
||||
create_bm25_collection,
|
||||
output_fields,
|
||||
to_bm25_rows,
|
||||
)
|
||||
for collection, dimension in pending_collections.items()
|
||||
]
|
||||
copied = all(copy.result() for copy in copies)
|
||||
if copied:
|
||||
swaps = [
|
||||
executor.submit(_swap_in_bm25_collection, client, collection) for collection in pending_collections
|
||||
]
|
||||
for swap in swaps:
|
||||
swap.result()
|
||||
else:
|
||||
executor.shutdown(cancel_futures=True)
|
||||
log.error('Milvus migration to native hybrid search failed, all collections are kept unchanged.')
|
||||
for collection in pending_collections:
|
||||
client.drop_collection(f'{collection}{BM25_STAGING_SUFFIX}')
|
||||
return
|
||||
log.info('Migrated %s Milvus collections to native hybrid search.', len(pending_collections))
|
||||
|
||||
|
||||
def _swap_in_bm25_collection(client: Client, collection: str):
|
||||
client.drop_collection(collection)
|
||||
client.rename_collection(f'{collection}{BM25_STAGING_SUFFIX}', collection)
|
||||
client.load_collection(collection)
|
||||
|
||||
|
||||
def _copy_to_bm25_collection(
|
||||
client: Client,
|
||||
collection: str,
|
||||
dimension: int,
|
||||
create_bm25_collection: Callable[[str, int], None],
|
||||
output_fields: list[str],
|
||||
to_bm25_rows: Callable[[list[dict]], list[dict]],
|
||||
) -> bool:
|
||||
staging_collection = f'{collection}{BM25_STAGING_SUFFIX}'
|
||||
try:
|
||||
create_bm25_collection(staging_collection, dimension)
|
||||
was_released = client.get_load_state(collection)['state'] == LoadState.NotLoad
|
||||
try:
|
||||
client.load_collection(collection)
|
||||
iterator = client.query_iterator(
|
||||
collection_name=collection,
|
||||
output_fields=output_fields,
|
||||
batch_size=BM25_BACKFILL_BATCH_SIZE,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=BM25_BACKFILL_INSERTS_IN_FLIGHT) as insert_executor:
|
||||
inserts = []
|
||||
while batch := iterator.next():
|
||||
if len(inserts) == BM25_BACKFILL_INSERTS_IN_FLIGHT:
|
||||
inserts.pop(0).result()
|
||||
inserts.append(
|
||||
insert_executor.submit(
|
||||
client.insert, collection_name=staging_collection, data=to_bm25_rows(batch)
|
||||
)
|
||||
)
|
||||
for insert in inserts:
|
||||
insert.result()
|
||||
iterator.close()
|
||||
finally:
|
||||
if was_released:
|
||||
client.release_collection(collection)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error('Error copying Milvus collection %s: %s', collection, e)
|
||||
return False
|
||||
|
||||
|
||||
class MilvusClient(VectorDBBase):
|
||||
def __init__(self):
|
||||
self.collection_prefix = 'open_webui'
|
||||
|
|
@ -75,6 +242,19 @@ class MilvusClient(VectorDBBase):
|
|||
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB)
|
||||
else:
|
||||
self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN)
|
||||
if ENABLE_DB_MIGRATIONS:
|
||||
collections = {
|
||||
collection_name_full.removesuffix(BM25_STAGING_SUFFIX)
|
||||
for collection_name_full in self.client.list_collections()
|
||||
if collection_name_full.startswith(f'{self.collection_prefix}_')
|
||||
}
|
||||
_backfill_bm25_collections(
|
||||
self.client,
|
||||
collections,
|
||||
self._create_unloaded_collection,
|
||||
['id', 'vector', 'data', 'metadata'],
|
||||
_bm25_rows,
|
||||
)
|
||||
|
||||
def _result_to_get_result(self, result) -> GetResult:
|
||||
ids = []
|
||||
|
|
@ -86,7 +266,7 @@ class MilvusClient(VectorDBBase):
|
|||
_metadatas = []
|
||||
for item in match:
|
||||
_ids.append(item.get('id'))
|
||||
_documents.append(item.get('data', {}).get('text'))
|
||||
_documents.append(_chunk_text(item))
|
||||
_metadatas.append(item.get('metadata'))
|
||||
ids.append(_ids)
|
||||
documents.append(_documents)
|
||||
|
|
@ -115,7 +295,7 @@ class MilvusClient(VectorDBBase):
|
|||
# https://milvus.io/docs/de/metric.md
|
||||
_dist = (item.get('distance') + 1.0) / 2.0
|
||||
_distances.append(_dist)
|
||||
_documents.append(item.get('entity', {}).get('data', {}).get('text'))
|
||||
_documents.append(_chunk_text(item.get('entity', {})))
|
||||
_metadatas.append(item.get('entity', {}).get('metadata'))
|
||||
ids.append(_ids)
|
||||
distances.append(_distances)
|
||||
|
|
@ -131,6 +311,12 @@ class MilvusClient(VectorDBBase):
|
|||
)
|
||||
|
||||
def _create_collection(self, collection_name: str, dimension: int):
|
||||
collection_name_full = f'{self.collection_prefix}_{collection_name}'
|
||||
self._create_unloaded_collection(collection_name_full, dimension)
|
||||
self.client.load_collection(collection_name_full)
|
||||
|
||||
def _create_unloaded_collection(self, collection_name_full: str, dimension: int):
|
||||
supports_bm25 = _supports_bm25(self.client)
|
||||
schema = self.client.create_schema(
|
||||
auto_id=False,
|
||||
enable_dynamic_field=True,
|
||||
|
|
@ -147,7 +333,17 @@ class MilvusClient(VectorDBBase):
|
|||
dim=dimension,
|
||||
description='vector',
|
||||
)
|
||||
schema.add_field(field_name='data', datatype=DataType.JSON, description='data')
|
||||
if supports_bm25:
|
||||
schema.add_field(
|
||||
field_name='text',
|
||||
datatype=DataType.VARCHAR,
|
||||
max_length=MILVUS_TEXT_MAX_LENGTH,
|
||||
enable_analyzer=True,
|
||||
description='text',
|
||||
)
|
||||
_add_bm25_fields(schema)
|
||||
else:
|
||||
schema.add_field(field_name='data', datatype=DataType.JSON, description='data')
|
||||
schema.add_field(field_name='metadata', datatype=DataType.JSON, description='metadata')
|
||||
|
||||
index_params = self.client.prepare_index_params()
|
||||
|
|
@ -192,15 +388,14 @@ class MilvusClient(VectorDBBase):
|
|||
params=index_creation_params,
|
||||
)
|
||||
|
||||
self.client.create_collection(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
schema=schema,
|
||||
index_params=index_params,
|
||||
)
|
||||
if supports_bm25:
|
||||
index_params.add_index(field_name='sparse', index_type='SPARSE_INVERTED_INDEX', metric_type='BM25')
|
||||
|
||||
self.client.create_collection(collection_name=collection_name_full, schema=schema)
|
||||
self.client.create_index(collection_name=collection_name_full, index_params=index_params)
|
||||
log.info(
|
||||
"Successfully created collection '%s_%s' with index type '%s' and metric '%s'.",
|
||||
self.collection_prefix,
|
||||
collection_name,
|
||||
"Successfully created collection '%s' with index type '%s' and metric '%s'.",
|
||||
collection_name_full,
|
||||
index_type,
|
||||
metric_type,
|
||||
)
|
||||
|
|
@ -233,13 +428,59 @@ class MilvusClient(VectorDBBase):
|
|||
result = self.client.search(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
data=vectors,
|
||||
anns_field='vector',
|
||||
limit=limit,
|
||||
output_fields=['data', 'metadata'],
|
||||
output_fields=['data', 'text', 'metadata'],
|
||||
**kwargs,
|
||||
# search_params=search_params # Potentially add later if needed
|
||||
)
|
||||
return self._result_to_search_result(result)
|
||||
|
||||
def hybrid_search(
|
||||
self,
|
||||
collection_name: str,
|
||||
query: str,
|
||||
vectors: list[list[float | int]],
|
||||
filter: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
hybrid_bm25_weight: float = 0.5,
|
||||
) -> Optional[SearchResult]:
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
collection_name_full = f'{self.collection_prefix}_{collection_name}'
|
||||
if not self.client.has_collection(collection_name_full) or not _has_bm25_field(
|
||||
self.client, collection_name_full
|
||||
):
|
||||
return None
|
||||
self.client.load_collection(f'{self.collection_prefix}_{collection_name}')
|
||||
|
||||
vector_result = None
|
||||
if hybrid_bm25_weight < 1 and vectors:
|
||||
vector_result = self.search(collection_name=collection_name, vectors=vectors, filter=filter, limit=limit)
|
||||
|
||||
fts_results = []
|
||||
if hybrid_bm25_weight > 0 and query.strip():
|
||||
metadata_exprs = _metadata_exprs(filter)
|
||||
result = self.client.search(
|
||||
collection_name=collection_name_full,
|
||||
data=[query],
|
||||
anns_field='sparse',
|
||||
limit=limit,
|
||||
filter=' and '.join(metadata_exprs),
|
||||
output_fields=['text', 'metadata'],
|
||||
)
|
||||
fts_results = [
|
||||
{'id': hit['id'], 'text': hit['entity']['text'], 'vmetadata': hit['entity']['metadata']}
|
||||
for hit in result[0]
|
||||
]
|
||||
|
||||
return merge_hybrid_search_results(
|
||||
vector_result=vector_result,
|
||||
fts_results=fts_results,
|
||||
num_queries=len(vectors) or 1,
|
||||
limit=limit,
|
||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||
)
|
||||
|
||||
def query(self, collection_name: str, filter: dict, limit: int = -1):
|
||||
collection_name = collection_name.replace('-', '_')
|
||||
if not self.has_collection(collection_name):
|
||||
|
|
@ -272,6 +513,7 @@ class MilvusClient(VectorDBBase):
|
|||
output_fields=[
|
||||
'id',
|
||||
'data',
|
||||
'text',
|
||||
'metadata',
|
||||
],
|
||||
limit=limit if limit > 0 else -1,
|
||||
|
|
@ -317,20 +559,26 @@ class MilvusClient(VectorDBBase):
|
|||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info('Inserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
|
||||
has_bm25 = _has_bm25_field(self.client, f'{self.collection_prefix}_{collection_name}')
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(f'Milvus: truncating text id={item["id"]} {len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars')
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
data.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'data': {'text': text},
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
)
|
||||
text_bytes = len(text.encode())
|
||||
if text_bytes > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
'Milvus: truncating text id=%s %s->%s bytes', item['id'], text_bytes, MILVUS_TEXT_MAX_LENGTH
|
||||
)
|
||||
text = _truncate_text(text)
|
||||
row = {
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
if has_bm25:
|
||||
row['text'] = text
|
||||
else:
|
||||
row['data'] = {'text': text}
|
||||
data.append(row)
|
||||
try:
|
||||
return self.client.insert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
|
|
@ -357,20 +605,26 @@ class MilvusClient(VectorDBBase):
|
|||
self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector']))
|
||||
|
||||
log.info('Upserting %s items into collection %s_%s.', len(items), self.collection_prefix, collection_name)
|
||||
has_bm25 = _has_bm25_field(self.client, f'{self.collection_prefix}_{collection_name}')
|
||||
data = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(f'Milvus: truncating text id={item["id"]} {len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars')
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
data.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'data': {'text': text},
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
)
|
||||
text_bytes = len(text.encode())
|
||||
if text_bytes > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
'Milvus: truncating text id=%s %s->%s bytes', item['id'], text_bytes, MILVUS_TEXT_MAX_LENGTH
|
||||
)
|
||||
text = _truncate_text(text)
|
||||
row = {
|
||||
'id': item['id'],
|
||||
'vector': item['vector'],
|
||||
'metadata': process_metadata(item['metadata']),
|
||||
}
|
||||
if has_bm25:
|
||||
row['text'] = text
|
||||
else:
|
||||
row['data'] = {'text': text}
|
||||
data.append(row)
|
||||
try:
|
||||
return self.client.upsert(
|
||||
collection_name=f'{self.collection_prefix}_{collection_name}',
|
||||
|
|
|
|||
|
|
@ -17,14 +17,23 @@ from open_webui.config import (
|
|||
MILVUS_TOKEN,
|
||||
MILVUS_URI,
|
||||
)
|
||||
from open_webui.retrieval.vector.dbs.milvus import _metadata_exprs
|
||||
from open_webui.env import ENABLE_DB_MIGRATIONS
|
||||
from open_webui.retrieval.vector.dbs.milvus import (
|
||||
BM25_STAGING_SUFFIX,
|
||||
_add_bm25_fields,
|
||||
_backfill_bm25_collections,
|
||||
_has_bm25_field,
|
||||
_metadata_exprs,
|
||||
_supports_bm25,
|
||||
_truncate_text,
|
||||
)
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
from open_webui.retrieval.vector.utils import process_metadata
|
||||
from open_webui.retrieval.vector.utils import merge_hybrid_search_results, process_metadata
|
||||
from pymilvus import DataType
|
||||
from pymilvus import MilvusClient as Client
|
||||
from pymilvus.exceptions import MilvusException
|
||||
|
|
@ -81,6 +90,14 @@ class MilvusClient(VectorDBBase):
|
|||
self.WEB_SEARCH_COLLECTION,
|
||||
self.HASH_BASED_COLLECTION,
|
||||
]
|
||||
if ENABLE_DB_MIGRATIONS:
|
||||
_backfill_bm25_collections(
|
||||
self.client,
|
||||
self.shared_collections,
|
||||
self._create_shared_collection,
|
||||
['id', 'vector', 'text', 'metadata', RESOURCE_ID_FIELD],
|
||||
lambda rows: rows,
|
||||
)
|
||||
|
||||
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
|
|
@ -107,10 +124,18 @@ class MilvusClient(VectorDBBase):
|
|||
return self.KNOWLEDGE_COLLECTION, resource_id
|
||||
|
||||
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
|
||||
supports_bm25 = _supports_bm25(self.client)
|
||||
schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy')
|
||||
schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36)
|
||||
schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension)
|
||||
schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH)
|
||||
schema.add_field(
|
||||
field_name='text',
|
||||
datatype=DataType.VARCHAR,
|
||||
max_length=MILVUS_TEXT_MAX_LENGTH,
|
||||
enable_analyzer=supports_bm25,
|
||||
)
|
||||
if supports_bm25:
|
||||
_add_bm25_fields(schema)
|
||||
schema.add_field(field_name='metadata', datatype=DataType.JSON)
|
||||
schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255)
|
||||
|
||||
|
|
@ -132,6 +157,17 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
self.client.create_collection(collection_name=mt_collection_name, schema=schema)
|
||||
self.client.create_index(collection_name=mt_collection_name, index_params=vector_index)
|
||||
if supports_bm25:
|
||||
self.client.create_index(
|
||||
collection_name=mt_collection_name,
|
||||
index_params=self.client.prepare_index_params(
|
||||
field_name='sparse', index_type='SPARSE_INVERTED_INDEX', metric_type='BM25'
|
||||
),
|
||||
)
|
||||
self._create_resource_id_index(mt_collection_name)
|
||||
log.info('Created shared collection: %s', mt_collection_name)
|
||||
|
||||
def _create_resource_id_index(self, mt_collection_name: str):
|
||||
try:
|
||||
# A Milvus server auto-selects the scalar index type from a parameterless call.
|
||||
self.client.create_index(
|
||||
|
|
@ -148,7 +184,6 @@ class MilvusClient(VectorDBBase):
|
|||
# The index only accelerates resource_id filters; never fail
|
||||
# collection creation over it.
|
||||
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
|
||||
log.info('Created shared collection: %s', mt_collection_name)
|
||||
|
||||
def _ensure_collection(self, mt_collection_name: str, dimension: int):
|
||||
if not self.client.has_collection(mt_collection_name):
|
||||
|
|
@ -180,13 +215,17 @@ class MilvusClient(VectorDBBase):
|
|||
entities = []
|
||||
for item in items:
|
||||
text = item['text'] or ''
|
||||
if len(text) > MILVUS_TEXT_MAX_LENGTH:
|
||||
text_bytes = len(text.encode())
|
||||
if text_bytes > MILVUS_TEXT_MAX_LENGTH:
|
||||
log.warning(
|
||||
f'Milvus: truncating text id={item["id"]} '
|
||||
f'{len(text)}->{MILVUS_TEXT_MAX_LENGTH} chars '
|
||||
f'(collection={mt_collection}, resource_id={resource_id})'
|
||||
'Milvus: truncating text id=%s %s->%s bytes (collection=%s, resource_id=%s)',
|
||||
item['id'],
|
||||
text_bytes,
|
||||
MILVUS_TEXT_MAX_LENGTH,
|
||||
mt_collection,
|
||||
resource_id,
|
||||
)
|
||||
text = text[:MILVUS_TEXT_MAX_LENGTH]
|
||||
text = _truncate_text(text)
|
||||
entities.append(
|
||||
{
|
||||
'id': item['id'],
|
||||
|
|
@ -250,6 +289,49 @@ class MilvusClient(VectorDBBase):
|
|||
|
||||
return SearchResult(ids=ids, documents=documents, metadatas=metadatas, distances=distances)
|
||||
|
||||
def hybrid_search(
|
||||
self,
|
||||
collection_name: str,
|
||||
query: str,
|
||||
vectors: List[List[float]],
|
||||
filter: Optional[Dict] = None,
|
||||
limit: int = 10,
|
||||
hybrid_bm25_weight: float = 0.5,
|
||||
) -> Optional[SearchResult]:
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
_validate_resource_id(resource_id)
|
||||
if not self.client.has_collection(mt_collection) or not _has_bm25_field(self.client, mt_collection):
|
||||
return None
|
||||
|
||||
vector_result = None
|
||||
if hybrid_bm25_weight < 1 and vectors:
|
||||
vector_result = self.search(collection_name=collection_name, vectors=vectors, filter=filter, limit=limit)
|
||||
|
||||
fts_results = []
|
||||
if hybrid_bm25_weight > 0 and query.strip():
|
||||
self.client.load_collection(mt_collection)
|
||||
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'", *_metadata_exprs(filter)]
|
||||
results = self.client.search(
|
||||
collection_name=mt_collection,
|
||||
data=[query],
|
||||
anns_field='sparse',
|
||||
limit=limit,
|
||||
filter=' and '.join(expr),
|
||||
output_fields=['text', 'metadata'],
|
||||
)
|
||||
fts_results = [
|
||||
{'id': hit['id'], 'text': hit['entity']['text'], 'vmetadata': hit['entity']['metadata']}
|
||||
for hit in results[0]
|
||||
]
|
||||
|
||||
return merge_hybrid_search_results(
|
||||
vector_result=vector_result,
|
||||
fts_results=fts_results,
|
||||
num_queries=len(vectors) or 1,
|
||||
limit=limit,
|
||||
hybrid_bm25_weight=hybrid_bm25_weight,
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
|
|
@ -278,6 +360,7 @@ class MilvusClient(VectorDBBase):
|
|||
for collection_name in self.shared_collections:
|
||||
if self.client.has_collection(collection_name):
|
||||
self.client.drop_collection(collection_name)
|
||||
self.client.drop_collection(f'{collection_name}{BM25_STAGING_SUFFIX}')
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
mt_collection, resource_id = self._get_collection_and_resource_id(collection_name)
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ class WeaviateClient(VectorDBBase):
|
|||
|
||||
# Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1
|
||||
raw_distances = [
|
||||
(obj.metadata.distance if obj.metadata and obj.metadata.distance else 2.0)
|
||||
(obj.metadata.distance if obj.metadata and obj.metadata.distance is not None else 2.0)
|
||||
for obj in response.objects
|
||||
]
|
||||
distances = [(2 - dist) / 2 for dist in raw_distances]
|
||||
|
|
|
|||
|
|
@ -307,6 +307,12 @@ _DROPPED_RESPONSE_HEADERS = {'connection', 'content-encoding', 'content-length',
|
|||
# The Playwright loader only reads the page HTML, which none of these feed.
|
||||
_DROPPED_RESOURCE_TYPES = {'font', 'image', 'media'}
|
||||
|
||||
# unstructured keeps only the first <main>, so text in any others would be dropped.
|
||||
_UNWRAP_EXTRA_MAINS = (
|
||||
'() => { const mains = document.querySelectorAll("main"); '
|
||||
'if (mains.length > 1) mains.forEach(main => main.replaceWith(...main.childNodes)); }'
|
||||
)
|
||||
|
||||
|
||||
def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS}
|
||||
|
|
@ -878,6 +884,7 @@ class SafePlaywrightURLLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
for element in page.locator(selector).all():
|
||||
if element.is_visible():
|
||||
element.evaluate('element => element.remove()')
|
||||
page.evaluate(_UNWRAP_EXTRA_MAINS)
|
||||
text = self._extract_html(page.content())
|
||||
page.unroute_all(behavior='ignoreErrors')
|
||||
metadata = {'source': url}
|
||||
|
|
@ -918,6 +925,7 @@ class SafePlaywrightURLLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
for element in await page.locator(selector).all():
|
||||
if await element.is_visible():
|
||||
await element.evaluate('element => element.remove()')
|
||||
await page.evaluate(_UNWRAP_EXTRA_MAINS)
|
||||
text = await asyncio.to_thread(self._extract_html, await page.content())
|
||||
await page.unroute_all(behavior='ignoreErrors')
|
||||
metadata = {'source': url}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ async def run_automation_by_id(
|
|||
await check_automations_permission(request, user)
|
||||
automation = await Automations.get_by_id(id, db=db)
|
||||
check_automation_access(automation, user)
|
||||
automation = await Automations.update_last_run_at(automation.id, db=db)
|
||||
asyncio.create_task(execute_automation(request.app, automation))
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from open_webui.models.users import UserModel
|
|||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.calendar import expand_recurring_event
|
||||
from open_webui.utils.recurrence import schedule_start_ns
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -206,13 +207,22 @@ async def get_events(
|
|||
if not rrule_str:
|
||||
continue
|
||||
|
||||
start_at = auto.next_run_at or 0
|
||||
upper_rrule = rrule_str.upper()
|
||||
if 'COUNT=' in upper_rrule and 'DTSTART' in upper_rrule:
|
||||
# COUNT runs from DTSTART, anchoring on the next run would restart it
|
||||
try:
|
||||
start_at = schedule_start_ns(rrule_str, user.timezone)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
virtual = {
|
||||
'id': f'auto_{auto.id}',
|
||||
'calendar_id': SCHEDULED_TASKS_CALENDAR_ID,
|
||||
'user_id': user.id,
|
||||
'title': auto.name,
|
||||
'description': auto.data.get('prompt', '') if auto.data else '',
|
||||
'start_at': auto.next_run_at or 0,
|
||||
'start_at': start_at,
|
||||
'end_at': None,
|
||||
'all_day': False,
|
||||
'rrule': rrule_str,
|
||||
|
|
|
|||
|
|
@ -642,6 +642,9 @@ async def add_members_by_id(
|
|||
if channel.user_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if channel.type == 'dm':
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
memberships = await Channels.add_members_to_channel(
|
||||
channel.id, user.id, form_data.user_ids, form_data.group_ids, db=db
|
||||
|
|
@ -686,9 +689,12 @@ async def remove_members_by_id(
|
|||
if channel.user_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if channel.type == 'dm':
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db)
|
||||
if channel.type in ['group', 'dm']:
|
||||
if channel.type == 'group':
|
||||
await leave_room_for_users(f'channel:{channel.id}', form_data.user_ids)
|
||||
|
||||
await publish_event(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
from open_webui.config import BannerModel
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_TOOL_SERVERS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
|
|
@ -176,6 +176,9 @@ async def register_oauth_client(
|
|||
type: str | None = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
try:
|
||||
oauth_client_id = form_data.client_id
|
||||
if type:
|
||||
|
|
@ -264,7 +267,7 @@ async def set_tool_servers_config(
|
|||
|
||||
await set_tool_servers(request)
|
||||
|
||||
for connection in connections:
|
||||
for connection in connections if ENABLE_TOOL_SERVERS else []:
|
||||
server_type = connection.get('type', 'openapi')
|
||||
if server_type == 'mcp':
|
||||
server_id = (connection.get('info') or {}).get('id')
|
||||
|
|
@ -362,6 +365,9 @@ async def verify_terminal_server_connection(
|
|||
Tries GET {url}/api/v1/policies (orchestrator) then GET {url}/api/config
|
||||
(plain terminal). Returns ``{status: true, type: "orchestrator"|"terminal"}``.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -432,6 +438,9 @@ async def put_terminal_server_policy(
|
|||
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""Proxy a policy read or update to an orchestrator terminal server."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -469,6 +478,9 @@ async def put_terminal_server_lifecycle(
|
|||
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -508,6 +520,9 @@ async def refresh_terminal_server_terminals(
|
|||
"""
|
||||
Proxy a terminal refresh request to an orchestrator terminal server.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -553,6 +568,9 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
"""
|
||||
Verify the connection to the tool server.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
try:
|
||||
if form_data.type == 'mcp':
|
||||
if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_FUNCTIONS
|
||||
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.functions import (
|
||||
|
|
@ -24,8 +24,8 @@ from open_webui.models.functions import (
|
|||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_contents_cache,
|
||||
get_functions_cache,
|
||||
get_function_module_from_cache,
|
||||
get_functions_cache,
|
||||
load_function_module_by_id,
|
||||
replace_imports,
|
||||
resolve_valves_schema_options,
|
||||
|
|
@ -47,7 +47,7 @@ router = APIRouter()
|
|||
|
||||
@router.get('/', response_model=list[FunctionResponse])
|
||||
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(db=db)
|
||||
|
|
@ -55,7 +55,7 @@ async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depe
|
|||
|
||||
@router.get('/list', response_model=list[FunctionUserResponse])
|
||||
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return []
|
||||
|
||||
return await Functions.get_function_list(db=db)
|
||||
|
|
@ -72,7 +72,7 @@ async def get_functions(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(include_valves=include_valves, db=db)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import logging
|
|||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
|
@ -488,14 +489,18 @@ async def get_image_data(data: str, headers=None, trusted_base_url: str | None =
|
|||
# that would follow arbitrary redirects.
|
||||
if trusted_base_url and _is_same_origin(data, trusted_base_url):
|
||||
log.debug('Skipping URL validation for trusted backend: %s', data)
|
||||
session_context = nullcontext(await get_session())
|
||||
else:
|
||||
await asyncio.to_thread(validate_url, data)
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
data,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
session_context = get_ssrf_safe_session()
|
||||
async with (
|
||||
session_context as session,
|
||||
session.get(
|
||||
data,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r,
|
||||
):
|
||||
r.raise_for_status()
|
||||
content_type = r.headers.get('content-type', '')
|
||||
if content_type.split('/')[0] == 'image':
|
||||
|
|
|
|||
|
|
@ -1109,7 +1109,8 @@ def get_azure_allowed_params(api_version: str) -> set[str]:
|
|||
|
||||
|
||||
def is_openai_new_model(model: str) -> bool:
|
||||
model_lower = model.lower()
|
||||
# Amazon Bedrock ids carry a provider prefix, e.g. us.openai.gpt-6-sol
|
||||
model_lower = re.sub(r'^(?:[a-z-]+\.)?openai\.', '', model.lower())
|
||||
# o-series models (o1, o3, o4, o5, ...)
|
||||
if re.match(r'^o\d+', model_lower):
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ class SCIMPhoto(BaseModel):
|
|||
class SCIMGroupMember(BaseModel):
|
||||
"""SCIM Group Member"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
value: str # User ID
|
||||
ref: Optional[str] = Field(None, alias='$ref')
|
||||
type: Optional[str] = 'User'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, Request, Response, WebSocket
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from open_webui.config import TERMINAL_PROXY_HEADERS
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_TOOL_SERVERS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
@ -87,6 +87,9 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
|||
@router.get('/')
|
||||
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
|
||||
"""Return terminal servers the authenticated user has access to."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
|
|
@ -114,6 +117,9 @@ async def proxy_terminal(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Proxy a request to the admin terminal server identified by *server_id*."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return JSONResponse({'error': 'Tool servers are disabled'}, status_code=403)
|
||||
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == server_id), None)
|
||||
|
||||
|
|
@ -288,6 +294,10 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
|
||||
async def _resolve_terminal_access(ws: WebSocket, server_id: str, token: str):
|
||||
"""Resolve current access for both the handshake and an open terminal session."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
await ws.close(code=4003, reason='Tool servers are disabled')
|
||||
return None
|
||||
|
||||
try:
|
||||
user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None))
|
||||
if user is None:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
ENABLE_TOOL_SERVERS,
|
||||
ENABLE_TOOLS,
|
||||
)
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
|
@ -33,8 +38,8 @@ from open_webui.utils.access_control import (
|
|||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.plugin import (
|
||||
get_tool_contents_cache,
|
||||
get_tools_cache,
|
||||
get_tool_module_from_cache,
|
||||
get_tools_cache,
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
resolve_valves_schema_options,
|
||||
|
|
@ -78,7 +83,7 @@ async def get_tools(
|
|||
)
|
||||
|
||||
# Local Tools
|
||||
if ENABLE_PLUGINS:
|
||||
if ENABLE_TOOLS:
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(
|
||||
defer_content=True,
|
||||
|
|
@ -132,7 +137,7 @@ async def get_tools(
|
|||
)
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in await Config.get('tool_server.connections', []):
|
||||
for server in (await Config.get('tool_server.connections', [])) if ENABLE_TOOL_SERVERS else []:
|
||||
if server.get('type', 'openapi') == 'mcp' and (server.get('config') or {}).get('enable'):
|
||||
info = server.get('info') or {}
|
||||
server_id = info.get('id')
|
||||
|
|
@ -198,7 +203,7 @@ async def get_tools(
|
|||
|
||||
@router.get('/list', response_model=list[ToolAccessResponse])
|
||||
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_TOOLS:
|
||||
return []
|
||||
|
||||
bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from open_webui.env import (
|
|||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.notes import Notes, NoteUpdateForm
|
||||
from open_webui.models.users import UserNameResponse, Users
|
||||
|
|
@ -392,8 +393,12 @@ async def enter_room_for_users(room: str, user_ids: list[str]):
|
|||
user_ids (list[str]): The target user's IDs.
|
||||
"""
|
||||
try:
|
||||
for user_id in user_ids:
|
||||
session_ids = get_session_ids_from_room(f'user:{user_id}')
|
||||
default_permissions = await Config.get('user.permissions')
|
||||
for user in await Users.get_users_by_user_ids(user_ids):
|
||||
if user.role != 'admin' and not await has_permission(user.id, 'features.channels', default_permissions):
|
||||
continue
|
||||
|
||||
session_ids = get_session_ids_from_room(f'user:{user.id}')
|
||||
for sid in session_ids:
|
||||
await sio.enter_room(sid, room)
|
||||
except Exception as e:
|
||||
|
|
@ -501,7 +506,7 @@ async def user_join(sid, data):
|
|||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
|
||||
# Join all the channels only if user has channels permission
|
||||
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
|
||||
if user.role == 'admin' or await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
|
||||
channels = await Channels.get_channels_by_user_id(user.id)
|
||||
log.debug('channels=%r', channels)
|
||||
for channel in channels:
|
||||
|
|
@ -533,7 +538,7 @@ async def join_channel(sid, data):
|
|||
return
|
||||
|
||||
# Join all the channels only if user has channels permission
|
||||
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
|
||||
if user.role == 'admin' or await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
|
||||
channels = await Channels.get_channels_by_user_id(user.id)
|
||||
log.debug('channels=%r', channels)
|
||||
for channel in channels:
|
||||
|
|
@ -554,6 +559,11 @@ async def join_note(sid, data):
|
|||
if not user:
|
||||
return
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions')
|
||||
):
|
||||
return
|
||||
|
||||
note = await Notes.get_note_by_id(data['note_id'])
|
||||
if not note:
|
||||
log.error(f'Note {data["note_id"]} not found for user {user.id}')
|
||||
|
|
@ -687,6 +697,11 @@ async def ydoc_document_join(sid, data):
|
|||
document_id = normalize_document_id(data['document_id'])
|
||||
|
||||
if document_id.startswith('note:'):
|
||||
if user.get('role') != 'admin' and not await has_permission(
|
||||
user.get('id'), 'features.notes', await Config.get('user.permissions')
|
||||
):
|
||||
return
|
||||
|
||||
note_id = document_id.split(':')[1]
|
||||
note = await Notes.get_note_by_id(note_id)
|
||||
if not note:
|
||||
|
|
|
|||
|
|
@ -4413,10 +4413,10 @@ async def update_calendar_event(
|
|||
return JSONCodec.dumps({'error': 'Event not found'})
|
||||
|
||||
# Check write access to the event's calendar
|
||||
if event.user_id != user_id and __user__.get('role') != 'admin':
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
if cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
|
|
@ -4517,10 +4517,10 @@ async def delete_calendar_event(
|
|||
return JSONCodec.dumps({'error': 'Event not found'})
|
||||
|
||||
# Check write access
|
||||
if event.user_id != user_id and __user__.get('role') != 'admin':
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
if cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import sys
|
|||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import ENABLE_FUNCTIONS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
|
|
@ -17,8 +17,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise Exception('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
if not ENABLE_FUNCTIONS:
|
||||
raise Exception('Functions are disabled by ENABLE_PLUGINS or ENABLE_FUNCTIONS')
|
||||
|
||||
if '.' in action_id:
|
||||
action_id, sub_action_id = action_id.split('.')
|
||||
|
|
|
|||
|
|
@ -282,11 +282,12 @@ class AuditLoggingMiddleware:
|
|||
response_body = context.response_body.decode('utf-8', errors='replace')
|
||||
|
||||
# Redact sensitive information
|
||||
if 'password' in request_body:
|
||||
if 'password' in request_body.lower():
|
||||
request_body = re.sub(
|
||||
r'"password":\s*"(.*?)"',
|
||||
'"password": "********"',
|
||||
r'"(\w*password)":\s*".*?"',
|
||||
r'"\1": "********"',
|
||||
request_body,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
entry = AuditLogEntry(
|
||||
|
|
|
|||
|
|
@ -261,6 +261,13 @@ async def _execute_channel_automation(
|
|||
if not channel_id or not await Config.get('channels.enable'):
|
||||
raise ValueError('Channel not found')
|
||||
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.channels', await Config.get('user.permissions')
|
||||
):
|
||||
raise ValueError('Owner no longer permitted to use channels')
|
||||
|
||||
model = getattr(app.state, 'MODELS', {}).get(model_id, {})
|
||||
request = _build_request(app, token=token)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ def expand_recurring_event(
|
|||
|
||||
range_start = to_local_datetime(range_start_ns)
|
||||
range_end = to_local_datetime(range_end_ns)
|
||||
scan_start = range_start - dt.timedelta(days=1)
|
||||
|
||||
original_start_ns = event_dict['start_at']
|
||||
original_start = to_local_datetime(original_start_ns)
|
||||
|
|
@ -55,24 +54,30 @@ def expand_recurring_event(
|
|||
|
||||
original_end_ns = event_dict.get('end_at')
|
||||
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
||||
# Look back by the event length so occurrences still running at range start are found
|
||||
event_length = dt.timedelta(microseconds=max(duration_ns or 0, 0) // 1000)
|
||||
scan_start = range_start - dt.timedelta(days=1) - event_length
|
||||
|
||||
instances = []
|
||||
previous_start = None
|
||||
for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True):
|
||||
for occurrence_start in rule.xafter(scan_start, inc=True):
|
||||
if occurrence_start >= range_end or occurrence_start == previous_start:
|
||||
break
|
||||
previous_start = occurrence_start
|
||||
|
||||
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
||||
instance_end_ns = (instance_start_ns + duration_ns) if duration_ns else None
|
||||
|
||||
if instance_start_ns >= range_start_ns:
|
||||
if instance_start_ns >= range_start_ns or (instance_end_ns and instance_end_ns > range_start_ns):
|
||||
instance = {
|
||||
**event_dict,
|
||||
'start_at': instance_start_ns,
|
||||
'end_at': (instance_start_ns + duration_ns) if duration_ns else None,
|
||||
'end_at': instance_end_ns,
|
||||
'instance_id': f'{event_dict["id"]}_{instance_start_ns}',
|
||||
}
|
||||
instances.append(instance)
|
||||
if len(instances) >= max_instances:
|
||||
break
|
||||
|
||||
return instances
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ def validate_user_variables(variables: Any) -> dict[str, str]:
|
|||
raise ChatVariablesError('User variables must be an object.')
|
||||
|
||||
try:
|
||||
if len(JSONCodec.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
if len(JSONCodec.dumps(variables, ensure_ascii=False)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('User variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('User variables must be JSON serializable.')
|
||||
|
|
@ -214,7 +214,7 @@ def validate_chat_variables(
|
|||
variables = normalize_chat_variables(variables)
|
||||
|
||||
try:
|
||||
if len(JSONCodec.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
if len(JSONCodec.dumps(variables, ensure_ascii=False)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('Chat variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('Chat variables must be JSON serializable.')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import inspect
|
||||
import logging
|
||||
|
||||
from open_webui.env import ENABLE_PLUGINS
|
||||
from open_webui.env import ENABLE_FUNCTIONS
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ def get_model_filter_ids(model, active_filters):
|
|||
|
||||
|
||||
async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list = None):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return [], []
|
||||
|
||||
active_filters = await get_filter_context(request).get_active_filters()
|
||||
|
|
@ -217,7 +217,7 @@ async def process_filter_functions(
|
|||
form_data,
|
||||
extra_params,
|
||||
):
|
||||
if not ENABLE_PLUGINS:
|
||||
if not ENABLE_FUNCTIONS:
|
||||
return form_data, {}
|
||||
|
||||
skip_files = None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -116,7 +117,11 @@ class MCPClient:
|
|||
if not self.session:
|
||||
raise RuntimeError('MCP client is not connected.')
|
||||
|
||||
result = await self.session.call_tool(function_name, function_args)
|
||||
tool_call_timeout = None
|
||||
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None and AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER > 0:
|
||||
tool_call_timeout = timedelta(seconds=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
|
||||
|
||||
result = await self.session.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout)
|
||||
if not result:
|
||||
raise Exception('No result returned from MCP tool call.')
|
||||
|
||||
|
|
|
|||
|
|
@ -37,10 +37,11 @@ from open_webui.env import (
|
|||
ENABLE_API_OUTLET_FILTERS,
|
||||
ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION,
|
||||
ENABLE_CHAT_RESPONSE_STREAM_INPLACE_APPEND,
|
||||
ENABLE_PLUGINS,
|
||||
ENABLE_FUNCTIONS,
|
||||
ENABLE_QUERIES_CACHE,
|
||||
ENABLE_REALTIME_CHAT_SAVE,
|
||||
ENABLE_RESPONSES_API_STATEFUL,
|
||||
ENABLE_TOOL_SERVERS,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
RAG_SYSTEM_CONTEXT,
|
||||
)
|
||||
|
|
@ -116,8 +117,8 @@ from open_webui.utils.misc import (
|
|||
get_message_list,
|
||||
get_output_text,
|
||||
get_paired_tool_call_ids,
|
||||
get_response_error_detail,
|
||||
get_reasoning_details,
|
||||
get_response_error_detail,
|
||||
get_system_message,
|
||||
is_raster_image_content_type,
|
||||
is_string_allowed,
|
||||
|
|
@ -2331,6 +2332,10 @@ async def connect_mcp_server(
|
|||
|
||||
Returns None if the server is not found or access is denied.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
log.debug('MCP resolution skipped: external plugins are disabled')
|
||||
return None
|
||||
|
||||
mcp_server_connection = None
|
||||
for server_connection in await Config.get('tool_server.connections', []):
|
||||
if server_connection.get('type', '') == 'mcp' and (server_connection.get('info') or {}).get('id') == server_id:
|
||||
|
|
@ -2649,8 +2654,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
raise e
|
||||
|
||||
filter_functions = []
|
||||
filter_context = get_filter_context(request) if ENABLE_PLUGINS else None
|
||||
if ENABLE_PLUGINS:
|
||||
filter_context = get_filter_context(request) if ENABLE_FUNCTIONS else None
|
||||
if ENABLE_FUNCTIONS:
|
||||
try:
|
||||
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
|
||||
|
||||
|
|
@ -2748,6 +2753,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
tool_ids = form_data.pop('tool_ids', None)
|
||||
terminal_id = form_data.pop('terminal_id', None)
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
if terminal_id or metadata.get('tool_servers'):
|
||||
log.debug('Excluded external plugins disabled by plugin configuration')
|
||||
terminal_id = None
|
||||
metadata['tool_servers'] = None
|
||||
files = form_data.pop('files', None)
|
||||
form_data.pop('folder_id', None)
|
||||
metadata['terminal_id'] = terminal_id
|
||||
|
|
@ -2927,7 +2937,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
mcp_tools_dict = {}
|
||||
|
||||
if tool_ids:
|
||||
db_tool_ids = []
|
||||
resolved_tool_ids = []
|
||||
for tool_id in tool_ids:
|
||||
if tool_id.startswith('server:mcp:'):
|
||||
try:
|
||||
|
|
@ -2979,13 +2989,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
}
|
||||
)
|
||||
continue
|
||||
elif ENABLE_PLUGINS:
|
||||
db_tool_ids.append(tool_id)
|
||||
else:
|
||||
resolved_tool_ids.append(tool_id)
|
||||
|
||||
if db_tool_ids:
|
||||
if resolved_tool_ids:
|
||||
tools_dict = await get_tools(
|
||||
request,
|
||||
db_tool_ids,
|
||||
resolved_tool_ids,
|
||||
user,
|
||||
{
|
||||
**extra_params,
|
||||
|
|
@ -3199,7 +3209,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
}
|
||||
)
|
||||
|
||||
if ENABLE_PLUGINS:
|
||||
if ENABLE_FUNCTIONS:
|
||||
try:
|
||||
form_data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
|
|
@ -3527,7 +3537,7 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) -
|
|||
)
|
||||
form_data['messages'] = sanitize_tool_pairs(form_data['messages'])
|
||||
|
||||
if not paused and ENABLE_PLUGINS:
|
||||
if not paused and ENABLE_FUNCTIONS:
|
||||
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
|
||||
if filter_functions:
|
||||
filtered_form_data, _ = await process_filter_functions(
|
||||
|
|
@ -4026,7 +4036,7 @@ async def outlet_filter_handler(ctx):
|
|||
is_unsaved_chat = not is_saved_chat_id(chat_id)
|
||||
try:
|
||||
filter_functions = (
|
||||
await get_filter_functions(request, model, metadata.get('filter_ids', [])) if ENABLE_PLUGINS else []
|
||||
await get_filter_functions(request, model, metadata.get('filter_ids', [])) if ENABLE_FUNCTIONS else []
|
||||
)
|
||||
model_id = model.get('id') if isinstance(model, dict) else model
|
||||
models = request.app.state.MODELS
|
||||
|
|
@ -4408,7 +4418,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
}
|
||||
|
||||
filter_functions = (
|
||||
await get_filter_functions(request, model, metadata.get('filter_ids', [])) if ENABLE_PLUGINS else []
|
||||
await get_filter_functions(request, model, metadata.get('filter_ids', [])) if ENABLE_FUNCTIONS else []
|
||||
)
|
||||
|
||||
# Standard streaming response handler
|
||||
|
|
@ -4760,6 +4770,9 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
'content': [{'type': 'output_text', 'text': initial_content}],
|
||||
}
|
||||
]
|
||||
if not continuing:
|
||||
# the client needs this item before the first delta arrives
|
||||
await event_emitter({'type': 'chat:completion', 'data': {'output': output}})
|
||||
else:
|
||||
output = []
|
||||
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ from open_webui.config import (
|
|||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
)
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FUNCTIONS, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX
|
||||
from open_webui.functions import get_function_models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.routers import ollama, openai
|
||||
from open_webui.socket.utils import RedisDict
|
||||
from open_webui.utils.access_control import has_access, has_base_model_access
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.plugin import (
|
||||
get_functions_cache,
|
||||
get_function_module_from_cache,
|
||||
get_functions_cache,
|
||||
)
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -150,7 +150,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
# One query per type: the global sets are subsets of the active sets, so
|
||||
# deriving them from the same rows halves the function-table queries.
|
||||
if ENABLE_PLUGINS:
|
||||
if ENABLE_FUNCTIONS:
|
||||
active_actions = await Functions.get_active_function_ids_by_type('action')
|
||||
global_action_ids = {function_id for function_id, is_global in active_actions if is_global}
|
||||
enabled_action_ids = {function_id for function_id, _ in active_actions}
|
||||
|
|
@ -205,7 +205,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
if 'info' in model:
|
||||
if 'meta' in model['info']:
|
||||
if ENABLE_PLUGINS:
|
||||
if ENABLE_FUNCTIONS:
|
||||
action_ids.extend(model['info']['meta'].get('actionIds', []))
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
|
||||
|
|
@ -265,10 +265,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
if custom_model.meta:
|
||||
meta = custom_model.meta.model_dump()
|
||||
|
||||
if ENABLE_PLUGINS and 'actionIds' in meta:
|
||||
if ENABLE_FUNCTIONS and 'actionIds' in meta:
|
||||
action_ids.extend(meta['actionIds'])
|
||||
|
||||
if ENABLE_PLUGINS and 'filterIds' in meta:
|
||||
if ENABLE_FUNCTIONS and 'filterIds' in meta:
|
||||
filter_ids.extend(meta['filterIds'])
|
||||
|
||||
model['action_ids'] = action_ids
|
||||
|
|
|
|||
|
|
@ -268,9 +268,6 @@ def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any],
|
|||
title = str(data.get('title') or event.message or 'Chat finished')
|
||||
content = str(data.get('message') or '')
|
||||
url = str(data.get('url') or '')
|
||||
chat_id = str(data.get('chat_id') or '')
|
||||
if chat_id and url.endswith(f'/c/{chat_id}'):
|
||||
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
|
||||
body = '\n'.join(part for part in (content, url) if part)
|
||||
return (
|
||||
f'**{title}**',
|
||||
|
|
@ -288,9 +285,6 @@ def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any],
|
|||
title = str(event.message or 'Chat failed')
|
||||
content = str(data.get('message') or '')
|
||||
url = str(data.get('url') or '')
|
||||
chat_id = str(data.get('chat_id') or '')
|
||||
if chat_id and url.endswith(f'/c/{chat_id}'):
|
||||
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
|
||||
body = '\n'.join(part for part in (content, url) if part)
|
||||
return (
|
||||
f'**{title}**',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
ENABLE_OAUTH_EMAIL_FALLBACK,
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE,
|
||||
ENABLE_TOOL_SERVERS,
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
|
||||
OAUTH_MAX_SESSIONS_PER_USER,
|
||||
REDIS_KEY_PREFIX,
|
||||
|
|
@ -85,8 +86,8 @@ from open_webui.models.users import Users
|
|||
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
|
||||
from open_webui.utils.auth import (
|
||||
create_token,
|
||||
get_password_hash,
|
||||
get_optional_verified_user_from_request,
|
||||
get_password_hash,
|
||||
get_verified_user_by_id,
|
||||
revoke_user_tokens,
|
||||
)
|
||||
|
|
@ -789,6 +790,11 @@ def should_send_oauth_resource(client_info: OAuthClientInformationFull | None) -
|
|||
return not scope_has_resource_indicator(client_info.scope)
|
||||
|
||||
|
||||
def uses_google_authorization_server(client_info: OAuthClientInformationFull) -> bool:
|
||||
server_metadata = client_info.server_metadata
|
||||
return server_metadata is not None and server_metadata.authorization_endpoint.host == 'accounts.google.com'
|
||||
|
||||
|
||||
def build_oauth_request_params(client_info: OAuthClientInformationFull | None) -> dict:
|
||||
if not client_info:
|
||||
return {}
|
||||
|
|
@ -798,6 +804,10 @@ def build_oauth_request_params(client_info: OAuthClientInformationFull | None) -
|
|||
params['scope'] = client_info.scope
|
||||
if should_send_oauth_resource(client_info):
|
||||
params['resource'] = client_info.resource
|
||||
# Google only issues a refresh token for offline access, and only re-issues it on a fresh consent.
|
||||
if uses_google_authorization_server(client_info):
|
||||
params['access_type'] = 'offline'
|
||||
params['prompt'] = 'consent'
|
||||
return params
|
||||
|
||||
|
||||
|
|
@ -887,6 +897,9 @@ class OAuthClientManager:
|
|||
Lazy-load an OAuth client from the current TOOL_SERVER_CONNECTIONS
|
||||
config if it hasn't been registered on this node yet.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
if client_id in self.clients:
|
||||
return self.clients[client_id]['client']
|
||||
|
||||
|
|
@ -1019,6 +1032,9 @@ class OAuthClientManager:
|
|||
return True
|
||||
|
||||
async def get_client(self, client_id):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
if client_id not in self.clients:
|
||||
await self.ensure_client_from_config(client_id)
|
||||
|
||||
|
|
@ -1026,6 +1042,9 @@ class OAuthClientManager:
|
|||
return client['client'] if client else None
|
||||
|
||||
async def get_client_info(self, client_id):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise HTTPException(status_code=403, detail='Tool servers are disabled')
|
||||
|
||||
if client_id not in self.clients:
|
||||
await self.ensure_client_from_config(client_id)
|
||||
|
||||
|
|
@ -1917,7 +1936,7 @@ class OAuthManager:
|
|||
detailed_error,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
except Exception as e:
|
||||
detailed_error = _build_oauth_callback_error_message(e)
|
||||
log.warning(
|
||||
|
|
@ -1926,7 +1945,7 @@ class OAuthManager:
|
|||
detailed_error,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
|
||||
# Try to get userinfo from the token first, some providers include it there
|
||||
user_data: UserInfo = token.get('userinfo')
|
||||
|
|
@ -1949,7 +1968,7 @@ class OAuthManager:
|
|||
user_data = user_data['data']
|
||||
if not user_data:
|
||||
log.warning('OAuth callback failed for provider %s, user data is missing', provider)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
|
||||
# Extract the "sub" claim, using custom claim if configured
|
||||
if auth_config.OAUTH_SUB_CLAIM:
|
||||
|
|
@ -1959,7 +1978,7 @@ class OAuthManager:
|
|||
sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
|
||||
if not sub:
|
||||
log.warning(f'OAuth callback failed, sub is missing: {user_data}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
sub = str(sub)
|
||||
|
||||
oauth_data = {}
|
||||
|
|
@ -1994,18 +2013,18 @@ class OAuthManager:
|
|||
email = primary_email
|
||||
else:
|
||||
log.warning('No primary email found in GitHub response')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
else:
|
||||
log.warning('Failed to fetch GitHub email')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
except Exception as e:
|
||||
log.warning(f'Error fetching GitHub email: {e}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
elif ENABLE_OAUTH_EMAIL_FALLBACK:
|
||||
email = f'{provider}@{sub}.local'
|
||||
else:
|
||||
log.warning(f'OAuth callback failed, email is missing: {user_data}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
|
||||
email = email.lower()
|
||||
# If allowed domains are configured, check if the email domain is in the list
|
||||
|
|
@ -2014,7 +2033,7 @@ class OAuthManager:
|
|||
and email.split('@')[-1] not in auth_config.OAUTH_ALLOWED_DOMAINS
|
||||
):
|
||||
log.warning(f'OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.OAUTH_LOGIN_FAILED)
|
||||
|
||||
# Check if the user exists
|
||||
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ from importlib import util
|
|||
from typing import Any
|
||||
|
||||
from open_webui.env import (
|
||||
ENABLE_FUNCTIONS,
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
|
||||
ENABLE_PLUGINS,
|
||||
ENABLE_TOOLS,
|
||||
OFFLINE_MODE,
|
||||
PIP_OPTIONS,
|
||||
PIP_PACKAGE_INDEX_OPTIONS,
|
||||
|
|
@ -204,8 +205,8 @@ def replace_imports(content):
|
|||
# May the intent of the one who wrote it survive every
|
||||
# import and transformation, as a deed survives the generations.
|
||||
async def load_tool_module_by_id(tool_id, content=None):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
if not ENABLE_TOOLS:
|
||||
raise RuntimeError('Tools are disabled by ENABLE_PLUGINS or ENABLE_TOOLS')
|
||||
|
||||
frontmatter = None
|
||||
if content is None:
|
||||
|
|
@ -257,8 +258,8 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
|
||||
|
||||
async def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
if not ENABLE_FUNCTIONS:
|
||||
raise RuntimeError('Functions are disabled by ENABLE_PLUGINS or ENABLE_FUNCTIONS')
|
||||
|
||||
frontmatter = None
|
||||
if content is None:
|
||||
|
|
@ -338,6 +339,9 @@ def get_function_contents_cache(request) -> dict:
|
|||
|
||||
|
||||
async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
||||
if not ENABLE_TOOLS:
|
||||
raise RuntimeError('Tools are disabled by ENABLE_PLUGINS or ENABLE_TOOLS')
|
||||
|
||||
tools_cache = get_tools_cache(request)
|
||||
tool_contents_cache = get_tool_contents_cache(request)
|
||||
content = None
|
||||
|
|
@ -375,6 +379,9 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
|||
async def get_function_module_from_cache(
|
||||
request, function_id, function: FunctionModel | None = None, load_from_db=True
|
||||
):
|
||||
if not ENABLE_FUNCTIONS:
|
||||
raise RuntimeError('Functions are disabled by ENABLE_PLUGINS or ENABLE_FUNCTIONS')
|
||||
|
||||
functions_cache = get_functions_cache(request)
|
||||
function_contents_cache = get_function_contents_cache(request)
|
||||
content = None
|
||||
|
|
@ -458,12 +465,12 @@ async def install_tool_and_function_dependencies():
|
|||
and then installing them using pip. Duplicates or similar version specifications are
|
||||
handled by pip as much as possible.
|
||||
"""
|
||||
if not ENABLE_PLUGINS:
|
||||
log.info('ENABLE_PLUGINS is disabled, skipping tool and function dependencies.')
|
||||
if not ENABLE_TOOLS and not ENABLE_FUNCTIONS:
|
||||
log.info('Tools and Functions are disabled, skipping their dependencies.')
|
||||
return
|
||||
|
||||
function_list = await Functions.get_functions(active_only=True)
|
||||
tool_list = await Tools.get_tools()
|
||||
function_list = await Functions.get_functions(active_only=True) if ENABLE_FUNCTIONS else []
|
||||
tool_list = await Tools.get_tools() if ENABLE_TOOLS else []
|
||||
|
||||
all_dependencies = ''
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -148,6 +148,18 @@ async def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
|||
return int(dt.timestamp() * 1_000_000_000)
|
||||
|
||||
|
||||
def schedule_start_ns(s: str, tz: str = None) -> int:
|
||||
"""DTSTART the scheduler anchors the rule to, as epoch nanoseconds."""
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
parsed = _parse_rule(s, now)
|
||||
rule = parsed._rrule[0] if isinstance(parsed, rruleset) else parsed
|
||||
dt = rule._dtstart
|
||||
if zi:
|
||||
dt = dt.replace(tzinfo=zi)
|
||||
return int(dt.timestamp() * 1_000_000_000)
|
||||
|
||||
|
||||
async def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
|
||||
"""Compute next N occurrences for UI preview.
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ async def process_pending_internal_messages(
|
|||
history['messages'] = messages
|
||||
history['currentId'] = assistant_message_id
|
||||
chat.chat = {**(chat.chat or {}), 'history': history}
|
||||
chat.current_message_id = assistant_message_id
|
||||
chat.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -614,10 +615,16 @@ async def delegate(
|
|||
updated_chat = copy.deepcopy(parent.chat or {})
|
||||
updated_history = updated_chat.setdefault('history', {})
|
||||
updated_messages = updated_history.setdefault('messages', {})
|
||||
parent_message = updated_messages.get(parent_message_id)
|
||||
done_assistants = [
|
||||
message
|
||||
for message in updated_messages.values()
|
||||
if message.get('role') == 'assistant' and message.get('done') is not False
|
||||
for message_id, message in updated_messages.items()
|
||||
if message.get('role') == 'assistant'
|
||||
and message.get('done') is not False
|
||||
and (
|
||||
parent_message is None
|
||||
or any(entry is parent_message for entry in get_message_list(updated_messages, message_id))
|
||||
)
|
||||
]
|
||||
result_parent_id = (
|
||||
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ class Instrumentor(BaseInstrumentor):
|
|||
SQLAlchemyInstrumentor().instrument(engine=self.db_engine)
|
||||
RedisInstrumentor().instrument(request_hook=redis_request_hook)
|
||||
RequestsInstrumentor().instrument(request_hook=requests_hook, response_hook=response_hook)
|
||||
LoggingInstrumentor().instrument()
|
||||
LoggingInstrumentor().instrument(enable_log_auto_instrumentation=False)
|
||||
HTTPXClientInstrumentor().instrument(
|
||||
request_hook=httpx_request_hook,
|
||||
response_hook=httpx_response_hook,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ntpath
|
|||
import posixpath
|
||||
from urllib.parse import quote
|
||||
|
||||
from open_webui.env import ENABLE_TOOL_SERVERS
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
|
||||
TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id'
|
||||
|
|
@ -122,8 +123,10 @@ def terminal_chat_uploads(connection: dict) -> str:
|
|||
|
||||
async def get_terminal_json(request, user, metadata: dict, path: str, extra_params: dict | None = None):
|
||||
"""Read from an admin terminal on the backend or a personal terminal in its browser."""
|
||||
import aiohttp
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return None
|
||||
|
||||
import aiohttp
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) ->
|
|||
|
||||
parent.chat = parent_chat
|
||||
history['currentId'] = assistant_message_id
|
||||
parent.current_message_id = assistant_message_id
|
||||
parent.updated_at = int(time.time())
|
||||
timer_row = await db.get(Chat, timer_id)
|
||||
if timer_row:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
ENABLE_PLUGINS,
|
||||
ENABLE_TOOL_SERVERS,
|
||||
ENABLE_TOOLS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
|
||||
REDIS_KEY_PREFIX,
|
||||
|
|
@ -101,7 +102,7 @@ from open_webui.tools.builtin import (
|
|||
write_note,
|
||||
)
|
||||
from open_webui.utils.access_control import has_access, has_connection_access, has_permission
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.chat_id import is_saved_chat_id, is_temporary_chat_id
|
||||
from open_webui.utils.headers import (
|
||||
bearer_auth_header,
|
||||
get_custom_headers,
|
||||
|
|
@ -266,9 +267,17 @@ async def get_updated_tool_function(function: Callable, extra_params: dict):
|
|||
|
||||
async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]:
|
||||
"""Load tools for the given tool_ids, checking access control."""
|
||||
if not ENABLE_PLUGINS:
|
||||
if not tool_ids:
|
||||
return {}
|
||||
|
||||
enabled_ids = [
|
||||
tool_id
|
||||
for tool_id in tool_ids
|
||||
if (ENABLE_TOOL_SERVERS if tool_id.startswith('server:') else ENABLE_TOOLS)
|
||||
]
|
||||
if len(enabled_ids) != len(tool_ids):
|
||||
log.debug('Excluded tools disabled by plugin configuration')
|
||||
tool_ids = enabled_ids
|
||||
if not tool_ids:
|
||||
return {}
|
||||
|
||||
|
|
@ -278,7 +287,8 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
|||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
# Batch-fetch all DB tools in one query instead of one per tool_id
|
||||
tool_models = await Tools.get_tools_by_ids(tool_ids)
|
||||
local_tool_ids = [tool_id for tool_id in tool_ids if not tool_id.startswith('server:')]
|
||||
tool_models = await Tools.get_tools_by_ids(local_tool_ids) if local_tool_ids else {}
|
||||
|
||||
for tool_id in tool_ids:
|
||||
tool = tool_models.get(tool_id)
|
||||
|
|
@ -650,6 +660,7 @@ async def get_builtin_tools(
|
|||
and config.get('subagents.enable')
|
||||
and getattr(request.state, 'internal', False) is not True
|
||||
and getattr(request.state, 'direct', False) is not True
|
||||
and not is_temporary_chat_id(metadata.get('chat_id'))
|
||||
):
|
||||
builtin_functions.extend([delegate_task, timer])
|
||||
|
||||
|
|
@ -1168,6 +1179,9 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
|||
|
||||
|
||||
async def set_tool_servers(request: Request):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
try:
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(await Config.get('tool_server.connections', []))
|
||||
except Exception as e:
|
||||
|
|
@ -1186,6 +1200,9 @@ async def set_tool_servers(request: Request):
|
|||
|
||||
|
||||
async def get_tool_servers(request: Request):
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
try:
|
||||
tool_servers = None
|
||||
if request.app.state.redis is not None:
|
||||
|
|
@ -1270,6 +1287,9 @@ async def get_terminal_system_prompt(
|
|||
|
||||
async def set_terminal_servers(request: Request):
|
||||
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
|
||||
# Build server configs compatible with get_tool_servers_data
|
||||
|
|
@ -1332,6 +1352,9 @@ async def set_terminal_servers(request: Request):
|
|||
|
||||
async def get_terminal_servers(request: Request):
|
||||
"""Return cached terminal server specs, loading if needed."""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
terminal_servers = None
|
||||
if request.app.state.redis is not None:
|
||||
try:
|
||||
|
|
@ -1367,6 +1390,9 @@ async def get_terminal_tools(
|
|||
- Loads specs from cache
|
||||
- Builds callables that route through the terminal proxy
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return {}
|
||||
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next(
|
||||
(terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
|
||||
|
|
@ -1471,6 +1497,9 @@ async def get_terminal_tools(
|
|||
|
||||
|
||||
async def get_tool_server_data(url: str, headers: dict | None) -> dict[str, Any]:
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise RuntimeError('Tool servers are disabled')
|
||||
|
||||
_headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -1519,6 +1548,9 @@ async def get_tool_server_data(url: str, headers: dict | None) -> dict[str, Any]
|
|||
async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
# Prepare list of enabled servers along with their original index
|
||||
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
return []
|
||||
|
||||
tasks = []
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
|
|
@ -1625,6 +1657,9 @@ async def execute_tool_server(
|
|||
params: dict[str, Any],
|
||||
server_data: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any | None]]:
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
raise RuntimeError('Tool servers are disabled')
|
||||
|
||||
error = None
|
||||
try:
|
||||
openapi = server_data.get('openapi', {})
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ aiosqlite==0.22.1
|
|||
psycopg[binary]==3.3.4
|
||||
alembic==1.18.4
|
||||
|
||||
pycrdt==0.13.1
|
||||
pycrdt==0.14.8
|
||||
redis==8.0.1
|
||||
hiredis==3.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ aiosqlite==0.22.1
|
|||
psycopg[binary]==3.3.4
|
||||
alembic==1.18.4
|
||||
|
||||
pycrdt==0.13.1
|
||||
pycrdt==0.14.8
|
||||
redis==8.0.1
|
||||
hiredis==3.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ Your remediation guidance can include, for example:
|
|||
> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
|
||||
> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
|
||||
|
||||
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. Deployments that do not need `workspace.tools` or Functions plugin execution can set `ENABLE_PLUGINS=false`. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
|
||||
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. Deployments that do not need internal Tools or Functions execution can set `ENABLE_TOOLS=false` and `ENABLE_FUNCTIONS=false` while keeping external plugins available. `ENABLE_PLUGINS=false` is the master switch and disables both internal and external plugins, including OpenAPI/MCP tool servers and Open Terminal. `ENABLE_TOOL_SERVERS=false` disables only external plugins. All four settings default to `true`, are environment-only, and require a restart; the master always overrides all feature switches. These controls do not disable built-in tools, the code interpreter, external knowledge, or model-provider connections, which have their own controls. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. For more details, see our [Plugin Security documentation](https://docs.openwebui.com/features/extensibility/plugin/).
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ dependencies = [
|
|||
"psycopg[binary]==3.3.4",
|
||||
"alembic==1.18.4",
|
||||
|
||||
"pycrdt==0.13.1",
|
||||
"pycrdt==0.14.8",
|
||||
"redis==8.0.1",
|
||||
"hiredis==3.4.2",
|
||||
# "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { get } from 'svelte/store';
|
||||
import { config } from '$lib/stores';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { convertOpenApiToToolPayload, resolveSchema } from '$lib/utils';
|
||||
import { normalizeTags } from '$lib/utils/tags';
|
||||
|
|
@ -392,6 +394,7 @@ export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
|
|||
};
|
||||
|
||||
export const getToolServerData = async (token: string, url: string) => {
|
||||
if (!get(config)?.features?.enable_tool_servers) throw new Error('Tool servers are disabled');
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${url}`, {
|
||||
|
|
@ -435,6 +438,7 @@ export const getToolServerData = async (token: string, url: string) => {
|
|||
};
|
||||
|
||||
export const getToolServersData = async (servers: object[]) => {
|
||||
if (!get(config)?.features?.enable_tool_servers) return [];
|
||||
return (
|
||||
await Promise.all(
|
||||
servers
|
||||
|
|
@ -546,6 +550,7 @@ export const executeToolServer = async (
|
|||
serverData: { openapi: any; info: any; specs: any },
|
||||
sessionId?: string
|
||||
) => {
|
||||
if (!get(config)?.features?.enable_tool_servers) throw new Error('Tool servers are disabled');
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { get } from 'svelte/store';
|
||||
import { config } from '$lib/stores';
|
||||
export type FileEntry = {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
|
|
@ -98,7 +100,7 @@ export const resolveTerminalConnection = (
|
|||
directServers: any[],
|
||||
token: string
|
||||
): TerminalConnection | null => {
|
||||
if (!selector) return null;
|
||||
if (!get(config)?.features?.enable_tool_servers || !selector) return null;
|
||||
if (servers.some((server) => server.id === selector)) {
|
||||
return {
|
||||
selector,
|
||||
|
|
@ -119,6 +121,7 @@ export const terminalRequest = async <T>(
|
|||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> => {
|
||||
if (!get(config)?.features?.enable_tool_servers) throw new Error('Tool servers are disabled');
|
||||
const response = await fetch(`${connection.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
|
|
@ -131,9 +134,10 @@ export const terminalRequest = async <T>(
|
|||
return response.json();
|
||||
};
|
||||
|
||||
const bearerHeaders = (apiKey: string): Record<string, string> => ({
|
||||
Authorization: `Bearer ${apiKey.trim()}`
|
||||
});
|
||||
const bearerHeaders = (apiKey: string): Record<string, string> => {
|
||||
if (!get(config)?.features?.enable_tool_servers) throw new Error('Tool servers are disabled');
|
||||
return { Authorization: `Bearer ${apiKey.trim()}` };
|
||||
};
|
||||
|
||||
export const joinTerminalPath = (base: string, child: string) => {
|
||||
if (!child) return base;
|
||||
|
|
@ -162,6 +166,7 @@ export type TerminalServer = {
|
|||
};
|
||||
|
||||
export const getTerminalServers = async (token: string): Promise<TerminalServer[]> => {
|
||||
if (!get(config)?.features?.enable_tool_servers) return [];
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
|
@ -622,6 +627,7 @@ export const getListeningPorts = async (
|
|||
};
|
||||
|
||||
export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''): string => {
|
||||
if (!get(config)?.features?.enable_tool_servers) return '';
|
||||
return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@
|
|||
}
|
||||
headers = JSON.stringify(_headers, null, 2);
|
||||
} catch (error) {
|
||||
loading = false;
|
||||
toast.error($i18n.t('Headers must be a valid JSON object'));
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { formatNumber } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Instance } from 'tippy.js';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -83,6 +84,7 @@
|
|||
{ input_tokens: number; output_tokens: number; total_tokens: number }
|
||||
> = {};
|
||||
let totalTokens = { input: 0, output: 0, total: 0 };
|
||||
let tokenTooltip: Instance | null = null;
|
||||
|
||||
let loading = true;
|
||||
|
||||
|
|
@ -232,6 +234,12 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Escape') tokenTooltip?.hide();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between mb-2 gap-2">
|
||||
<h2 class="text-sm font-medium text-gray-900 dark:text-white shrink-0">
|
||||
{$i18n.t('Analytics')}
|
||||
|
|
@ -291,8 +299,25 @@
|
|||
>
|
||||
{$i18n.t('messages')}</span
|
||||
>
|
||||
<Tooltip content={$i18n.t('Token counts are estimates and may not reflect actual API usage')}>
|
||||
<span class="cursor-help"
|
||||
<Tooltip
|
||||
as="button"
|
||||
className="whitespace-nowrap rounded-sm cursor-pointer hover:underline decoration-dotted underline-offset-4 focus-visible:outline focus-visible:outline-offset-2"
|
||||
placement="bottom"
|
||||
interactive
|
||||
tippyOptions={{
|
||||
trigger: 'mouseenter focus click',
|
||||
appendTo: () => document.body,
|
||||
aria: { content: 'describedby', expanded: true },
|
||||
onCreate: (instance) => (tokenTooltip = instance),
|
||||
onDestroy: () => (tokenTooltip = null)
|
||||
}}
|
||||
content={`<div class="p-1 text-left">
|
||||
<div class="flex justify-between gap-6"><span>${$i18n.t('Input')}</span><span class="tabular-nums">${totalTokens.input.toLocaleString()}</span></div>
|
||||
<div class="flex justify-between gap-6 mt-1"><span>${$i18n.t('Output')}</span><span class="tabular-nums">${totalTokens.output.toLocaleString()}</span></div>
|
||||
<div class="mt-2 max-w-56 text-gray-400">${$i18n.t('Token counts are estimates and may not reflect actual API usage')}</div>
|
||||
</div>`}
|
||||
>
|
||||
<span
|
||||
><span class="font-normal text-gray-900 dark:text-gray-300"
|
||||
>{formatNumber(totalTokens.total)}</span
|
||||
>
|
||||
|
|
|
|||
|
|
@ -176,6 +176,26 @@
|
|||
const saveHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
if (
|
||||
typeof config?.IMAGES_OPENAI_API_PARAMS === 'string' &&
|
||||
config.IMAGES_OPENAI_API_PARAMS.trim() !== '' &&
|
||||
!validateJSON(config.IMAGES_OPENAI_API_PARAMS)
|
||||
) {
|
||||
toast.error($i18n.t('Invalid JSON format for Parameters'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof config?.AUTOMATIC1111_PARAMS === 'string' &&
|
||||
config.AUTOMATIC1111_PARAMS.trim() !== '' &&
|
||||
!validateJSON(config.AUTOMATIC1111_PARAMS)
|
||||
) {
|
||||
toast.error($i18n.t('Invalid JSON format for Parameters'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (config?.COMFYUI_WORKFLOW) {
|
||||
if (!validateJSON(config?.COMFYUI_WORKFLOW)) {
|
||||
toast.error($i18n.t('Invalid JSON format for ComfyUI Workflow.'));
|
||||
|
|
|
|||
|
|
@ -186,155 +186,160 @@
|
|||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
|
||||
{#if servers !== null && connectionsConfig !== null}
|
||||
<AdminSettingSection
|
||||
title={$i18n.t('settings.admin.integrations.sections.tools.title')}
|
||||
first
|
||||
<fieldset
|
||||
disabled={!$config?.features?.enable_tool_servers}
|
||||
class="min-w-0 disabled:opacity-50"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('settings.admin.integrations.externalToolServers.label')}
|
||||
<AdminSettingSection
|
||||
title={$i18n.t('settings.admin.integrations.sections.tools.title')}
|
||||
first
|
||||
>
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('settings.admin.integrations.externalToolServers.label')}
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('settings.admin.integrations.addConnection.label')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
on:click={() => {
|
||||
showConnectionModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('settings.admin.integrations.addConnection.label')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
on:click={() => {
|
||||
showConnectionModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each servers ?? [] as server, idx}
|
||||
<Connection
|
||||
bind:connection={server}
|
||||
onSubmit={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
onDelete={() => {
|
||||
servers = (servers ?? []).filter((_, i) => i !== idx);
|
||||
updateHandler();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if (servers ?? []).length === 0}
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No tool server connections configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingSection>
|
||||
|
||||
<AdminSettingSection title={$i18n.t('settings.admin.integrations.sections.terminal.title')}>
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('settings.admin.integrations.openTerminal.label')}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each servers ?? [] as server, idx}
|
||||
<Connection
|
||||
bind:connection={server}
|
||||
onSubmit={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
onDelete={() => {
|
||||
servers = (servers ?? []).filter((_, i) => i !== idx);
|
||||
updateHandler();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('settings.admin.integrations.addConnection.label')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
on:click={() => {
|
||||
editTerminalIdx = null;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{#if (servers ?? []).length === 0}
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No tool server connections configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingSection>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each terminalConnections as connection, idx}
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<Tooltip className="w-full relative" content={''} placement="top-start">
|
||||
<div class="flex w-full">
|
||||
<div
|
||||
class="flex-1 relative flex gap-1.5 items-center {connection?.enabled ===
|
||||
false
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<Tooltip
|
||||
content={$i18n.t('settings.admin.integrations.sections.terminal.title')}
|
||||
>
|
||||
<Cloud className="size-4" strokeWidth="1.5" />
|
||||
</Tooltip>
|
||||
<AdminSettingSection title={$i18n.t('settings.admin.integrations.sections.terminal.title')}>
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('settings.admin.integrations.openTerminal.label')}
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('settings.admin.integrations.addConnection.label')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
on:click={() => {
|
||||
editTerminalIdx = null;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each terminalConnections as connection, idx}
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<Tooltip className="w-full relative" content={''} placement="top-start">
|
||||
<div class="flex w-full">
|
||||
<div
|
||||
class="outline-hidden w-full bg-transparent text-xs text-gray-700 dark:text-gray-300"
|
||||
class="flex-1 relative flex gap-1.5 items-center {connection?.enabled ===
|
||||
false
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
{connection.name || connection.url || $i18n.t('New Terminal')}
|
||||
<Tooltip
|
||||
content={$i18n.t('settings.admin.integrations.sections.terminal.title')}
|
||||
>
|
||||
<Cloud className="size-4" strokeWidth="1.5" />
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
class="outline-hidden w-full bg-transparent text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{connection.name || connection.url || $i18n.t('New Terminal')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<Tooltip content={$i18n.t('Configure')}>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
|
||||
on:click={() => {
|
||||
editTerminalIdx = idx;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
<div class="flex gap-1 items-center">
|
||||
<Tooltip content={$i18n.t('Configure')}>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
|
||||
on:click={() => {
|
||||
editTerminalIdx = idx;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={connection?.enabled !== false
|
||||
? $i18n.t('Enabled')
|
||||
: $i18n.t('Disabled')}
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={connection?.enabled !== false
|
||||
? $i18n.t('Enabled')
|
||||
: $i18n.t('Disabled')}
|
||||
>
|
||||
<Switch
|
||||
state={connection?.enabled !== false}
|
||||
on:change={() => {
|
||||
terminalConnections = terminalConnections.map((c, i) =>
|
||||
i === idx ? { ...c, enabled: !(c?.enabled !== false) } : c
|
||||
);
|
||||
saveTerminalServers();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
state={connection?.enabled !== false}
|
||||
on:change={() => {
|
||||
terminalConnections = terminalConnections.map((c, i) =>
|
||||
i === idx ? { ...c, enabled: !(c?.enabled !== false) } : c
|
||||
);
|
||||
saveTerminalServers();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if terminalConnections.length === 0}
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No terminal connections configured.')}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t(
|
||||
'Connect to Open Terminal instances. Admins and users granted access can use file browsing and terminal tools through these servers.'
|
||||
)}
|
||||
{#if terminalConnections.length === 0}
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No terminal connections configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t(
|
||||
'Connect to Open Terminal instances. Admins and users granted access can use file browsing and terminal tools through these servers.'
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
class="mt-0.5 block text-[0.6875rem] text-gray-500 underline hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
href="https://github.com/open-webui/open-terminal"
|
||||
target="_blank">{$i18n.t('Learn more about Open Terminal')} ↗</a
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
class="mt-0.5 block text-[0.6875rem] text-gray-500 underline hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
href="https://github.com/open-webui/open-terminal"
|
||||
target="_blank">{$i18n.t('Learn more about Open Terminal')} ↗</a
|
||||
>
|
||||
</div>
|
||||
</AdminSettingSection>
|
||||
</AdminSettingSection>
|
||||
</fieldset>
|
||||
|
||||
<AdminSettingSection title={$i18n.t('settings.admin.integrations.sections.knowledge.title')}>
|
||||
<ExternalKnowledge />
|
||||
|
|
@ -349,6 +354,7 @@
|
|||
let:labelId
|
||||
>
|
||||
<Switch
|
||||
disabled={!$config?.features?.enable_tool_servers}
|
||||
bind:state={connectionsConfig.ENABLE_DIRECT_INTEGRATIONS}
|
||||
on:change={updateDirectIntegrations}
|
||||
ariaLabelledbyId={labelId}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $config?.features?.enable_plugins}
|
||||
{#if $config?.features?.enable_tools}
|
||||
<div class="flex flex-col w-full">
|
||||
<Tooltip
|
||||
className="flex w-full justify-between my-1"
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@
|
|||
return null;
|
||||
});
|
||||
if (res) {
|
||||
automation = res;
|
||||
toast.success($i18n.t('Automation triggered'));
|
||||
setTimeout(() => loadRuns(false), 2000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@
|
|||
messages = null;
|
||||
channel = null;
|
||||
threadId = null;
|
||||
replyToMessage = null;
|
||||
|
||||
typingUsers = [];
|
||||
typingUsersTimeout = {};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { config, mobile, settings, socket, user } from '$lib/stores';
|
||||
import {
|
||||
convertHeicToJpeg,
|
||||
isHeicImage,
|
||||
compressImage,
|
||||
extractInputVariables,
|
||||
getAge,
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
import { getSessionUser } from '$lib/apis/auths';
|
||||
|
||||
import { uploadFile } from '$lib/apis/files';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
||||
|
||||
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
|
||||
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
|
||||
|
|
@ -377,7 +378,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (file['type'].startsWith('image/')) {
|
||||
if (file['type'].startsWith('image/') || isHeicImage(file)) {
|
||||
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||
// Quick shortcut so we don’t do unnecessary work.
|
||||
const settingsCompression =
|
||||
|
|
@ -415,6 +416,7 @@
|
|||
return imageUrl;
|
||||
};
|
||||
|
||||
const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file;
|
||||
let reader = new FileReader();
|
||||
|
||||
reader.onload = async (event) => {
|
||||
|
|
@ -424,12 +426,12 @@
|
|||
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
|
||||
|
||||
const blob = await (await fetch(imageUrl)).blob();
|
||||
const compressedFile = new File([blob], file.name, { type: file.type });
|
||||
const compressedFile = new File([blob], imageFile.name, { type: imageFile.type });
|
||||
|
||||
uploadFileHandler(compressedFile, false);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
|
||||
reader.readAsDataURL(imageFile);
|
||||
} else {
|
||||
uploadFileHandler(file);
|
||||
}
|
||||
|
|
@ -971,10 +973,26 @@
|
|||
|
||||
if (clipboardData && clipboardData.items) {
|
||||
for (const item of clipboardData.items) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await inputFilesHandler([file]);
|
||||
e.preventDefault();
|
||||
if (item.type === 'text/plain') {
|
||||
if ($settings?.largeTextAsFile ?? false) {
|
||||
const text = clipboardData.getData('text/plain');
|
||||
|
||||
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, {
|
||||
type: 'text/plain'
|
||||
});
|
||||
|
||||
await uploadFileHandler(file);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await inputFilesHandler([file]);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@
|
|||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
class="copy-code-button bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
|
||||
class="bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
|
||||
on:click={() => {
|
||||
copyToClipboard(contents[selectedContentIdx].content);
|
||||
copied = true;
|
||||
|
|
|
|||
|
|
@ -987,8 +987,9 @@
|
|||
/** Check whether a terminal ID references an available system or direct terminal. */
|
||||
const isTerminalAvailable = (tid: string): boolean => {
|
||||
return (
|
||||
($terminalServers ?? []).some((t) => t.id && t.id === tid) ||
|
||||
($settings?.terminalServers ?? []).some((s) => s.url === tid)
|
||||
$config?.features?.enable_tool_servers === true &&
|
||||
(($terminalServers ?? []).some((t) => t.id && t.id === tid) ||
|
||||
($settings?.terminalServers ?? []).some((s) => s.url === tid))
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1633,6 +1634,11 @@
|
|||
}
|
||||
});
|
||||
|
||||
// the artifacts pane reads this list when it opens, which can be before the debounced rebuild
|
||||
const showArtifactsSubscribe = showArtifacts.subscribe((value) => {
|
||||
if (value) getContents();
|
||||
});
|
||||
|
||||
const selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
|
||||
await tick();
|
||||
// Folder default models apply to new chats only.
|
||||
|
|
@ -1685,6 +1691,7 @@
|
|||
}
|
||||
pageSubscribe();
|
||||
showControlsSubscribe();
|
||||
showArtifactsSubscribe();
|
||||
selectedFolderSubscribe();
|
||||
|
||||
// Clear the selected chat when leaving the chat surface (e.g. navigating
|
||||
|
|
@ -3628,7 +3635,8 @@
|
|||
const skillIds = [...selectedSkillIds];
|
||||
|
||||
// Only send terminal_id if the model has terminal capability enabled
|
||||
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
|
||||
const terminalEnabled =
|
||||
$config?.features?.enable_tool_servers && (model.info?.meta?.capabilities?.terminal ?? true);
|
||||
const useChatVariablesFallback =
|
||||
!_chatId || $temporaryChatEnabled || isTemporaryChatId(_chatId);
|
||||
|
||||
|
|
@ -3646,21 +3654,30 @@
|
|||
|
||||
files: (files?.length ?? 0) > 0 ? files : undefined,
|
||||
|
||||
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
||||
tool_ids: toolIds.length > 0 ? toolIds : undefined,
|
||||
filter_ids:
|
||||
$config?.features?.enable_functions && selectedFilterIds.length > 0
|
||||
? selectedFilterIds
|
||||
: undefined,
|
||||
tool_ids: toolIds.filter((id) =>
|
||||
id.startsWith('server:')
|
||||
? $config?.features?.enable_tool_servers
|
||||
: $config?.features?.enable_tools
|
||||
),
|
||||
skill_ids: skillIds.length > 0 ? skillIds : undefined,
|
||||
terminal_id: terminalEnabled && $selectedTerminalId ? $selectedTerminalId : undefined,
|
||||
tool_servers: [
|
||||
...($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
|
||||
...(terminalEnabled
|
||||
? ($terminalServers ?? [])
|
||||
.filter((server) => !server.id)
|
||||
.map((server) => ({ ...server, is_terminal: true }))
|
||||
: [])
|
||||
],
|
||||
tool_servers: $config?.features?.enable_tool_servers
|
||||
? [
|
||||
...($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
|
||||
...(terminalEnabled
|
||||
? ($terminalServers ?? [])
|
||||
.filter((server) => !server.id)
|
||||
.map((server) => ({ ...server, is_terminal: true }))
|
||||
: [])
|
||||
]
|
||||
: [],
|
||||
features: getFeatures(),
|
||||
variables: {
|
||||
...getPromptVariables(
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
chatContextAvailable(selectedSystemTerminal) &&
|
||||
!(chatContextNeedsSavedChat(selectedSystemTerminal) && !isSavedChatId(chatId));
|
||||
$: terminalFilesAvailable = !!(
|
||||
$config?.features?.enable_tool_servers &&
|
||||
$selectedTerminalId &&
|
||||
(selectedSystemTerminalAvailable ||
|
||||
(!selectedSystemTerminal &&
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
|
||||
import {
|
||||
convertHeicToJpeg,
|
||||
isHeicImage,
|
||||
compressImage,
|
||||
createMessagesList,
|
||||
extractContentFromFile,
|
||||
|
|
@ -802,6 +803,7 @@
|
|||
$: hasDirectToolServerAccess =
|
||||
$_user?.role === 'admin' || ($_user?.permissions?.features?.direct_tool_servers ?? true);
|
||||
$: showTerminalSelector =
|
||||
$config?.features?.enable_tool_servers &&
|
||||
terminalCapableModels.length > 0 &&
|
||||
(($terminalServers ?? []).some((t) => t.id) ||
|
||||
(hasDirectToolServerAccess &&
|
||||
|
|
@ -1123,7 +1125,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (isRasterImageContentType(file['type'])) {
|
||||
if (isRasterImageContentType(file['type']) || isHeicImage(file)) {
|
||||
if (visionCapableModels.length === 0) {
|
||||
toast.error($i18n.t('Selected model(s) do not support image inputs'));
|
||||
return;
|
||||
|
|
@ -1165,6 +1167,7 @@
|
|||
return imageUrl;
|
||||
};
|
||||
|
||||
const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file;
|
||||
let reader = new FileReader();
|
||||
|
||||
reader.onload = async (event) => {
|
||||
|
|
@ -1183,13 +1186,13 @@
|
|||
];
|
||||
} else {
|
||||
const blob = await (await fetch(imageUrl)).blob();
|
||||
const compressedFile = new File([blob], file.name, { type: file.type });
|
||||
const compressedFile = new File([blob], imageFile.name, { type: imageFile.type });
|
||||
|
||||
uploadFileHandler(compressedFile, false);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
|
||||
reader.readAsDataURL(imageFile);
|
||||
} else {
|
||||
uploadFileHandler(file);
|
||||
}
|
||||
|
|
@ -2328,8 +2331,10 @@
|
|||
onClose={async () => {
|
||||
await tick();
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
if (!$mobile) {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
const getItemsPage = async (activeRequestId = requestId) => {
|
||||
itemsLoading = true;
|
||||
const res = query.trim()
|
||||
? await searchNotes(localStorage.token, query.trim(), null, null, null, page).catch(
|
||||
? await searchNotes(localStorage.token, query.trim(), null, 'read', null, page).catch(
|
||||
() => null
|
||||
)
|
||||
: await getNoteList(localStorage.token, page).catch(() => {
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@
|
|||
/>
|
||||
</svelte:element>
|
||||
{:else if token.type === 'code'}
|
||||
{#if token.raw.includes('```')}
|
||||
{#if token.codeBlockStyle !== 'indented'}
|
||||
<CodeBlock
|
||||
id={`${id}-${tokenIdx}`}
|
||||
collapsed={$settings?.collapseCodeBlocks ?? false}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { encode } from 'html-entities';
|
||||
|
||||
export type OutputContentPart = {
|
||||
type?: string;
|
||||
text?: unknown;
|
||||
|
|
@ -228,9 +230,10 @@ function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string,
|
|||
name,
|
||||
done: isDone ? 'true' : 'false',
|
||||
status,
|
||||
arguments: stringifyAttribute(item.arguments ?? ''),
|
||||
files: stringifyAttribute(resultItem?.files),
|
||||
embeds: stringifyAttribute(resultItem?.embeds)
|
||||
// ToolCallDisplay HTML-decodes these, like legacy <details> attributes.
|
||||
arguments: encode(stringifyAttribute(item.arguments ?? '')),
|
||||
files: encode(stringifyAttribute(resultItem?.files)),
|
||||
embeds: encode(stringifyAttribute(resultItem?.embeds))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { tick } from 'svelte';
|
||||
import { mobile } from '$lib/stores';
|
||||
|
||||
/** Whether the dropdown is open */
|
||||
export let show = false;
|
||||
|
|
@ -275,7 +276,7 @@
|
|||
if (show) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown(true);
|
||||
openDropdown(!$mobile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@
|
|||
let cellContent = turndownService.turndown(cell.innerHTML).trim();
|
||||
// Remove extra paragraph tags that might be added
|
||||
cellContent = cellContent.replace(/^\n+|\n+$/g, '');
|
||||
cellContent = cellContent.replace(/\n/g, '<br>');
|
||||
return cellContent;
|
||||
});
|
||||
|
||||
|
|
@ -142,7 +143,7 @@
|
|||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||
import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
|
||||
import { AIAutocompletion, setFollowUpSuggestion } from './RichTextInput/AutoCompletion.js';
|
||||
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
|
||||
|
|
@ -321,23 +322,7 @@
|
|||
export let followUpSuggestion = '';
|
||||
|
||||
$: if (editor && !editor.isDestroyed) {
|
||||
const { doc } = editor.state;
|
||||
const node = doc.firstChild;
|
||||
if (node?.type.name === 'paragraph' && !node.attrs['data-prompt']) {
|
||||
const suggestion = doc.childCount === 1 && node.content.size === 0 ? followUpSuggestion : '';
|
||||
if ((node.attrs['data-suggestion'] ?? '') !== suggestion) {
|
||||
editor.view.dispatch(
|
||||
editor.state.tr
|
||||
.setNodeMarkup(0, null, {
|
||||
...node.attrs,
|
||||
class: suggestion ? 'ai-autocompletion' : null,
|
||||
'data-prompt': suggestion ? '' : null,
|
||||
'data-suggestion': suggestion || null
|
||||
})
|
||||
.setMeta('addToHistory', false)
|
||||
);
|
||||
}
|
||||
}
|
||||
setFollowUpSuggestion(editor.view, followUpSuggestion);
|
||||
}
|
||||
|
||||
export let messageInput = false;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,31 @@ Intelligently reset suggestions on new input.
|
|||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||
|
||||
const aiAutocompletionKey = new PluginKey('aiAutocompletion');
|
||||
|
||||
const getFollowUpSuggestion = (state) => {
|
||||
const { doc } = state;
|
||||
const isEmpty =
|
||||
doc.childCount === 1 &&
|
||||
doc.firstChild.type.name === 'paragraph' &&
|
||||
doc.firstChild.content.size === 0;
|
||||
return isEmpty ? aiAutocompletionKey.getState(state) : '';
|
||||
};
|
||||
|
||||
export const setFollowUpSuggestion = (view, suggestion) => {
|
||||
const current = aiAutocompletionKey.getState(view.state);
|
||||
if (current === undefined || current === suggestion) return;
|
||||
view.dispatch(view.state.tr.setMeta(aiAutocompletionKey, suggestion));
|
||||
};
|
||||
|
||||
const acceptFollowUpSuggestion = (view) => {
|
||||
const suggestion = getFollowUpSuggestion(view.state);
|
||||
if (!suggestion) return false;
|
||||
view.dispatch(view.state.tr.insertText(suggestion));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const AIAutocompletion = Extension.create({
|
||||
name: 'aiAutocompletion',
|
||||
|
|
@ -131,8 +156,23 @@ export const AIAutocompletion = Extension.create({
|
|||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('aiAutocompletion'),
|
||||
key: aiAutocompletionKey,
|
||||
state: {
|
||||
init: () => '',
|
||||
apply: (tr, suggestion) => tr.getMeta(aiAutocompletionKey) ?? suggestion
|
||||
},
|
||||
props: {
|
||||
// A decoration is patched in place, so removing it cannot break an IME composition
|
||||
decorations: (state) => {
|
||||
const suggestion = getFollowUpSuggestion(state);
|
||||
if (!suggestion) return null;
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.node(0, state.doc.firstChild.nodeSize, {
|
||||
class: 'ai-autocompletion',
|
||||
'data-suggestion': suggestion
|
||||
})
|
||||
]);
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if (isComposing || event.isComposing || (event.key === 'Tab' && event.shiftKey))
|
||||
return false;
|
||||
|
|
@ -145,6 +185,8 @@ export const AIAutocompletion = Extension.create({
|
|||
const node = $head.parent;
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
if (acceptFollowUpSuggestion(view)) return true;
|
||||
|
||||
// if (!node.attrs['data-suggestion']) {
|
||||
// // Generate completion
|
||||
// if (loading) return true
|
||||
|
|
@ -219,6 +261,8 @@ export const AIAutocompletion = Extension.create({
|
|||
|
||||
// Check if the swipe was primarily horizontal and to the right
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) {
|
||||
if (acceptFollowUpSuggestion(view)) return true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
const { selection } = state;
|
||||
const { $head } = selection;
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ export const Image = Node.create<ImageOptions>({
|
|||
return [
|
||||
{
|
||||
tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])'
|
||||
},
|
||||
{
|
||||
tag: 'img[src^="data://"]'
|
||||
}
|
||||
];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@
|
|||
{
|
||||
label: $i18n.t('Start a new conversation'),
|
||||
onClick: async () => {
|
||||
await goto(`/${query ? `?q=${query}` : ''}`);
|
||||
await goto(`/${query ? `?q=${encodeURIComponent(query)}` : ''}`);
|
||||
show = false;
|
||||
onClose();
|
||||
},
|
||||
|
|
@ -482,7 +482,7 @@
|
|||
} else if (e.code === 'Enter') {
|
||||
const item = document.querySelector(`[data-arrow-selected="true"]`);
|
||||
if (item) {
|
||||
item?.click();
|
||||
(item.querySelector('a') ?? item).click();
|
||||
show = false;
|
||||
}
|
||||
|
||||
|
|
@ -585,7 +585,7 @@
|
|||
if (e.code === 'Enter' && (chatList ?? []).length > 0) {
|
||||
const item = document.querySelector(`[data-arrow-selected="true"]`);
|
||||
if (item) {
|
||||
item?.click();
|
||||
(item.querySelector('a') ?? item).click();
|
||||
}
|
||||
|
||||
show = false;
|
||||
|
|
|
|||
|
|
@ -1464,6 +1464,10 @@
|
|||
});
|
||||
|
||||
folderRegistry[chat.folder_id]?.setFolderItems();
|
||||
|
||||
if (res) {
|
||||
chat = res;
|
||||
}
|
||||
}
|
||||
|
||||
if (chat.pinned) {
|
||||
|
|
@ -1561,6 +1565,10 @@
|
|||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
chat = res;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chat.pinned) {
|
||||
|
|
|
|||
|
|
@ -835,8 +835,7 @@
|
|||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateHandler({ name });
|
||||
edit = false;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
class="w-full h-full bg-transparent outline-hidden"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
import { compressImage, copyToClipboard, convertHeicToJpeg } from '$lib/utils';
|
||||
import { compressImage, copyToClipboard, convertHeicToJpeg, isHeicImage } from '$lib/utils';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { getFileById, uploadFile } from '$lib/apis/files';
|
||||
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
|
||||
|
|
@ -627,7 +627,7 @@ ${content}
|
|||
return;
|
||||
}
|
||||
|
||||
if (file['type'].startsWith('image/')) {
|
||||
if (file['type'].startsWith('image/') || isHeicImage(file)) {
|
||||
const uploadImagePromise = new Promise(async (resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
|
|
@ -654,7 +654,7 @@ ${content}
|
|||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
|
||||
reader.readAsDataURL(isHeicImage(file) ? await convertHeicToJpeg(file) : file);
|
||||
});
|
||||
|
||||
return await uploadImagePromise;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2078,8 +2078,8 @@
|
|||
"settings.admin.authentication.label.label": "标签",
|
||||
"settings.admin.authentication.ldap.description": "允许用户通过 LDAP 目录进行身份验证。",
|
||||
"settings.admin.authentication.ldap.label": "LDAP",
|
||||
"settings.admin.authentication.loginForm.description": "",
|
||||
"settings.admin.authentication.loginForm.label": "",
|
||||
"settings.admin.authentication.loginForm.description": "在登录页显示邮箱和密码登录选项。关闭前请先配置 SSO 或 LDAP。关闭后也无法在本地注册,但不影响通过密码访问 API。",
|
||||
"settings.admin.authentication.loginForm.label": "登录表单",
|
||||
"settings.admin.authentication.mergeAccountsByEmail.description": "将 OAuth 登录关联到拥有相同邮件的现有账户。",
|
||||
"settings.admin.authentication.mergeAccountsByEmail.label": "按邮件合并账户",
|
||||
"settings.admin.authentication.newSignUps.description": "允许新用户注册账号。",
|
||||
|
|
@ -2167,8 +2167,8 @@
|
|||
"settings.admin.connections.cacheBaseModelList.label": "缓存基础模型列表",
|
||||
"settings.admin.connections.directConnections.description": "直接连接允许用户连接自有的 OpenAI 兼容的接口",
|
||||
"settings.admin.connections.directConnections.label": "直接连接",
|
||||
"settings.admin.connections.directIntegrations.description": "",
|
||||
"settings.admin.connections.directIntegrations.label": "",
|
||||
"settings.admin.connections.directIntegrations.description": "向有权限管理个人工具和终端连接的用户(包括管理员)显示“集成”标签页。隐藏该标签页不会禁用现有连接。",
|
||||
"settings.admin.connections.directIntegrations.label": "直接集成",
|
||||
"settings.admin.connections.manageOllamaApiConnections.description": "访问 Ollama 时遇到问题?",
|
||||
"settings.admin.connections.manageOllamaApiConnections.label": "管理 Ollama 接口连接",
|
||||
"settings.admin.connections.manageOpenaiApiConnections.label": "管理 OpenAI 接口连接",
|
||||
|
|
@ -2400,8 +2400,8 @@
|
|||
"settings.admin.general.sections.ui.title": "界面",
|
||||
"settings.admin.general.seeWhatSNew.label": "查阅最新更新内容",
|
||||
"settings.admin.general.title": "通用",
|
||||
"settings.admin.general.translations.addTranslation.label": "",
|
||||
"settings.admin.general.translations.uiTranslations.label": "",
|
||||
"settings.admin.general.translations.addTranslation.label": "添加翻译",
|
||||
"settings.admin.general.translations.uiTranslations.label": "界面翻译",
|
||||
"settings.admin.general.userStatus.description": "在应用中显示用户状态信息。",
|
||||
"settings.admin.general.userStatus.label": "用户状态",
|
||||
"settings.admin.general.userWebhooks.description": "允许用户在账号中配置 Webhook。",
|
||||
|
|
@ -2466,7 +2466,7 @@
|
|||
"settings.admin.images.serverConnectionVerified.label": "已验证服务器连接",
|
||||
"settings.admin.images.steps.label": "迭代步数",
|
||||
"settings.admin.images.title": "图片",
|
||||
"settings.admin.images.verifyConnection.label": "",
|
||||
"settings.admin.images.verifyConnection.label": "验证连接",
|
||||
"settings.admin.integrations.addConnection.label": "添加连接",
|
||||
"settings.admin.integrations.externalKnowledge.addConnection.label": "添加连接",
|
||||
"settings.admin.integrations.externalKnowledge.title": "外部知识源",
|
||||
|
|
@ -2667,8 +2667,8 @@
|
|||
"settings.admin.web.kagiSearchApiKey.label": "Kagi 搜索接口密钥",
|
||||
"settings.admin.web.language.label": "语言",
|
||||
"settings.admin.web.linkupApiKey.label": "Linkup 接口密钥",
|
||||
"settings.admin.web.maxContentLength.description": "",
|
||||
"settings.admin.web.maxContentLength.label": "",
|
||||
"settings.admin.web.maxContentLength.description": "每条 Exa 搜索结果的最大字符数。留空表示不限制。",
|
||||
"settings.admin.web.maxContentLength.label": "最大内容长度",
|
||||
"settings.admin.web.microsoftWebIqApiBaseUrl.label": "Microsoft Web IQ API 基础 URL",
|
||||
"settings.admin.web.microsoftWebIqApiKey.label": "Microsoft Web IQ API 密钥",
|
||||
"settings.admin.web.mojeekSearchApiKey.label": "Mojeek Search 接口密钥",
|
||||
|
|
@ -2865,8 +2865,8 @@
|
|||
"settings.personal.general.theme.description": "选择界面使用的颜色主题。",
|
||||
"settings.personal.general.theme.label": "主题",
|
||||
"settings.personal.general.title": "通用",
|
||||
"settings.personal.interface.accessibilityMode.description": "启用无障碀导向的视觉增强效果。",
|
||||
"settings.personal.interface.accessibilityMode.label": "",
|
||||
"settings.personal.interface.accessibilityMode.description": "启用针对无障碍使用优化的视觉增强效果。",
|
||||
"settings.personal.interface.accessibilityMode.label": "无障碍模式",
|
||||
"settings.personal.interface.allowUserLocation.description": "和可以使用它的功能分享您的当前位置。",
|
||||
"settings.personal.interface.allowUserLocation.label": "获取您的位置",
|
||||
"settings.personal.interface.allowVoiceInterruptionInCall.description": "允许语音在语音通话期间中断助手。",
|
||||
|
|
@ -2914,8 +2914,8 @@
|
|||
"settings.personal.interface.floatingQuickActions.label": "快捷操作浮窗",
|
||||
"settings.personal.interface.followUpAutoGeneration.description": "在回答后生成建议的后续提示词。",
|
||||
"settings.personal.interface.followUpAutoGeneration.label": "自动生成追问",
|
||||
"settings.personal.interface.fontFamily.description": "",
|
||||
"settings.personal.interface.fontFamily.label": "",
|
||||
"settings.personal.interface.fontFamily.description": "为应用界面使用本地字体。",
|
||||
"settings.personal.interface.fontFamily.label": "字体",
|
||||
"settings.personal.interface.hapticFeedback.label": "震动反馈",
|
||||
"settings.personal.interface.iframeSandboxAllowDownloads.description": "允许在沙盒 iframe 内下载文件。",
|
||||
"settings.personal.interface.iframeSandboxAllowDownloads.label": "iframe 沙箱允许下载",
|
||||
|
|
@ -2998,7 +2998,7 @@
|
|||
"settings.personal.personalization.savedMemories.label": "已保存的记忆",
|
||||
"settings.personal.personalization.sections.memory.title": "记忆",
|
||||
"settings.personal.personalization.title": "个性化",
|
||||
"settings.personal.shortcuts.acceptAutocomplete.label": "",
|
||||
"settings.personal.shortcuts.acceptAutocomplete.label": "接受自动补全生成\n跳转到提示词变量",
|
||||
"settings.personal.shortcuts.addPrompt.label": "增加自定义提示词",
|
||||
"settings.personal.shortcuts.allowToolCall.label": "允许工具调用",
|
||||
"settings.personal.shortcuts.attachFile.label": "引用知识库中的文件",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,9 @@ type Config = {
|
|||
enable_community_sharing: boolean;
|
||||
enable_memories: boolean;
|
||||
enable_plugins?: boolean;
|
||||
enable_tools?: boolean;
|
||||
enable_functions?: boolean;
|
||||
enable_tool_servers?: boolean;
|
||||
enable_autocomplete_generation: boolean;
|
||||
enable_direct_connections: boolean;
|
||||
enable_direct_integrations?: boolean;
|
||||
|
|
|
|||
|
|
@ -2107,10 +2107,18 @@ export const getAge = (birthDate) => {
|
|||
return age.toString();
|
||||
};
|
||||
|
||||
const HEIC_EXTENSION_PATTERN = /\.(heic|heif)$/i;
|
||||
|
||||
// Browsers label HEIC photos inconsistently (Firefox uses image/heif, some leave it empty).
|
||||
export const isHeicImage = (file: File) =>
|
||||
['image/heic', 'image/heif'].includes(file.type) || HEIC_EXTENSION_PATTERN.test(file.name);
|
||||
|
||||
export const convertHeicToJpeg = async (file: File) => {
|
||||
const { default: heic2any } = await import('heic2any');
|
||||
try {
|
||||
return await heic2any({ blob: file, toType: 'image/jpeg' });
|
||||
const jpegBlob = (await heic2any({ blob: file, toType: 'image/jpeg' })) as Blob;
|
||||
const jpegName = `${file.name.replace(HEIC_EXTENSION_PATTERN, '')}.jpg`;
|
||||
return new File([jpegBlob], jpegName, { type: 'image/jpeg' });
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('already browser readable')) {
|
||||
return file;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@
|
|||
};
|
||||
|
||||
const setToolServers = async () => {
|
||||
if (!$config?.features?.enable_tool_servers) {
|
||||
toolServers.set([]);
|
||||
terminalServers.set([]);
|
||||
return;
|
||||
}
|
||||
let toolServersData = await getToolServersData($settings?.toolServers ?? []);
|
||||
toolServersData = toolServersData.filter((data) => {
|
||||
if (!data || data.error) {
|
||||
|
|
@ -234,7 +239,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
selectedTerminalId.set(localStorage.selectedTerminalId ?? null);
|
||||
selectedTerminalId.set(
|
||||
$config?.features?.enable_tool_servers ? (localStorage.selectedTerminalId ?? null) : null
|
||||
);
|
||||
|
||||
const loadToolServers = setToolServers().catch((e) => {
|
||||
console.error('Failed to load tool servers:', e);
|
||||
|
|
@ -389,6 +396,7 @@
|
|||
|
||||
// Persist selectedTerminalId across page loads
|
||||
selectedTerminalId.subscribe((value) => {
|
||||
if (!$config?.features?.enable_tool_servers) return;
|
||||
if (value === null) {
|
||||
delete localStorage.selectedTerminalId;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
if ($user?.role !== 'admin') {
|
||||
await goto('/', { replaceState: true });
|
||||
} else if (
|
||||
!$config?.features?.enable_plugins &&
|
||||
!$config?.features?.enable_functions &&
|
||||
$page.url.pathname.includes('/admin/functions')
|
||||
) {
|
||||
await goto('/admin', { replaceState: true });
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
href="/admin/evaluations">{$i18n.t('Evaluations')}</a
|
||||
>
|
||||
|
||||
{#if $config?.features?.enable_plugins}
|
||||
{#if $config?.features?.enable_functions}
|
||||
<a
|
||||
draggable="false"
|
||||
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/admin/functions')
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import Functions from '$lib/components/admin/Functions.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
if (!$config?.features?.enable_plugins) {
|
||||
if (!$config?.features?.enable_functions) {
|
||||
await goto('/admin', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!$config?.features?.enable_plugins) {
|
||||
if (!$config?.features?.enable_functions) {
|
||||
goto('/admin', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!$config?.features?.enable_plugins) {
|
||||
if (!$config?.features?.enable_functions) {
|
||||
goto('/admin', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import {
|
||||
getCalendars,
|
||||
getCalendarEvents,
|
||||
getCalendarEventById,
|
||||
deleteCalendar,
|
||||
type CalendarModel,
|
||||
type CalendarEventModel
|
||||
|
|
@ -134,7 +135,7 @@
|
|||
showEventModal = true;
|
||||
}
|
||||
|
||||
function handleEventClick(e: CustomEvent<CalendarEventModel>) {
|
||||
async function handleEventClick(e: CustomEvent<CalendarEventModel>) {
|
||||
const evt = e.detail;
|
||||
if (evt.meta?.automation_id) {
|
||||
if (evt.meta?.chat_id) {
|
||||
|
|
@ -144,7 +145,9 @@
|
|||
}
|
||||
return;
|
||||
}
|
||||
editEvent = evt;
|
||||
editEvent = evt.instance_id
|
||||
? ((await getCalendarEventById(localStorage.token, evt.id)) ?? evt)
|
||||
: evt;
|
||||
defaultStartAt = null;
|
||||
showEventModal = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
const canViewPrompts = $user?.role === 'admin' || $user?.permissions?.workspace?.prompts;
|
||||
const canViewSkills = $user?.role === 'admin' || $user?.permissions?.workspace?.skills;
|
||||
const canViewTools =
|
||||
$config?.features?.enable_plugins &&
|
||||
$config?.features?.enable_tools &&
|
||||
($user?.role === 'admin' || $user?.permissions?.workspace?.tools);
|
||||
|
||||
const [modelRes, knowledgeRes, promptRes, skillRes, toolRes] = await Promise.all([
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
goto('/', { replaceState: true });
|
||||
} else if (
|
||||
$page.url.pathname.includes('/tools') &&
|
||||
(!$config?.features?.enable_plugins || !$user?.permissions?.workspace?.tools)
|
||||
(!$config?.features?.enable_tools || !$user?.permissions?.workspace?.tools)
|
||||
) {
|
||||
goto('/', { replaceState: true });
|
||||
} else if ($page.url.pathname.includes('/skills') && !$user?.permissions?.workspace?.skills) {
|
||||
|
|
@ -216,7 +216,7 @@
|
|||
</a>
|
||||
{/if}
|
||||
|
||||
{#if $config?.features?.enable_plugins && ($user?.role === 'admin' || $user?.permissions?.workspace?.tools)}
|
||||
{#if $config?.features?.enable_tools && ($user?.role === 'admin' || $user?.permissions?.workspace?.tools)}
|
||||
<a
|
||||
draggable="false"
|
||||
aria-current={activeWorkspaceSection === 'tools' ? 'page' : null}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
goto('/workspace/knowledge', { replaceState: true });
|
||||
} else if ($user?.permissions?.workspace?.prompts) {
|
||||
goto('/workspace/prompts', { replaceState: true });
|
||||
} else if ($config?.features?.enable_plugins && $user?.permissions?.workspace?.tools) {
|
||||
} else if ($config?.features?.enable_tools && $user?.permissions?.workspace?.tools) {
|
||||
goto('/workspace/tools', { replaceState: true });
|
||||
} else if ($user?.permissions?.workspace?.skills) {
|
||||
goto('/workspace/skills', { replaceState: true });
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
import Tools from '$lib/components/workspace/Tools.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!$config?.features?.enable_plugins) {
|
||||
if (!$config?.features?.enable_tools) {
|
||||
goto('/workspace', { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $config?.features?.enable_plugins}
|
||||
{#if $config?.features?.enable_tools}
|
||||
<Tools />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,10 @@
|
|||
};
|
||||
|
||||
const executeTool = async (data, cb, chatId) => {
|
||||
if (!$config?.features?.enable_tool_servers) {
|
||||
cb?.({ error: 'Tool servers are disabled' });
|
||||
return;
|
||||
}
|
||||
const { toolServer, toolServerData, token } = resolveToolServer(data.server?.url);
|
||||
const defaultInline =
|
||||
data?.name === 'display_file' &&
|
||||
|
|
@ -634,6 +638,7 @@
|
|||
return;
|
||||
} else if (type === 'request:terminal') {
|
||||
try {
|
||||
if (!$config?.features?.enable_tool_servers) throw new Error('Tool servers are disabled');
|
||||
const connection = resolveTerminalConnection(
|
||||
data.terminal_id,
|
||||
[],
|
||||
|
|
@ -868,6 +873,10 @@
|
|||
|
||||
if (type === 'message') {
|
||||
const title = `${data?.user?.name}${event?.channel?.type !== 'dm' ? ` (#${event?.channel?.name})` : ''}`;
|
||||
const content = data?.content?.replace(
|
||||
/<([@#])([^|>\s]+)(?:\|([^>]*))?>/g,
|
||||
(_, trigger, id, label) => trigger + (label || id)
|
||||
);
|
||||
|
||||
if ($isLastActiveTab) {
|
||||
if ($settings?.notificationEnabled ?? false) {
|
||||
|
|
@ -875,7 +884,7 @@
|
|||
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
// https://docs.openwebui.com/license.
|
||||
new Notification(`${title} / Open WebUI`, {
|
||||
body: data?.content,
|
||||
body: content,
|
||||
icon: `${WEBUI_API_BASE_URL}/users/${data?.user?.id}/profile/image`
|
||||
});
|
||||
}
|
||||
|
|
@ -888,7 +897,7 @@
|
|||
`/channels/${event.channel_id}${data?.parent_id ? `?thread=${data.parent_id}` : ''}`
|
||||
);
|
||||
},
|
||||
content: data?.content,
|
||||
content,
|
||||
title: `${title}`
|
||||
},
|
||||
duration: 15000,
|
||||
|
|
@ -1255,7 +1264,10 @@
|
|||
// Initialize i18n even if we didn't get a backend config,
|
||||
// so `/error` can show something that's not `undefined`.
|
||||
|
||||
await initI18n(localStorage?.locale, backendConfig?.i18n ?? {});
|
||||
await initI18n(
|
||||
localStorage?.locale ?? backendConfig?.default_locale,
|
||||
backendConfig?.i18n ?? {}
|
||||
);
|
||||
if (!localStorage.locale) {
|
||||
const languages = await getLanguages();
|
||||
const browserLanguages = navigator.languages
|
||||
|
|
|
|||
72
uv.lock
generated
72
uv.lock
generated
|
|
@ -2950,7 +2950,7 @@ requires-dist = [
|
|||
{ name = "psycopg2-binary", marker = "extra == 'all'", specifier = "==2.9.12" },
|
||||
{ name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = "==2.9.12" },
|
||||
{ name = "pyarrow", specifier = "==20.0.0" },
|
||||
{ name = "pycrdt", specifier = "==0.13.1" },
|
||||
{ name = "pycrdt", specifier = "==0.14.8" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pydub", specifier = "==0.25.1" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
|
|
@ -3797,46 +3797,46 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pycrdt"
|
||||
version = "0.13.1"
|
||||
version = "0.14.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/88/e0c1b145f25f02ae0b95ce98cfa653941fc3e13ec95b1c90a62cd717a932/pycrdt-0.13.1.tar.gz", hash = "sha256:3d888fc5dafe17d6f53a1430c5342cd58f0f704cafb6ff3f565e597c50d99934", size = 85504, upload-time = "2026-05-21T12:36:25.545Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/9f/540084c927f3ff2d22883abb722c6b9af1630b0ac31d7f4cf4ec4f1342df/pycrdt-0.14.8.tar.gz", hash = "sha256:45867f5ff08006d852d0cbb3e26b581977122b8f77dcd300359a16188b6cc931", size = 98177, upload-time = "2026-09-30T07:59:48.553Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/3c/7d6c850fcbd7cf011c1818cf27c85c04c23a91c20c660c89f40658d51561/pycrdt-0.13.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ab8e797a7e7658a4765acd25ca6c175e33327be4cecf6cbf6299bd13d4b7d5f5", size = 1702440, upload-time = "2026-05-21T12:34:39.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ea/2a1a42358ad9a0fbca3a84193ea6ad5c965412e6b9175080a37c53026261/pycrdt-0.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4eb42e3ccc7fa265a181c0f1b322f263ed367f456b5cc2b09981e5830b5d56", size = 948038, upload-time = "2026-05-21T12:34:41.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/34/c2e08ddb3253ff53629ce3db23ec806b669b7015570588d5e526c811aed2/pycrdt-0.13.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc68323d1a3c7b7c7e5c595308b52d6ae6141709724b043cfd333f431d6d259", size = 961904, upload-time = "2026-05-21T12:34:42.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/96/ce1bd0827887b44cdb4f522dbde524b237bedafcf10de288e18169271248/pycrdt-0.13.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28e3abb9a5c9642cd05a73871f58982d56a56aa86c3373e93c9c690cc187a683", size = 1135191, upload-time = "2026-05-21T12:34:44.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/71/4dd73ff84e1ed4f05328d09988551bed88c7e10d1bb0e2b0656ee6d4046c/pycrdt-0.13.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e24ff42e99aa67452b4bfec72d09de36425ec2eabc7e78d4c59ea671baef81e", size = 988852, upload-time = "2026-05-21T12:34:45.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/4c/da70355291570a1c61465791b2ee648457bef9dde62e66b1617349bf5d18/pycrdt-0.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689c3e1c100c86096802bc27356f8b686fea3abbd2e1641ae87783f68dd4081c", size = 957219, upload-time = "2026-05-21T12:34:47.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/0454874f4817b4a759b4bf9731511f452d404d8bf1d4a9a5fc30ab76c2d4/pycrdt-0.13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0f49ad9d702c0ef09f8346e4cb445ca34d0a1760186178acb0fd9c7dcedd5a7d", size = 1057747, upload-time = "2026-05-21T12:34:49.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/ddcb2636e5c5d5efa5b52c87b772fe8708d62c423c5a84bd01b4cead706d/pycrdt-0.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c07b7d7e6914900c2b878827e772a9f4226248d7dba098965c5bdd9b9aab062", size = 1123471, upload-time = "2026-05-21T12:34:50.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/74/28e63a2e46a7cb4eef0ed9961b02f1f4bb15ef62ee27abe89e43ed5433c0/pycrdt-0.13.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:dd3b5e58ae6fe8acfeceeb10fd23a5778fdb67596c5411a16b5bf51bdab6b277", size = 1236330, upload-time = "2026-05-21T12:34:51.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/5f/8b8fcc48a46a721c4c38ccd30a718a56e4eff282926b9dffe7c817e56aaa/pycrdt-0.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9ab6caa63565d4ba7f109126336ac1c30fbe146b25b16574669013e52c65feb5", size = 1232793, upload-time = "2026-05-21T12:34:53.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ea/59e0814910e181af50a3f3e20d68e005b5e2598aa6401812168ccedd11b1/pycrdt-0.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23be7d711363b51324cdba90c1e67d8f5c4e37e14eb503c38a9b35de8b329be1", size = 1170011, upload-time = "2026-05-21T12:34:55.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/a9/2c6fa5c160b328b08a21ab1c75caffdfce2a24bf0cf0edfd7c6f329ab1bf/pycrdt-0.13.1-cp311-cp311-win32.whl", hash = "sha256:6926f156e8a0e88c967a5b4bea2af1425054775943fc9e0be08f4c9523fba509", size = 696030, upload-time = "2026-05-21T12:34:57.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f0/036f521770e156e5250dc6a930516b0acf70482792a563afc9ac2cca133c/pycrdt-0.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a65d09e383debe2a00d13a3e1415cd599037e0dbf3976adca8cc36483eafc1db", size = 756974, upload-time = "2026-05-21T12:34:58.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/47/61ecc2f5a2d2127a00ffeb3eae5e515f7fc07e203883001cd0c5f8e2c319/pycrdt-0.13.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0269fefddef54f529ef360554ec7ac967d01ba22677c65fc155addbb5c4329d1", size = 1700474, upload-time = "2026-05-21T12:35:00.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/e5/595d8582952be13060556f2faa29bff2b10b9247011a1a5882240fea6f33/pycrdt-0.13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:084fd1c3a680764a1717c638d26dd238a953b8a66801ce34d0f4041963c525cf", size = 942672, upload-time = "2026-05-21T12:35:01.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0b/429b57dd1e0a1e8eb79ce8bbd97e1ecea8566c1ed7fea08f79bcd54ad0b0/pycrdt-0.13.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8a417723c723764f4621e03df16e7a19e55ed28048b7015afa04cc4e71338", size = 958984, upload-time = "2026-05-21T12:35:03.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/be64722f870b03cd8b50505567692f5615ebcc40c03b7dc7b1c1efe15547/pycrdt-0.13.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b69026155572547633af419d7e6da728aa53175b9df95b65a4850365ffc4d79", size = 1134053, upload-time = "2026-05-21T12:35:05.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/44/b5ba4ed6b2be26ded680a0a2115e749275f779c0db93d8bdc20dcf9f5874/pycrdt-0.13.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d5a4f4ff430a5ed71c33680ec69f6d914c12a4332ba67f4566c82688357046b", size = 984448, upload-time = "2026-05-21T12:35:07.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ec/7363504d395d6785f159830430e24ecb1e9d074e2ce364db39fbc104d94f/pycrdt-0.13.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:190819bf2eebb9153ae6cf35e62d09a57fd0de15176a5047c2cb27130f57381d", size = 954167, upload-time = "2026-05-21T12:35:08.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/83/46ab10d688add2a3dce155a3b3062f8cbf8080cd0b87b4e0f04cfeb1170b/pycrdt-0.13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:425541aeb3c6809938685a680b21fecb09b352e7fb8a21a97e0742fa27cb8cb6", size = 1052431, upload-time = "2026-05-21T12:35:10.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4d/015157ec60a82cedcf9322b55bb8cc0c31fe15bb0582f0644d7655b04d49/pycrdt-0.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ea52a06fc84bebd719f515ce9a590e8bc5fc58f2a6c262db1cd01d727cf253", size = 1118940, upload-time = "2026-05-21T12:35:12.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/82/c9cab9cfc73cfa87ad97580822274f3a51aba47f9432b8c95795dae84f7a/pycrdt-0.13.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06066675f65e228c22ebf5e5bb1e79fe463f89832b48899b092259c206256880", size = 1233250, upload-time = "2026-05-21T12:35:14.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/64/9d9069e7572c8350206c315cf8431d50b03a8c7af8d8b8a4c19338d99fd5/pycrdt-0.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:29e99ae8c2c7eb31363af386f22221fb5b685710b7e665ac90a5f772c0dc3df5", size = 1226052, upload-time = "2026-05-21T12:35:15.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c6/2f2b36043170c511b0284987ecb346fa7e90709712600c9e298219b58bd3/pycrdt-0.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07692df4f1d493fabe7e337004a4cd671978833486f0d1dd481748f1f91350d2", size = 1167999, upload-time = "2026-05-21T12:35:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/14/d1dc34597666a491683a1fd61439f3350180d0bc53101effb465f56698e9/pycrdt-0.13.1-cp312-cp312-win32.whl", hash = "sha256:b4640674e88978cdd12ad531c6b73ac2d72697b854ca04c968113a36cb98c867", size = 693890, upload-time = "2026-05-21T12:35:19.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/d5/2b84f8e3e6aae2632c6ee7351b73fa74093795ba24751ec582773d881ca3/pycrdt-0.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:6502dc5eae7f9489da534fc847d61c7bedcaf014c65fd46dbb4a30ca298594a7", size = 753915, upload-time = "2026-05-21T12:35:21.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4d/56a44130870221ff86bde308585e079254f0b44bcd4ee5776d924be85d16/pycrdt-0.13.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1110cd4460afb0fdae5ff88a8356dbf0cc963904d6fbc222ea68f4f4e6b9dd84", size = 1708730, upload-time = "2026-05-21T12:36:10.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/85/d8779cfd4d288d4382bb148f13bb02e423983418616577dc1142eddd4073/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b864bef9c42a7c18c1652f31907b2eda68e18a87bcc33369e342e667c1b9381", size = 947592, upload-time = "2026-05-21T12:36:12.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/3a/5240398ec6adce9a7807de918c1dfd1325085eb07e1a32533085e9ff15f5/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21e1a73cf7b28391b2f52461590f3be4262df811b0648e4faf7a1503abe2c8d0", size = 965927, upload-time = "2026-05-21T12:36:14.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d8/7af8f8ee7e82987bf0b3bffb1f10b03715d47f929b4407c9566290c74d8d/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4fbb82675b0a6130cd7df1fe60c53ef02515d9f79f7c106f1bbb37fe2368239", size = 1132014, upload-time = "2026-05-21T12:36:16.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/e56e7b4ffdd12824d0a78f8ebe31d150a806a23aee065dc83ad9b3497875/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7d2424598c2b960a92ae705b1e213ad65d83b3210774ba08cd1d5c246a294b3", size = 989719, upload-time = "2026-05-21T12:36:19.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/da/9154643a1d93b32d8cb16cc30a2eaaec3beb791fb4e240c84b2bc11a0d13/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69fc50d94b4adf6f87bcb7ce70beee1a9af2684e9a43bc46c04f7bf909cc5a05", size = 956173, upload-time = "2026-05-21T12:36:21.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c4/3058ea744cd72c89a982e5c9b5d85dedd6bd8b93602828759708403dd100/pycrdt-0.13.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dfec67ce79abc92d60cf5727dd58a762d6be361b550943d533a1407c73e62b4f", size = 1056620, upload-time = "2026-05-21T12:36:23.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4c/e725ecfdd367cd21dc9a29569e3d692e5f342731da5fffd8eee6e0f5ed0a/pycrdt-0.14.8-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dc8b17443acbeb1668e9e442e18f5f6bb5cad6e1904c3b9144d512b2489f3499", size = 1939220, upload-time = "2026-09-30T07:57:53.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5f/94b91aeec65371846c968b10c2b983e107ff51b1d2a774d03520cac16ffe/pycrdt-0.14.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3f6694066aafdf0e7014af3a89dcc26db75619dd99259a002e6954b04c5e27", size = 1074712, upload-time = "2026-09-30T07:57:55.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/8c/6c9c397ea7fa7ce99c0ce3c947ad3415940568018c703949c2d7e0d4363d/pycrdt-0.14.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2564a348d8125d12c4891a724c1baf36601df2da3e0140abf29f40b65c5b4265", size = 1098768, upload-time = "2026-09-30T07:57:57.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/66/7c06bae084d1ccc6c20e42989e94d8931b8170e42452e47d6ccab16ec69b/pycrdt-0.14.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a55d9be59106b52f5b209e6a796994535f40a0f83af3aae75a5de18656248ebc", size = 1290230, upload-time = "2026-09-30T07:57:59.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/29/a171029873629ca87f26df37249e0d582c75b22658008676b32832f0053c/pycrdt-0.14.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18bd511f71b6a2a90f3955f1d7cec6dfdba389527ef105fb2598cdd987365c5f", size = 1126302, upload-time = "2026-09-30T07:58:01.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ab/5aeef5e966d5fc51dcd87e971fce9d6fa2e03addd876f5e782fe6eb4473a/pycrdt-0.14.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa6226a9f402c00b6a4f0654f7ba032291cfff5c1dc7154dfdf0db74653fe", size = 1081298, upload-time = "2026-09-30T07:58:02.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/46/44ef9ebb9728216ade30cca1478e76205e5bc2c80577708b1b3fc477d228/pycrdt-0.14.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ddebf695a4cd846f2750fbfaf657233910a1a33daf280aa4cbde5f38a4c1e0e", size = 1212135, upload-time = "2026-09-30T07:58:04.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/2d/280a9c13b830bac82d690984730383bb8ad42ffdbdec2efd769690b9263a/pycrdt-0.14.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cb8f669ec1b8d5c783db4ad94a25da2df010b3ee0fa756e9025530ec57657433", size = 1250917, upload-time = "2026-09-30T07:58:06.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/82/7af0c55470dd6563c106fac7162b442eb3262d665adbce4318a06bcc4c27/pycrdt-0.14.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fa5a9531cc245253e7400eb684a3de357a6c44d5355cf51ec7aa603536a12b03", size = 1374029, upload-time = "2026-09-30T07:58:07.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/2abff768d3e63b161a989d6204f91598a1d53ffd9c01c95c0b41db30b73b/pycrdt-0.14.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dab9b0e5eb9abbdf20c9b8c159f16d681512aaf11c2b975c3b2f2f8fca8c7fbf", size = 1379658, upload-time = "2026-09-30T07:58:09.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/6052266979223afd0b78074748d6b6fa1dfd981359e35531fd84a0e2d580/pycrdt-0.14.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fa7bb3e4cb0bc9d6c9d77567b141847aa5da90004d4c8e2b588d8ed1cb933ae2", size = 1309915, upload-time = "2026-09-30T07:58:11.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/7b/8fd45190f55ac85ecb18695b8d89316c26f7fd13a54ee3ff056bb224868e/pycrdt-0.14.8-cp311-cp311-win32.whl", hash = "sha256:53942c09503d3c4c42f0b2c59b95dc5a80b18982871cdc57d04e7070c3700a5d", size = 828399, upload-time = "2026-09-30T07:58:12.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a8/80279dc4eff64d4609aec3dd1036898628a90305f0e0413d661a81640b7f/pycrdt-0.14.8-cp311-cp311-win_amd64.whl", hash = "sha256:180b587d9561eea6858cc503a288b44e8891fd21d8eb3fd02665728c48f6beeb", size = 884067, upload-time = "2026-09-30T07:58:14.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/dd/f6abc67c12ca906c977685f2fc8532b6b8d84c3b999089d78274fe1422d1/pycrdt-0.14.8-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dcfdb452743ae02a3d1cff83bf7ef3a95de3e054732add89e3fa5ae5bbac3ba7", size = 1932928, upload-time = "2026-09-30T07:58:16.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b4/8073c090a2130cb09f455f75a9ed5b5e2e0feb39ca12af88357aca0b3e52/pycrdt-0.14.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0827c9809899f5ceb52572f5421b1344d019de8c13df8e5c7b085bce554c463c", size = 1066250, upload-time = "2026-09-30T07:58:18.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ef/0a6347ea10ade0991dc15ed5b138f88253d74e19c2ebea9f806f0888f12e/pycrdt-0.14.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:822a7b68ea6274c0df6ba8f4f52e8592d607cf5cc9ab88a91992165e9e3c1b9f", size = 1094538, upload-time = "2026-09-30T07:58:20.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d5/446e965ebe08f2f2737d041cd49314386b25bb0bfc9553b48d657f82c192/pycrdt-0.14.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d299bdbe7fb0bc2fc81ca6c734252d757b873b12ac604824129a6238740153a", size = 1281969, upload-time = "2026-09-30T07:58:22.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/cd/f3b03152dee00f559e54faa0850bffcb817d4a9efb19f98f564bc36d8dbc/pycrdt-0.14.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60d49df3203cd5e51197ff02898c474871618742d28a0da680831af4cc2ed854", size = 1124068, upload-time = "2026-09-30T07:58:24.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/24/158eea1800e1d12c6c05d2d321fa161da5ec896ce7ee466ac1354b28e2ce/pycrdt-0.14.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0496ade0ec92904f244c04c6791f9585aedab9065359e5f4c770522ea755695e", size = 1073691, upload-time = "2026-09-30T07:58:26.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d2/f6dc68037c0312c0bfa59fcc2fe8c9522fafb20d6793d6cff947503c41e6/pycrdt-0.14.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f88f775836bd9a6897aaf9020f2df84e59ab9e3d669cd1dd8c111dc2239822d", size = 1205759, upload-time = "2026-09-30T07:58:28.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/01/4f543c17582ee3319952103c1dd6a4c5935119091070ee28bc7a6e83bb52/pycrdt-0.14.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5871a239cbf8e8428aeee9db7250ad01fbde0d3f73761f3fd99b25c12f363d4d", size = 1243621, upload-time = "2026-09-30T07:58:29.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/06/3676b46449ab54e19c17dc51beba8ae5c80c5a34c0fee9ea84715cabe991/pycrdt-0.14.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3d173c730424d777f8d315c8407e7f221d981ce233cc9450731abb347049d09a", size = 1372362, upload-time = "2026-09-30T07:58:31.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/cfb116e6283dc0ff32559a01be9732db5f579b2752041458751fb8ec2a27/pycrdt-0.14.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:725ed3241d0a748e374f1b8891d42b05b697c7d6cc65932cc6bf2f316146d6e6", size = 1372653, upload-time = "2026-09-30T07:58:33.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/bf/d14c1c8b39302df43342cd6f29027afa03e709cd49bc8ac5ee2135e8d0d7/pycrdt-0.14.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8822a1da5a9252b18465b10203780400babbdf75f3284858652490c6ea2a759b", size = 1299725, upload-time = "2026-09-30T07:58:35.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/52/a4b61c0edfdf136ef3e1c1a9153e08c61ae2e5143e2597a6f9df58bd533d/pycrdt-0.14.8-cp312-cp312-win32.whl", hash = "sha256:29b5689393acb6b9475f2e5ea122e80b5eb1a96c6cbb9f362cfb44cd4279f3e4", size = 822498, upload-time = "2026-09-30T07:58:37.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/da/f5108de83a48b62ae289802e751e5bd98189e1e425adf98dbd2d403ed719/pycrdt-0.14.8-cp312-cp312-win_amd64.whl", hash = "sha256:ff7c417c59e2bd72bea576323a3042017313eddebc5dd06c153a38a6016b80ba", size = 877355, upload-time = "2026-09-30T07:58:39.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0c/1c419aea13103e8c6296eea95f9dc09763ee58f98edf707e417a6f0935c1/pycrdt-0.14.8-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:49e4de3e62bfa05404ea6dea5d850a8102a0a73cf02eca434807825c831d2e61", size = 1953141, upload-time = "2026-09-30T07:59:33.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/fd/dafeeeae019969dab168d20b93efe9b62f34aa30acaed841662897f02a41/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e66c0aef0a32f8d8d86cfdfa84d132ab14a651ffdbc4d0c63e6215e5d1fe73ee", size = 1082192, upload-time = "2026-09-30T07:59:35.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/05/62982574024283afc246f743f760d2df1230fa40c353f5577ffd7733c06a/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25294f8eb74e4006ff4e78588616924e40c9c8ba8f33444154ad4616adcbe041", size = 1103067, upload-time = "2026-09-30T07:59:38.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/63/aa0022807dc71828cba605111814f31fa09ff5a18da0c6a3729a9571fde2/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01480e703423f981bfedfb1fcf32caad37a67c706e5b228e90dade9f97cca33b", size = 1290942, upload-time = "2026-09-30T07:59:40.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/8c/a59de72336786189d43bf87c2c3532f6a9c19efb323d24a88e425b864dff/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b741a54351db58642fe310f193dd27db6ef67fe75ab5d0b6b0e25d805ecb561", size = 1133036, upload-time = "2026-09-30T07:59:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/95/9c5fefeb1dc14ac654cab67210ce37d09e1ae88a615f79c560d4cc033597/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:507adbe13e15d0ea871349156cc619e7c9797789098c0f70547f5bc21387a9cf", size = 1089400, upload-time = "2026-09-30T07:59:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/40/f7e69b241c492051304d5310d65ab867a7e0d2e359fa5675ee2304ecadbb/pycrdt-0.14.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8dabcd216adedee5a6fa5c2175d8a377831eeafe5b68f598939467b3ba51c5b4", size = 1221648, upload-time = "2026-09-30T07:59:46.568Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue