diff --git a/CHANGELOG.md b/CHANGELOG.md index ce66178e1c..a35c62b828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.2] - 2026-10-14 + +### Added + +- 📑 **MinerU 4.x servers.** The MinerU document engine now works with self-hosted MinerU 4.x servers, where every document failed with a 404 because 4.x removed the old parsing interface; 3.x servers work as before, 4.x servers take a tier (flash, basic, standard or advanced) and enable_ocr in the MinerU parameters while language and model_version stay 3.x only, and images that 4.x returns inside the document text are left out of the indexed content. [#32422](https://github.com/open-webui/open-webui/pull/32422), [Commit](https://github.com/open-webui/open-webui/commit/81f5b8adf80e49bd2213390b59dc867074c3738e), [#31516](https://github.com/open-webui/open-webui/issues/31516) +- 🤝 **Keep both changes when comparing.** The Compare revision view now has a Keep both button that puts the original and the proposed text of the selected change into the proposed revision, original first, so you no longer have to pick one side. [Commit](https://github.com/open-webui/open-webui/commit/97b7ed73c84767611d3a26d594c36f63619d92ea) +- 📚 **Open attached knowledge from its preview.** Clicking the name in the preview of an attached knowledge collection now opens that collection in the workspace, and file and link names are real links that open in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/40bf4a1b99aa0aae316095eff100c07570c542da) +- 🏷️ **Model ID in custom headers.** Custom headers on connections and tool servers can now use {{MODEL_ID}}, which holds the ID of the model handling the chat request, the workspace model's own ID for a workspace model and each model's own ID in a multi-model chat, so downstream services can route or log usage per model. [#32426](https://github.com/open-webui/open-webui/pull/32426), [Commit](https://github.com/open-webui/open-webui/commit/bae22752d3995f573a373ee1ae4735b746d952a9), [#30006](https://github.com/open-webui/open-webui/issues/30006) +- 🧾 **Requested model in the audit log.** Audit log entries for chat and completion requests, including the OpenAI, Ollama and Anthropic compatible endpoints, now carry a model_id field with the model the client asked for at every AUDIT_LOG_LEVEL, where the model only appeared in the request body and went missing when MAX_BODY_LOG_SIZE cut a long prompt; other requests log it as null. [#32429](https://github.com/open-webui/open-webui/pull/32429), [Commit](https://github.com/open-webui/open-webui/commit/fb3d92a6ef56b5a8a6820f615f6e6294c44eaad7), [#30097](https://github.com/open-webui/open-webui/issues/30097) +- 🔐 **Custom TLS certificate for Milvus.** Open WebUI can now connect to a Milvus server with a self-signed or company-issued TLS certificate by pointing the new "MILVUS_SERVER_PEM_PATH" environment variable at the certificate file, in normal and multi-tenancy mode. [#32427](https://github.com/open-webui/open-webui/pull/32427), [Commit](https://github.com/open-webui/open-webui/commit/3bc82bb2c110fc3ed7064e6107a4731b8d9ab5ae), [#31632](https://github.com/open-webui/open-webui/issues/31632) +- 🧭 **call_tool for Tool Search.** With Tool Search on, the model now gets a new call_tool tool and runs a tool it found with search_tools through it, passing the tool's name and its arguments, instead of calling the deferred tool directly by a name that is not in the request's tool list, which strict providers rejected; approvals and other tool behavior stay the same. [Commit](https://github.com/open-webui/open-webui/commit/7b47c3b22ce983d57f4a13073748a935c4614889), [#32243](https://github.com/open-webui/open-webui/issues/32243) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. + +### Fixed + +- 🔏 **Channel message metadata kept server side.** Channel messages posted or edited through the API no longer take their metadata from the request, so a user can no longer make a message appear to come from a channel webhook or a model. [#32421](https://github.com/open-webui/open-webui/pull/32421), [Commit](https://github.com/open-webui/open-webui/commit/44953df70ed758a6321f7a2e139c93aca29129f1) +- 👮 **SCIM cannot delete admins.** An identity provider can no longer delete administrator accounts, including the first admin, through a SCIM delete request, which now answers with 403 and leaves the account in place, so a sync that prunes unassigned users can no longer leave the instance without an administrator; admins are removed in the admin panel. [#32415](https://github.com/open-webui/open-webui/pull/32415), [Commit](https://github.com/open-webui/open-webui/commit/6276e81cec38f5c5cce067e4be905c5710c2d7d6), [#27662](https://github.com/open-webui/open-webui/issues/27662) +- 🗝️ **Collection names checked as stored.** Retrieval access checks now compare collection names the way vector stores resolve them, ignoring letter case and treating hyphens and underscores alike, so a name written differently can no longer pass the check as something other than the collection that is then read. [#32435](https://github.com/open-webui/open-webui/pull/32435), [Commit](https://github.com/open-webui/open-webui/commit/30f3c49fb02c22269b42fe3b310209926ee7e8f9) +- 📱 **Voice on Android Chrome.** Voice calls and dictation on Android Chrome no longer go silent or hang on Listening after about ten uses until the page is reloaded, since the microphone level display now releases its audio output after each turn. [#32417](https://github.com/open-webui/open-webui/pull/32417), [Commit](https://github.com/open-webui/open-webui/commit/f35b1993271e6f0cf1ffc55375d634a65c9e0ab1), [#29969](https://github.com/open-webui/open-webui/issues/29969) +- 🖥️ **Per chat terminal file actions.** With the Open Terminal chat context set to Per chat, opening HTML files, running notebooks, saving edited files, creating new files, deleting several selected files and downloading a folder or selection as a zip now use the chat's own workspace, where HTML files showed "File not found" and saved files never appeared in the chat. [#32418](https://github.com/open-webui/open-webui/pull/32418), [Commit](https://github.com/open-webui/open-webui/commit/77fd8aea613c42e0d590804c7f9fdcf6f5f4af02), [#29965](https://github.com/open-webui/open-webui/issues/29965) +- 🧮 **Memory reindex rate limits.** Reindexing memories, from the admin Reindex Knowledge and Memory Vectors button, a user resetting their memory vectors or the automatic reindex after an embedding model change, now follows the Embedding Concurrent Requests limit and batch size, where it sent one request per memory at once and rate-limited providers failed most of them with 429 errors. [#32420](https://github.com/open-webui/open-webui/pull/32420), [Commit](https://github.com/open-webui/open-webui/commit/8bddf9bc62c3ffcaa3e6433bc5ac0f1d04a94421), [#32212](https://github.com/open-webui/open-webui/issues/32212) +- ⏰ **Automation run results.** An automation run is now recorded as successful only after its reply finishes, and a reply that fails or is cancelled is recorded as an error that links to the chat, where runs counted as successful as soon as the reply started. [Commit](https://github.com/open-webui/open-webui/commit/de446952c2db1168dca5c6e3da6d961e6f55a8eb) +- 📜 **Long polling kept out of the audit log.** With websocket support turned off, every socket.io long polling request is no longer written to the audit log as its own entry, which flooded it and could not be filtered with AUDIT_EXCLUDED_PATHS, so the audit log looks the same with either transport. [#32425](https://github.com/open-webui/open-webui/pull/32425), [Commit](https://github.com/open-webui/open-webui/commit/209b15b9633c63ce1b7cf3c1fdc6e032d67978c6), [#31871](https://github.com/open-webui/open-webui/issues/31871) +- 📎 **Sources of approved tools.** With tool approval set to ask, a knowledge or file tool the user approved now saves and shows its sources below the reply, where they were lost after approval. [#32439](https://github.com/open-webui/open-webui/pull/32439), [Commit](https://github.com/open-webui/open-webui/commit/5fde0485a5adda046d283416a5c4c7a543d7a5fe), [#32436](https://github.com/open-webui/open-webui/issues/32436) +- 🗂️ **Sidebar hover previews.** Moving the pointer from one sidebar folder to the next no longer leaves the first folder's preview open or keeps the next one from opening, and folder and chat previews now also open when the pointer rests on the padding around a name. [#32438](https://github.com/open-webui/open-webui/pull/32438), [Commit](https://github.com/open-webui/open-webui/commit/5e2748a03edd2459d07fcfea168daf740092bf94), [#32437](https://github.com/open-webui/open-webui/issues/32437) + +### Changed + +- 🔖 **Valkey connections tagged as Open WebUI.** Open WebUI's Valkey vector store connections now report their library name as GlidePySync(open-webui), so operators of a shared Valkey or ElastiCache can tell its traffic apart; this needs valkey-glide-sync 2.5.2 or newer, so installs that use VECTOR_DB=valkey with an older manually installed version must upgrade it, or startup fails. [#32428](https://github.com/open-webui/open-webui/pull/32428), [Commit](https://github.com/open-webui/open-webui/commit/980225e5823808de7ace86a8ee73a0b880b37023), [#31654](https://github.com/open-webui/open-webui/issues/31654) + ## [0.12.1] - 2026-10-14 ### Added diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index d3f77d3472..210eafcb71 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -603,6 +603,7 @@ if VECTOR_DB == 'mariadb-vector': MILVUS_URI = os.getenv('MILVUS_URI', f'{DATA_DIR}/vector_db/milvus.db') MILVUS_DB = os.getenv('MILVUS_DB', 'default') MILVUS_TOKEN = os.getenv('MILVUS_TOKEN', None) +MILVUS_SERVER_PEM_PATH = os.getenv('MILVUS_SERVER_PEM_PATH', '') MILVUS_INDEX_TYPE = os.getenv('MILVUS_INDEX_TYPE', 'HNSW') MILVUS_METRIC_TYPE = os.getenv('MILVUS_METRIC_TYPE', 'COSINE') MILVUS_HNSW_M = int(os.getenv('MILVUS_HNSW_M', '16')) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 863d8be4a0..c9540eeb9d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1152,6 +1152,7 @@ async def chat_completion( await get_all_models(request, user=user) model_id = form_data.get('model', None) + request.state.model_id = model_id model_item = form_data.pop('model_item', {}) tasks = form_data.pop('background_tasks', None) @@ -1813,7 +1814,7 @@ async def chat_completion( # Fan out: one task per model if metadata.get('session_id') and metadata.get('chat_id'): - task_ids = [] + chat_tasks = {} subagent_results = [] is_internal = getattr(request.state, 'internal', False) is True chat_id = metadata['chat_id'] @@ -1869,13 +1870,13 @@ async def chat_completion( subagent_results.append(await process) continue - task_id, _ = await create_task( + task_id, task = await create_task( request.app.state.redis, process, id=chat_id, task_id=per_model_metadata['task_id'], ) - task_ids.append(task_id) + chat_tasks[task_id] = task if is_internal: return { @@ -1886,7 +1887,7 @@ async def chat_completion( } # Emit chat:active=true - if task_ids: + if chat_tasks: event_emitter = await get_event_emitter( {**metadata, 'message_id': message_ids[0]['message_id']}, update_db=False, @@ -1895,9 +1896,13 @@ async def chat_completion( folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id) await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}}) + # Automation chat runs must finish before their caller records success. + if metadata.get('automation_id') and is_saved_chat_id(chat_id): + await asyncio.gather(*chat_tasks.values()) + return { 'status': True, - 'task_ids': task_ids, + 'task_ids': list(chat_tasks), 'chat_id': chat_id, } else: @@ -2055,6 +2060,7 @@ async def generate_messages( Anthropic's x-api-key header (via middleware translation). """ requested_model = form_data.get('model', '') + request.state.model_id = requested_model input_tokens = None try: input_tokens = await openai.count_anthropic_tokens(request, form_data, user) diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py index 4459515dc2..0a3017cbd1 100644 --- a/backend/open_webui/retrieval/loaders/mineru.py +++ b/backend/open_webui/retrieval/loaders/mineru.py @@ -1,5 +1,6 @@ import logging import os +import re import tempfile import time import zipfile @@ -11,6 +12,10 @@ from langchain_core.documents import Document log = logging.getLogger(__name__) +# The V1 API embeds every image as a base64 data URI, which is not text worth indexing +MARKDOWN_IMAGE = re.compile(r'!\[([^\[\]\n]*)\]\(data:[^()\s]*\)') +HTML_IMAGE = re.compile(r']*>', re.IGNORECASE) + class MinerULoader: """ @@ -44,6 +49,7 @@ class MinerULoader: self.enable_table = self.params.get('enable_table', True) self.language = self.params.get('language', 'en') self.model_version = self.params.get('model_version', 'pipeline') + self.tier = self.params.get('tier') self.page_ranges = self.params.pop('page_ranges', '') @@ -63,6 +69,8 @@ class MinerULoader: try: if self.api_mode == 'cloud': return self._load_cloud_api() + elif self._is_v1_api(): + return self._load_v1_api() else: return self._load_local_api() except Exception as e: @@ -175,6 +183,116 @@ class MinerULoader: return [Document(page_content=markdown_content, metadata=metadata)] + def _is_v1_api(self) -> bool: + """ + Detect MinerU 4.x, whose V1 API replaced the /file_parse endpoint of 3.x. + """ + try: + return requests.get(f'{self.api_url}/v1/health', timeout=10).ok + except requests.RequestException: + return False + + def _load_v1_api(self) -> List[Document]: + """ + Load document using the MinerU 4.x V1 API (asynchronous). + Uploads the file, submits a parse job, polls it and downloads the markdown. + """ + log.info('Using MinerU V1 API at %s', self.api_url) + + filename = os.path.basename(self.file_path) + + upload = self._request_v1_api( + 'POST', + '/v1/uploads', + json={ + 'filename': filename, + 'bytes': os.path.getsize(self.file_path), + 'mime_type': 'application/octet-stream', + }, + ).json() + with open(self.file_path, 'rb') as f: + self._request_v1_api('PUT', f'/v1/uploads/{upload["id"]}/content', data=f) + upload = self._request_v1_api('POST', f'/v1/uploads/{upload["id"]}/complete').json() + + file_entry = {'source': {'type': 'file_id', 'file_id': upload['file']['id']}} + if self.page_ranges and filename.lower().endswith('.pdf'): + file_entry['page_range'] = self.page_ranges + + # The job rejects unknown fields, so the 3.x params are not passed through + job = self._request_v1_api( + 'POST', + '/v1/parse/jobs', + json={ + 'files': [file_entry], + 'tier': self.tier, + 'ocr_mode': 'ocr' if self.enable_ocr else 'auto', + }, + ).json() + + log.info('Polling MinerU V1 job: %s', job['job_id']) + deadline = time.monotonic() + self.timeout if self.timeout else None + while job['status'] in ['queued', 'running']: + if deadline and time.monotonic() > deadline: + raise HTTPException( + status.HTTP_504_GATEWAY_TIMEOUT, + detail=f'MinerU processing timed out after {self.timeout} seconds', + ) + time.sleep(2) + job = self._request_v1_api('GET', f'/v1/parse/jobs/{job["job_id"]}').json() + + file_result = job['files'][0] + if job['status'] != 'completed': + error = file_result.get('error') or {} + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f'MinerU processing {job["status"]}: {error.get("message", "Unknown error")}', + ) + + markdown_id = file_result['output_files']['markdown']['file_id'] + markdown_content = self._request_v1_api('GET', f'/v1/files/{markdown_id}/content').content.decode('utf-8') + markdown_content = HTML_IMAGE.sub('', MARKDOWN_IMAGE.sub(r'\1', markdown_content)) + + if not markdown_content: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail='MinerU returned empty markdown content', + ) + + log.info('Successfully parsed document with MinerU V1 API: %s', filename) + + metadata = { + 'source': filename, + 'api_mode': 'local', + 'tier': job['tier'], + 'version': (file_result.get('parse') or {}).get('parser_version', 'unknown'), + } + + return [Document(page_content=markdown_content, metadata=metadata)] + + def _request_v1_api(self, method: str, path: str, **kwargs) -> requests.Response: + """ + Send one request to the MinerU V1 API and raise an HTTPException on failure. + """ + try: + response = requests.request(method, f'{self.api_url}{path}', timeout=self.timeout, **kwargs) + response.raise_for_status() + except requests.Timeout: + raise HTTPException( + status.HTTP_504_GATEWAY_TIMEOUT, + detail='MinerU V1 API request timed out', + ) + except requests.HTTPError as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f'MinerU V1 API request failed: {e} - {e.response.text}', + ) + except Exception as e: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f'Error calling MinerU V1 API: {str(e)}', + ) + return response + def _load_cloud_api(self) -> List[Document]: """ Load document using Cloud API (asynchronous). diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 61cca0a441..e5a61d7ce5 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1318,20 +1318,22 @@ async def filter_accessible_collections( validated = set() for name in safe_names: - if name == 'knowledge-bases': + # Some vector stores fold case and _/- together, so authorize a name as the collection it can resolve to. + canonical = name.lower().replace('_', '-') + if canonical == 'knowledge-bases': # System meta-collection — never exposed to non-admins. continue - elif name.startswith('file-'): - file_id = name[len('file-') :] + elif canonical.startswith('file-'): + file_id = canonical[len('file-') :] if await has_access_to_file(file_id=file_id, access_type=access_type, user=user): validated.add(name) - elif name.startswith('user-memory-'): - if name == f'user-memory-{user.id}': + elif canonical.startswith('user-memory-'): + if canonical == f'user-memory-{user.id}': validated.add(name) - elif name.startswith('web-search-'): + elif canonical.startswith('web-search-'): # Ephemeral per-query collections, owner-bound: process_web_search mints # them as web-search-{user.id}-, so only the creator may read/write. - if name.startswith(f'web-search-{user.id}-'): + if canonical.startswith(f'web-search-{user.id}-'): validated.add(name) else: # May be a knowledge-base ID or a legacy/ephemeral collection. @@ -1340,9 +1342,9 @@ async def filter_accessible_collections( # ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS: # False (default) — deny (closes the unscoped namespace) # True — allow (preserves legacy behaviour) - if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type): + if await Knowledges.check_access_by_user_id(canonical, user.id, permission=access_type): validated.add(name) - elif ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS and not await Knowledges.get_knowledge_by_id(name): + elif ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS and not await Knowledges.get_knowledge_by_id(canonical): # Not a KB at all — legacy/ephemeral collection, allow validated.add(name) return validated diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 2eefb8f67f..b0787e145c 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -17,6 +17,7 @@ from open_webui.config import ( MILVUS_INDEX_TYPE, MILVUS_IVF_FLAT_NLIST, MILVUS_METRIC_TYPE, + MILVUS_SERVER_PEM_PATH, MILVUS_TOKEN, MILVUS_URI, ) @@ -244,9 +245,11 @@ class MilvusClient(VectorDBBase): def __init__(self): self.collection_prefix = 'open_webui' if MILVUS_TOKEN is None: - self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, server_pem_path=MILVUS_SERVER_PEM_PATH) else: - self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN) + self.client = Client( + uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN, server_pem_path=MILVUS_SERVER_PEM_PATH + ) if ENABLE_DB_MIGRATIONS: collections = { collection_name_full.removesuffix(BM25_STAGING_SUFFIX) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index b6306b88a5..cca6a44415 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -14,6 +14,7 @@ from open_webui.config import ( MILVUS_INDEX_TYPE, MILVUS_IVF_FLAT_NLIST, MILVUS_METRIC_TYPE, + MILVUS_SERVER_PEM_PATH, MILVUS_TOKEN, MILVUS_URI, ) @@ -76,7 +77,9 @@ class MilvusClient(VectorDBBase): def __init__(self): # Milvus collection names can only contain numbers, letters, and underscores. self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_') - self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB) + self.client = Client( + uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB, server_pem_path=MILVUS_SERVER_PEM_PATH + ) # Main collection types for multi-tenancy self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories' diff --git a/backend/open_webui/retrieval/vector/dbs/valkey.py b/backend/open_webui/retrieval/vector/dbs/valkey.py index bf202a22c0..c315a0a5fb 100644 --- a/backend/open_webui/retrieval/vector/dbs/valkey.py +++ b/backend/open_webui/retrieval/vector/dbs/valkey.py @@ -56,7 +56,7 @@ def _import_glide(): ) except ImportError as e: raise ImportError( - 'valkey-glide-sync is required when VECTOR_DB=valkey. Install it with: pip install valkey-glide-sync==2.3.1' + 'valkey-glide-sync is required when VECTOR_DB=valkey. Install it with: pip install valkey-glide-sync==2.5.2' ) from e return { 'Batch': Batch, @@ -193,6 +193,7 @@ class ValkeyClient(VectorDBBase): database_id=db if db else None, request_timeout=5000, client_name='open_webui_vector_store_client', + client_info_tag='open-webui', ) try: self.client = GlideClient.create(config) @@ -206,6 +207,7 @@ class ValkeyClient(VectorDBBase): database_id=db if db else None, request_timeout=10000, # 10s — HNSW indexing can take 1-4s per vector client_name='open_webui_vector_store_batch_client', + client_info_tag='open-webui', ) try: self.batch_client = GlideClient.create(batch_config) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 633ceb1d70..6ef96c8062 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1462,6 +1462,8 @@ async def post_new_message( db: AsyncSession = Depends(get_async_session), ): await check_channels_access(request, user) + # Message metadata is set by the server only. + form_data.meta = None try: message, channel = await new_message_handler(request, id, form_data, user, db) @@ -1769,6 +1771,7 @@ async def update_message_by_id( if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + form_data.meta = None try: await Messages.update_message_by_id(message_id, form_data, db=db) message = await Messages.get_message_by_id(message_id, db=db) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 2ed11e6094..af95faca73 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging from typing import Literal @@ -142,15 +141,11 @@ async def reindex_memory_vectors_for_user( if not memories: return 0 - vectors = await asyncio.gather( - *[ - request.app.state.EMBEDDING_FUNCTION( - memory_vector_text(memory.content, memory.path), - prefix=RAG_EMBEDDING_CONTENT_PREFIX, - user=user, - ) - for memory in memories - ] + # One list call so batching and the concurrency limit apply. + vectors = await request.app.state.EMBEDDING_FUNCTION( + [memory_vector_text(memory.content, memory.path) for memory in memories], + prefix=RAG_EMBEDDING_CONTENT_PREFIX, + user=user, ) await ASYNC_VECTOR_DB_CLIENT.upsert( @@ -488,10 +483,9 @@ async def reset_memory_from_vector_db( """Reset user's memory vector embeddings. CRITICAL: We intentionally do NOT use Depends(get_async_session) here. - This endpoint generates embeddings for ALL user memories in parallel using - asyncio.gather(). A user with 100 memories would trigger 100 embedding API - calls simultaneously. With a session held, this could block a connection - for MINUTES, completely exhausting the connection pool. + This endpoint generates embeddings for ALL user memories. With a session + held, this could block a connection for MINUTES, completely exhausting the + connection pool. """ await check_memories_permission(user) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index fd5bf331af..2d2b0fe397 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1033,6 +1033,7 @@ async def generate_completion( if not await Config.get('ollama.enable'): raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + request.state.model_id = form_data.model await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) await validate_ollama_backend_idx(request, form_data.model, url_idx, user) @@ -1164,6 +1165,8 @@ async def generate_chat_completion( payload.pop('metadata', None) model_id = payload['model'] + if not hasattr(request.state, 'model_id'): + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) if model_info is not None: @@ -1266,6 +1269,7 @@ async def generate_openai_completion( payload = form_data.model_dump(exclude_none=True, exclude=['metadata']) model_id = form_data.model + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) if model_info is not None: if model_info.base_model_id: @@ -1370,6 +1374,7 @@ async def generate_openai_chat_completion( payload = form_data.model_dump(exclude_none=True, exclude=['metadata']) model_id = form_data.model + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) if model_info is not None: if model_info.base_model_id: @@ -1427,6 +1432,7 @@ async def generate_anthropic_messages( payload = {**form_data} model_id = payload.get('model', '') + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) if model_info: @@ -1485,6 +1491,7 @@ async def generate_responses( payload = form_data.model_dump() model_id = form_data.model + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) if model_info: diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 8548a8ab0d..ee96e53c99 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -42,7 +42,7 @@ from open_webui.utils.anthropic import ANTHROPIC_VERSION, get_anthropic_models, from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_headers_and_cookies, include_user_info_headers from open_webui.utils.json_codec import JSONCodec -from open_webui.utils.misc import convert_logit_bias_input_to_json +from open_webui.utils.misc import convert_logit_bias_input_to_json, get_provider_function from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.payload import ( apply_model_params_to_body_openai, @@ -1190,6 +1190,8 @@ def _normalize_stored_item(item: dict) -> dict: if allowed is None: # Unknown type — pass through as-is (e.g. reasoning, extension items). return item + if item_type == 'function_call': + item = {**item, **get_provider_function(item)} return {k: v for k, v in item.items() if k in allowed} @@ -1432,6 +1434,9 @@ async def generate_chat_completion( metadata = payload.pop('metadata', None) model_id = form_data.get('model') + # keep the model the client asked for when reached from /api/chat/completions + if not hasattr(request.state, 'model_id'): + request.state.model_id = model_id model_info = await Models.get_model_by_id(model_id) # Check model info and override the payload @@ -1819,6 +1824,7 @@ async def responses( idx = 0 model_id = form_data.model + request.state.model_id = model_id # Enforce per-model access control await check_model_access(user, await Models.get_model_by_id(model_id), BYPASS_MODEL_ACCESS_CONTROL) @@ -1942,6 +1948,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): idx = 0 model_id = payload.get('model') if isinstance(payload, dict) else None + request.state.model_id = model_id if model_id: models = request.app.state.OPENAI_MODELS if not models or model_id not in models: diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 886ec521a9..9b290c9ee4 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -870,6 +870,13 @@ async def delete_user( detail=f'User {user_id} not found', ) + # An IdP prune must not lock the instance out by deleting an admin. + if user.role == 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + success = await Auths.delete_auth_by_id(user_id, db=db) if not success: raise HTTPException( diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index b3eb6c68a1..297b3b055c 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3837,10 +3837,12 @@ async def search_tools( """ Search the tools listed in and return their full definitions. Pass the exact tool name when you already know it. + Invoke a result with call_tool(tool_name, params), where params is a JSON-encoded object + matching the tool's parameters. Do not call deferred tools directly by name. :param query: Keywords describing the capability you need (e.g. "jira create issue"), or an exact tool name :param count: Maximum number of results to return (default: 5, max: 20) - :return: JSON with the definitions of the matching tools, which can then be called by name + :return: JSON with the definitions of the matching tools, which can then be invoked through call_tool """ from open_webui.utils.tool_search import search_deferred_tools diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index aace99dd54..e5ff7564c1 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -43,6 +43,7 @@ class AuditLogEntry: request_uri: str user_agent: Optional[str] = None source_ip: Optional[str] = None + model_id: Optional[str] = None # `Request` audit level properties request_object: Any = None # `Request Response` level @@ -254,6 +255,9 @@ class AuditLoggingMiddleware: if request.method not in self.audited_methods: return True + if request.url.path.startswith('/ws/socket.io'): + return True + path = request.url.path.lower() for endpoint in self.ALWAYS_LOG_ENDPOINTS: if path.startswith(endpoint): @@ -309,6 +313,7 @@ class AuditLoggingMiddleware: response_status_code=context.metadata.get('response_status_code', None), source_ip=request.client.host if request.client else None, user_agent=request.headers.get('user-agent'), + model_id=getattr(request.state, 'model_id', None), request_object=request_body, response_object=response_body, ) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 0bda3a3d1b..ce4c9af3ec 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -357,6 +357,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: session_id + chat_id + message_id → async task → pipeline handles everything (filters, model params, knowledge/RAG, tools, DB saves, webhooks). """ + chat = None try: user = await Users.get_user_by_id(automation.user_id) if not user: @@ -521,6 +522,12 @@ async def execute_automation(app, automation: AutomationModel) -> None: request = _build_request(app, token=token) await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + reply = await Chats.get_message_by_id_and_message_id(chat.id, assistant_msg_id) + if reply and reply.get('error'): + raise ValueError(str(reply['error']['content'])) + if not reply or not reply.get('done'): + raise ValueError('Automation reply did not complete') + # Notify user from open_webui.socket.main import sio @@ -544,16 +551,18 @@ async def execute_automation(app, automation: AutomationModel) -> None: data={'name': automation.name, 'chat_id': chat.id}, ) - except Exception as e: + except (Exception, asyncio.CancelledError) as e: log.exception(f'Automation {automation.id} failed') - error = str(e)[:4000] - await _record_run(automation.id, 'error', error=error) + error = 'Automation reply was cancelled' if isinstance(e, asyncio.CancelledError) else str(e)[:4000] + await _record_run(automation.id, 'error', chat_id=chat.id if chat else None, error=error) await publish_event( app, EVENTS.AUTOMATION_RUN_FAILED, subject_id=automation.id, - data={'name': automation.name, 'error': error}, + data={'name': automation.name, 'error': error, **({'chat_id': chat.id} if chat else {})}, ) + if isinstance(e, asyncio.CancelledError): + raise #################### diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 8b73ca40d8..00262df0f1 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -144,6 +144,7 @@ def parse_custom_headers( '{{FILE_NAME}}': metadata.get('file_name', '') or '', '{{FILE_CONTENT_TYPE}}': metadata.get('file_content_type', '') or '', '{{TASK}}': metadata.get('task', '') or '', + '{{MODEL_ID}}': metadata.get('model_id', '') or '', '{{USER_ID}}': (user.id if user else '') or '', '{{USER_NAME}}': (user.name.strip() if user else '') or '', '{{USER_EMAIL}}': (user.email.strip() if user else '') or '', diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index f54b7f9840..3ab517f396 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -156,6 +156,7 @@ def file_format(record: 'Record'): 'response_status_code': record['extra'].get('response_status_code', 0), 'source_ip': record['extra'].get('source_ip', ''), 'user_agent': record['extra'].get('user_agent', ''), + 'model_id': record['extra'].get('model_id', ''), 'request_object': record['extra'].get('request_object', b''), 'response_object': record['extra'].get('response_object', b''), 'extra': record['extra'].get('extra', {}), diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 0afeffa277..ea4b4e7681 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -145,7 +145,7 @@ from open_webui.utils.task import ( tools_function_calling_generation_template, ) from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls -from open_webui.utils.tool_search import apply_tool_search +from open_webui.utils.tool_search import apply_tool_search, resolve_tool_call from open_webui.utils.tools import ( connect_mcp_server, get_attached_knowledge, @@ -441,6 +441,9 @@ def _split_tool_calls( return expanded +CITATION_TOOL_NAMES = ('fetch_url', 'view_file', 'view_knowledge_file', 'query_knowledge_files', 'query_chat_files') + + def get_citation_source_from_tool_result( tool_name: str, tool_params: dict, tool_result: str, tool_id: str = '' ) -> list[dict]: @@ -3290,7 +3293,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata['tools'] = tools_dict if metadata.get('params', {}).get('function_calling') != 'legacy': - deferred = await apply_tool_search(form_data, metadata, tools_dict) + deferred = await apply_tool_search(form_data, metadata, tools_dict, inlet_filter_tools) # If the function calling is native, then call the tools function calling handler form_data['tools'] = [ @@ -3438,6 +3441,9 @@ async def build_chat_response_context(request, form_data, user, model, metadata, async def execute_tool_call(form_data, metadata, event_caller, tool_call): + error = resolve_tool_call(tool_call, metadata.get('tools', {})) + if error: + return {}, error, None, None, False name = tool_call.get('function', {}).get('name', '') tool_args = tool_call.get('function', {}).get('arguments', '{}') params = {} @@ -3619,6 +3625,14 @@ async def resume_tool_calls(request, form_data, user, model, metadata, message) request, name, result, tool_type, direct_tool, metadata, user ) await terminal_event_handler(name, params, result, event_emitter) + citations_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( + 'citations', True + ) + if event_emitter and citations_enabled and name in CITATION_TOOL_NAMES and result: + for source in get_citation_source_from_tool_result( + tool_name=name, tool_params=params, tool_result=result, tool_id=tool.get('tool_id', '') + ): + await event_emitter({'type': 'source', 'data': source}) content = tool_result_content(result) item['arguments'] = tool_call.get('function', {}).get('arguments', '{}') output_parts = [{'type': 'input_text', 'text': content}] @@ -5981,7 +5995,41 @@ async def streaming_chat_response_handler(response, ctx): ): tool_call_iterations += 1 - response_tool_calls = tool_calls.pop(0) + # Resolve dispatch before approvals and special handling such as ask_user. + response_tool_calls = [] + output_by_call_id = { + item.get('call_id'): item for item in output if item.get('type') == 'function_call' + } + for tool_call in tool_calls.pop(0): + error = resolve_tool_call(tool_call, metadata.get('tools', {})) + call_id = tool_call.get('id') or output_id('fc') + tool_call['id'] = call_id + function = tool_call.get('function', {}) + item = output_by_call_id.get(call_id) + if item is None: + item = {'type': 'function_call', 'id': call_id, 'call_id': call_id} + output.append(item) + output_by_call_id[call_id] = item + item.update( + name=function.get('name', ''), + arguments=function.get('arguments', '{}'), + status='failed' if error else item.get('status', 'in_progress'), + ) + if 'provider_function' in tool_call: + item['provider_function'] = tool_call['provider_function'] + if error: + output.append( + { + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': call_id, + 'output': [{'type': 'input_text', 'text': error}], + 'status': 'failed', + } + ) + else: + response_tool_calls.append(tool_call) + ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id) if ask_user_error: response_tool_calls = [ @@ -6006,24 +6054,6 @@ async def streaming_chat_response_handler(response, ctx): if item.get('name') == 'ask_user' and item.get('status') == 'pending': item['status'] = 'in_progress' - # Append function_call items for each tool call - # (Responses API already has them from streaming, so skip duplicates) - existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'} - for tc in response_tool_calls: - call_id = tc.get('id', '') - if call_id not in existing_call_ids: - func = tc.get('function', {}) - output.append( - { - 'type': 'function_call', - 'id': call_id or output_id('fc'), - 'call_id': call_id, - 'name': func.get('name', ''), - 'arguments': func.get('arguments', '{}'), - 'status': 'in_progress', - } - ) - tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full') if ( response_tool_calls @@ -6119,18 +6149,7 @@ async def streaming_chat_response_handler(response, ctx): ) # Extract citation sources from tool results - if ( - citations_enabled - and tool_function_name - in [ - 'fetch_url', - 'view_file', - 'view_knowledge_file', - 'query_knowledge_files', - 'query_chat_files', - ] - and tool_result - ): + if citations_enabled and tool_function_name in CITATION_TOOL_NAMES and tool_result: try: citation_sources = get_citation_source_from_tool_result( tool_name=tool_function_name, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 9cc979117a..b6be078bda 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -336,6 +336,18 @@ def get_reasoning_details(payload: dict): return payload.get('reasoning_details') or provider_details +def get_provider_function(item: dict) -> dict: + """Restore the model's call when OWUI displays and executes a resolved target instead.""" + function = item.get('provider_function') + if not isinstance(function, dict): + function = item + arguments = function.get('arguments', '{}') + return { + 'name': function.get('name', ''), + 'arguments': arguments if isinstance(arguments, str) else JSONCodec.dumps(arguments), + } + + def convert_output_to_messages( output: list, raw: bool = False, @@ -502,18 +514,11 @@ def convert_output_to_messages( continue # Collect tool calls to batch into assistant message - arguments = item.get('arguments', '{}') - # Ensure arguments is always a JSON string - if not isinstance(arguments, str): - arguments = JSONCodec.dumps(arguments) pending_tool_calls.append( { 'id': item.get('call_id', ''), 'type': 'function', - 'function': { - 'name': item.get('name', ''), - 'arguments': arguments, - }, + 'function': get_provider_function(item), } ) diff --git a/backend/open_webui/utils/tool_search.py b/backend/open_webui/utils/tool_search.py index 8bff23e62c..96dbdca6f8 100644 --- a/backend/open_webui/utils/tool_search.py +++ b/backend/open_webui/utils/tool_search.py @@ -1,7 +1,7 @@ """ Tool search: large tool schemas are left out of the provider `tools` array and listed by name in an `` system prompt block. `search_tools` returns the full definitions of matching tools, -which the model then calls by name. The `tools` array never changes during a chat, so prompt caches hold. +which the model invokes through `call_tool`. Discovery does not change the provider `tools` array. """ import fnmatch @@ -14,6 +14,7 @@ from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import add_or_update_system_message SEARCH_TOOL_NAME = 'search_tools' +CALL_TOOL_NAME = 'call_tool' MANIFEST_DESCRIPTION_MAX_CHARS = 100 MAX_SEARCH_COUNT = 20 DEFAULT_SEARCH_COUNT = 5 @@ -66,15 +67,20 @@ def build_deferred_tools_manifest(tools_dict: dict[str, dict], deferred: list[st '\n' 'The following tools are available but their definitions are not loaded. ' f'To use one, call `{SEARCH_TOOL_NAME}` with a short keyword query or the exact tool name to get its ' - 'definition, then call the tool by name with the parameters it defines. ' + f'definition, then use `{CALL_TOOL_NAME}` with `tool_name` set to its exact name and `params` set ' + 'to a JSON-encoded object containing its arguments. Do not call deferred tools directly. ' + 'For example: call_tool(tool_name="get_weather", params=\'{"city":"Dubai"}\'). ' 'Never tell the user a capability is unavailable without searching first.\n' f'{entries}' ) -async def apply_tool_search(form_data: dict, metadata: dict, tools_dict: dict[str, dict]) -> set[str]: - """Add search_tools and the manifest when tools can be deferred; returns the names to leave out of `tools`.""" - config = SEARCH_TOOL_NAME not in tools_dict and await get_tool_search_config() +async def apply_tool_search( + form_data: dict, metadata: dict, tools_dict: dict[str, dict], extra_tools: list[dict] | None = None +) -> set[str]: + """Add discovery and dispatch when tools can be deferred; return names to omit from provider tools.""" + names = set(tools_dict) | {(tool.get('function') or tool).get('name') for tool in extra_tools or []} + config = not names.intersection({SEARCH_TOOL_NAME, CALL_TOOL_NAME}) and await get_tool_search_config() deferred = select_deferred_tools(tools_dict, config) if config else [] if not deferred: return set() @@ -100,9 +106,64 @@ async def apply_tool_search(form_data: dict, metadata: dict, tools_dict: dict[st 'spec': get_builtin_tool_spec(search_tools), 'type': 'builtin', } + # Middleware resolves this before approvals and execution, preserving the target tool's behavior. + tools_dict[CALL_TOOL_NAME] = { + 'tool_id': f'builtin:{CALL_TOOL_NAME}', + 'type': 'builtin', + 'spec': { + 'name': CALL_TOOL_NAME, + 'description': ( + 'Invoke a tool found by search_tools. Use its exact name and encode its arguments as a JSON ' + 'object string matching the returned definition. Use "{}" for a tool without arguments.' + ), + 'strict': True, + 'parameters': { + 'type': 'object', + 'properties': { + 'tool_name': {'type': 'string', 'description': 'Exact tool name returned by search_tools.'}, + 'params': {'type': 'string', 'description': 'JSON-encoded object of arguments for the tool.'}, + }, + 'required': ['tool_name', 'params'], + 'additionalProperties': False, + }, + }, + } return set(deferred) +def resolve_tool_call(tool_call: dict, tools: dict[str, dict]) -> str | None: + """Unwrap our dispatcher in place, retaining the provider call for history replay. Never execute here.""" + function = tool_call.get('function', {}) + if ( + function.get('name') != CALL_TOOL_NAME + or tools.get(CALL_TOOL_NAME, {}).get('tool_id') != f'builtin:{CALL_TOOL_NAME}' + ): + return None + + try: + arguments = JSONCodec.loads(function.get('arguments', '')) + if not isinstance(arguments, dict) or set(arguments) != {'tool_name', 'params'}: + raise ValueError('call_tool requires tool_name and params.') + name = arguments['tool_name'] + if not isinstance(name, str) or not name.strip(): + raise ValueError('tool_name must be a non-empty string.') + if name in {SEARCH_TOOL_NAME, CALL_TOOL_NAME}: + raise ValueError('call_tool cannot invoke search_tools or itself.') + if name not in tools: + raise ValueError(f'Tool "{name}" is not available. Search for an available tool.') + if not isinstance(arguments['params'], str): + raise ValueError('params must be a JSON-encoded object string.') + params = JSONCodec.loads(arguments['params']) + if not isinstance(params, dict): + raise ValueError('params must decode to a JSON object.') + except (TypeError, ValueError) as error: + return f'Error: {error}' + + tool_call['provider_function'] = dict(function) + tool_call['function'] = {'name': name, 'arguments': JSONCodec.dumps(params)} + return None + + def strip_deferred_tools_manifest(system_prompt: str | None) -> str | None: """Remove the manifest from a system prompt that is reused by a request which builds its own.""" return _MANIFEST_RE.sub('', system_prompt) if system_prompt else system_prompt diff --git a/backend/requirements.txt b/backend/requirements.txt index e26cd08c67..c5c95fa427 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -116,7 +116,7 @@ pgvector==0.4.2 PyMySQL==1.2.0 boto3==1.42.62 # mariadb==1.1.14 should be added if you want to support MariaDB -# valkey-glide-sync==2.3.1 # optional: install manually if VECTOR_DB=valkey +# valkey-glide-sync==2.5.2 # optional: install manually if VECTOR_DB=valkey pymilvus==2.6.14 qdrant-client==1.18.0 diff --git a/package-lock.json b/package-lock.json index 118b0031b3..fe39412956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.12.1", + "version": "0.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.12.1", + "version": "0.12.2", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 724d424a3c..910aa316aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.12.1", + "version": "0.12.2", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 202bf0a352..78f256eff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "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 + # "valkey-glide-sync==2.5.2", # optional: install manually if VECTOR_DB=valkey "pytz==2026.2", diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 12b015a3da..1e821dc173 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -389,7 +389,7 @@ let terminalChatContextPending = false; let terminalChatContextHidden = false; let isPerChatTerminal = false; - $: portPreviewBaseUrl = + $: previewBaseUrl = selectedTerminal && isPerChatTerminal && chatId ? `${selectedTerminal.url}/chats/${encodeURIComponent(chatId)}` : (selectedTerminal?.url ?? ''); @@ -1033,7 +1033,12 @@ // Directories end with '/', downloaded as a ZIP archive const isDir = path.endsWith('/'); const result = isDir - ? await archiveFromTerminal(terminal.url, terminal.key, [path.replace(/\/$/, '')]) + ? await archiveFromTerminal( + terminal.url, + terminal.key, + [path.replace(/\/$/, '')], + chatId ?? undefined + ) : await downloadFileBlob(terminal.url, terminal.key, path, chatId ?? undefined); if (!result) { toast.error($i18n.t('Download failed')); @@ -1218,7 +1223,13 @@ if (!terminal) return; const emptyFile = new File([''], name, { type: 'application/octet-stream' }); - const result = await uploadToTerminal(terminal.url, terminal.key, currentPath, emptyFile); + const result = await uploadToTerminal( + terminal.url, + terminal.key, + currentPath, + emptyFile, + chatId ?? undefined + ); toast[result ? 'success' : 'error']( result ? $i18n.t('File created') : $i18n.t('Failed to create file') ); @@ -1387,7 +1398,12 @@ const paths = [...selectedEntries]; let ok = 0; for (const p of paths) { - const result = await deleteEntry(terminal.url, terminal.key, p.replace(/\/$/, '')); + const result = await deleteEntry( + terminal.url, + terminal.key, + p.replace(/\/$/, ''), + chatId ?? undefined + ); if (result) ok++; } toast[ok > 0 ? 'success' : 'error']( @@ -1415,7 +1431,12 @@ const toastId = toast.loading($i18n.t('Preparing download...')); try { // Archive everything into a single ZIP - const result = await archiveFromTerminal(terminal.url, terminal.key, paths); + const result = await archiveFromTerminal( + terminal.url, + terminal.key, + paths, + chatId ?? undefined + ); if (!result) { toast.error($i18n.t('Download failed')); return; @@ -1907,7 +1928,7 @@ > {#if previewPort !== null} { @@ -1945,7 +1966,7 @@ const DOMPurify = (await import('dompurify')).default; fileOfficeHtml = DOMPurify.sanitize(result.html); }} - baseUrl={selectedTerminal?.url ?? ''} + baseUrl={previewBaseUrl} apiKey={selectedTerminal?.key ?? ''} overlay={overlay || isDraggingHandle} onSave={async (content) => { @@ -1954,7 +1975,13 @@ const fileName = selectedFile.split('/').pop() ?? 'file'; const dir = selectedFile.substring(0, selectedFile.lastIndexOf('/') + 1) || '/'; const file = new File([content], fileName, { type: 'text/plain' }); - const result = await uploadToTerminal(terminal.url, terminal.key, dir, file); + const result = await uploadToTerminal( + terminal.url, + terminal.key, + dir, + file, + chatId ?? undefined + ); toast[result ? 'success' : 'error']( result ? $i18n.t('File saved') : $i18n.t('Failed to save file') ); @@ -2192,7 +2219,7 @@ {#key JSON.stringify([chatId, selectedTerminal.url])} { diff --git a/src/lib/components/chat/MessageInput/CallOverlay.svelte b/src/lib/components/chat/MessageInput/CallOverlay.svelte index 2004964f6a..12c8834a76 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay.svelte @@ -46,6 +46,7 @@ let hasStartedSpeaking = false; let mediaRecorder; let audioStream = null; + let audioContext: AudioContext | undefined; let audioChunks = []; let destroyed = false; let avatarView: VoiceAvatar | undefined; @@ -358,6 +359,12 @@ } }; + // Chrome on Android caps open audio outputs at 10. + const closeAudioContext = () => { + void audioContext?.close(); + audioContext = undefined; + }; + const stopAudioStream = async () => { try { if (mediaRecorder) { @@ -367,6 +374,8 @@ console.log('Error stopping audio stream:', error); } + closeAudioContext(); + if (!audioStream) return; audioStream.getAudioTracks().forEach(function (track) { @@ -387,7 +396,8 @@ }; const analyseAudio = (stream) => { - const audioContext = new AudioContext(); + closeAudioContext(); + audioContext = new AudioContext(); const audioStreamSource = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); diff --git a/src/lib/components/chat/MessageInput/VoiceRecording.svelte b/src/lib/components/chat/MessageInput/VoiceRecording.svelte index 50e239705a..de82130681 100644 --- a/src/lib/components/chat/MessageInput/VoiceRecording.svelte +++ b/src/lib/components/chat/MessageInput/VoiceRecording.svelte @@ -87,6 +87,7 @@ }; let stream; + let audioContext: AudioContext | null = null; let speechRecognition; let mediaRecorder; @@ -116,8 +117,14 @@ return Math.min(1.0, Math.max(0.01, scaledRMS)); }; + // Chrome on Android caps open audio outputs at 10. + const closeAudioContext = () => { + void audioContext?.close(); + audioContext = null; + }; + const analyseAudio = (stream) => { - const audioContext = new AudioContext(); + audioContext = new AudioContext(); const audioStreamSource = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); @@ -399,6 +406,7 @@ } stream = null; + closeAudioContext(); }; const confirmRecording = async () => { @@ -418,6 +426,7 @@ } stream = null; + closeAudioContext(); }; let resizeObserver; @@ -463,6 +472,7 @@ window.removeEventListener('keydown', handleKeyDown); document.removeEventListener('visibilitychange', handleVisibilityChange); releaseWakeLock(); + closeAudioContext(); // remove resize observer resizeObserver.disconnect(); }); diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index afe6b4fec7..e802f22880 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -23,6 +23,7 @@ } from './diffEditorAlignment'; import { copyChunk, + keepBothChunk, selectedChunk, chunkOverview, changedLineCounts, @@ -65,6 +66,7 @@ let comparing = false; let merge: MergeView | null = null; let activeChunk = 0; + let canKeepBoth = false; let overview: ReturnType = []; let chunkCount = 0; let lineNumberDigits = 1; @@ -101,6 +103,7 @@ proposal = session.proposal; comparing = proposedContent !== undefined; activeChunk = chunkCount = 0; + canKeepBoth = false; overview = []; lineChanges = { removed: 0, added: 0 }; copied = null; @@ -200,6 +203,11 @@ if (index >= 0) activeChunk = index; } activeChunk = Math.max(0, Math.min(activeChunk, chunkCount - 1)); + const chunk = merge.chunks[activeChunk]; + canKeepBoth = + session?.mode === 'edit' && + !!chunk && + keepBothChunk(merge.a.state.doc, merge.b.state.doc, chunk) !== null; }; const reveal = (index: number) => { if (!merge || !chunkCount) return; @@ -215,6 +223,7 @@ effects: EditorView.scrollIntoView(position, { y: 'center' }) }); } + refresh(); }; const navigateOverview = (event: MouseEvent) => { const bounds = @@ -253,6 +262,21 @@ refresh(); }; + const keepBoth = () => { + if (session?.mode !== 'edit' || loading || !merge) return; + const chunk = merge.chunks[activeChunk]; + if (!chunk) return; + const changes = keepBothChunk(merge.a.state.doc, merge.b.state.doc, chunk); + if (!changes) return; + merge.b.dispatch({ + changes, + annotations: isolateHistory.of('full'), + userEvent: 'input.merge' + }); + refresh(); + merge.b.focus(); + }; + const mountMerge = (node: HTMLElement) => { let disposed = false; let view: MergeView | null = null; @@ -403,7 +427,7 @@ >
-
+

{session.title || $i18n.t('Compare revision')}

@@ -415,7 +439,7 @@ > {/if} {#if comparing} -
+
{loading @@ -469,6 +493,13 @@ title={$i18n.t('Copy change to proposal')} on:click={() => copy('to-proposal')}>{$i18n.t('Proposed revision')} → + {:else}
{ - if (item.type === 'file' || safeLinkUrl(item.url)) { - let fileId = item?.id ?? item?.tempId; - window.open( - item.type === 'file' - ? item?.url?.startsWith('http') - ? item.url - : `${WEBUI_API_BASE_URL}/files/${fileId}/content` - : item.url, - '_blank' - ); - } - }} > {item?.name ?? 'File'} diff --git a/src/lib/components/common/diffEditor.ts b/src/lib/components/common/diffEditor.ts index 699853e48a..c1519c6b73 100644 --- a/src/lib/components/common/diffEditor.ts +++ b/src/lib/components/common/diffEditor.ts @@ -67,6 +67,22 @@ export function copyChunk(a: Text, b: Text, chunk: Chunk, direction: MergeDirect return { from, to: Math.min(to, destination.length), insert }; } +export function keepBothChunk(a: Text, b: Text, chunk: Chunk): ChangeSpec | null { + // A line-aligned chunk can include text on both sides even for a pure EOF insertion. + if ( + !chunk.changes.some((change) => change.fromA < change.toA) || + !chunk.changes.some((change) => change.fromB < change.toB) + ) + return null; + const original = a.sliceString(chunk.fromA, Math.min(chunk.toA, a.length)); + const proposed = b.sliceString(chunk.fromB, Math.min(chunk.toB, b.length)); + return { + from: chunk.fromB, + to: Math.min(chunk.toB, b.length), + insert: original + (original.endsWith('\n') ? '' : '\n') + proposed + }; +} + export function selectedChunk(chunks: readonly Chunk[], side: 'a' | 'b', position: number) { return chunks.findIndex((chunk) => { const from = side === 'a' ? chunk.fromA : chunk.fromB; diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 4b6cf6b2fd..675082cf4c 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -673,7 +673,7 @@ }} > + let closeActiveHoverPreview = null; + +