diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2dfe189f0d..ad311a371a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,6 @@ This is to ensure large feature PRs are discussed with the community first, befo The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase. --> - **Before submitting, make sure you've checked the following:** - [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.** diff --git a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py index ca2f9e7cd3..2451f50ae2 100644 --- a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py +++ b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py @@ -91,41 +91,41 @@ def upgrade(): original_chat_id = row.user_id.replace('shared-', '', 1) # Verify original chat still exists - original = conn.execute( - sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id) - ).fetchone() + original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone() if not original: continue # Insert snapshot into shared_chat - conn.execute(shared_chat_t.insert().values( - id=share_token, - chat_id=original_chat_id, - user_id=original.user_id, - title=row.title, - chat=row.chat, - created_at=row.created_at, - updated_at=row.updated_at, - )) + conn.execute( + shared_chat_t.insert().values( + id=share_token, + chat_id=original_chat_id, + user_id=original.user_id, + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + ) + ) # Create user:*:read grant for backward compat - conn.execute(access_grant_t.insert().values( - id=str(uuid.uuid4()), - resource_type='shared_chat', - resource_id=original_chat_id, - principal_type='user', - principal_id='*', - permission='read', - created_at=row.created_at or int(time.time()), - )) + conn.execute( + access_grant_t.insert().values( + id=str(uuid.uuid4()), + resource_type='shared_chat', + resource_id=original_chat_id, + principal_type='user', + principal_id='*', + permission='read', + created_at=row.created_at or int(time.time()), + ) + ) # 3. Clean up old phantom rows conn.execute( chat_message_t.delete().where( - chat_message_t.c.chat_id.in_( - sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')) - ) + chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%'))) ) ) conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%'))) @@ -147,18 +147,18 @@ def downgrade(): ).fetchall() for row in shared_rows: - conn.execute(chat_t.insert().values( - id=row.id, - user_id=f'shared-{row.chat_id}', - title=row.title, - chat=row.chat, - created_at=row.created_at, - updated_at=row.updated_at, - archived=False, - meta={}, - )) + conn.execute( + chat_t.insert().values( + id=row.id, + user_id=f'shared-{row.chat_id}', + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + archived=False, + meta={}, + ) + ) - conn.execute( - access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat') - ) + conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat')) op.drop_table('shared_chat') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index be65a92c91..ba6611a811 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -723,9 +723,7 @@ class ChatTable: """Delegate to SharedChats for listing shared chats by user.""" from open_webui.models.shared_chats import SharedChats - return await SharedChats.get_by_user_id( - user_id, filter=filter, skip=skip, limit=limit, db=db - ) + return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db) async def get_chat_list_by_user_id( self, diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 808073a458..dec1848aa7 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -299,15 +299,17 @@ class PromptsTable: if dialect_name == 'sqlite': tag_clause = text( - "EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)" + 'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)' ) elif dialect_name == 'postgresql': tag_clause = text( - "EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)" + 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' ) else: # Fallback: LIKE on serialised JSON text (ASCII-safe only) - tag_clause = func.lower(cast(Prompt.tags, String)).like(f'%{json.dumps(tag_lower, ensure_ascii=False)}%') + tag_clause = func.lower(cast(Prompt.tags, String)).like( + f'%{json.dumps(tag_lower, ensure_ascii=False)}%' + ) tag_lower = None if tag_lower is not None: diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py index 1a042922fb..37a3fea852 100644 --- a/backend/open_webui/models/shared_chats.py +++ b/backend/open_webui/models/shared_chats.py @@ -60,9 +60,7 @@ class SharedChatResponse(BaseModel): class SharedChatsTable: - async def create( - self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: """ Create a snapshot of the chat for link sharing. Returns the SharedChatModel with the share token as its id. @@ -92,9 +90,7 @@ class SharedChatsTable: return SharedChatModel.model_validate(shared_chat) - async def update( - self, share_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: """ Re-snapshot: update the shared chat with the current state of the original chat. """ @@ -117,9 +113,7 @@ class SharedChatsTable: await db.refresh(shared_chat) return SharedChatModel.model_validate(shared_chat) - async def get_by_id( - self, share_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: """Get a shared chat by its share token.""" async with get_async_db_context(db) as db: shared_chat = await db.get(SharedChat, share_id) @@ -127,16 +121,11 @@ class SharedChatsTable: return SharedChatModel.model_validate(shared_chat) return None - async def get_by_chat_id( - self, chat_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: """Get the shared chat for a given original chat. Returns the most recent one.""" async with get_async_db_context(db) as db: result = await db.execute( - select(SharedChat) - .filter_by(chat_id=chat_id) - .order_by(SharedChat.updated_at.desc()) - .limit(1) + select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1) ) shared_chat = result.scalars().first() if shared_chat: @@ -194,9 +183,7 @@ class SharedChatsTable: for sc in result.scalars().all() ] - async def delete_by_id( - self, share_id: str, db: Optional[AsyncSession] = None - ) -> bool: + async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool: """Delete a shared chat by its share token.""" try: async with get_async_db_context(db) as db: @@ -206,9 +193,7 @@ class SharedChatsTable: except Exception: return False - async def delete_by_chat_id( - self, chat_id: str, db: Optional[AsyncSession] = None - ) -> bool: + async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: """Delete all shared chats for a given original chat.""" try: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 07c1ae5210..93ba72ce13 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -959,7 +959,7 @@ async def filter_accessible_collections( # System meta-collection — never exposed to non-admins. continue elif name.startswith('file-'): - file_id = name[len('file-'):] + file_id = name[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-'): diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index b275c84e29..6522118258 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -150,15 +150,8 @@ async def query_memory( # same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching # memories are surfaced (distances are normalised to 0→1, higher is # better). - relevance_threshold = getattr( - request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0 - ) - if ( - results - and relevance_threshold > 0.0 - and results.distances - and results.distances[0] - ): + relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0) + if results and relevance_threshold > 0.0 and results.distances and results.distances[0]: from open_webui.retrieval.vector.main import SearchResult filtered_ids = [] diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 85686c4fcf..ee8a9007fe 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -2365,7 +2365,6 @@ async def _validate_collection_access(collection_names: list[str], user, access_ ) - class QueryDocForm(BaseModel): collection_name: str query: str diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 7eba61770e..06e8d0aba0 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -339,11 +339,8 @@ async def check_model_access( raise HTTPException(status_code=403, detail='Model not found') # Enforce access on chained base models - if not await has_base_model_access( - user.id, model_info, user_group_ids=user_group_ids - ): + if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): raise HTTPException(status_code=403, detail='Model not found') else: if user.role != 'admin': raise HTTPException(status_code=403, detail='Model not found') - diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 2ed08fedab..404e36cfa3 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -452,6 +452,50 @@ def serialize_output(output: list) -> str: # Already handled inline with function_call above pass + elif item_type in ('web_search_call', 'file_search_call', 'computer_call'): + # OpenAI Responses API built-in server-side tool output items. + # These are emitted when the model uses native tools (web_search, + # file_search, computer_use) through the Responses API. Render as + # collapsible tool call blocks matching the function_call pattern. + if content and not content.endswith('\n'): + content += '\n' + + call_id = item.get('id', '') + status = item.get('status', 'in_progress') + + # Derive a human-readable display name + display_names = { + 'web_search_call': 'Web Search', + 'file_search_call': 'File Search', + 'computer_call': 'Computer Use', + } + display_name = display_names.get(item_type, item_type) + + # Extract a summary of what the tool did for the details body + summary_text = '' + if item_type == 'web_search_call': + action = item.get('action', {}) + if isinstance(action, dict): + query = action.get('query', '') + if query: + summary_text = f'Query: {query}' + elif item_type == 'file_search_call': + queries = item.get('queries', []) + if queries: + summary_text = f'Queries: {", ".join(str(q) for q in queries)}' + elif item_type == 'computer_call': + action = item.get('action', {}) + if isinstance(action, dict): + action_type = action.get('type', '') + if action_type: + summary_text = f'Action: {action_type}' + + done = status == 'completed' or idx != len(output) - 1 + if done: + content += f'
\nTool Executed\n{html.escape(summary_text)}\n
\n' + else: + content += f'
\nExecuting...\n
\n' + elif item_type == 'reasoning': reasoning_content = '' # Check for 'summary' (new structure) or 'content' (legacy/fallback) @@ -2619,9 +2663,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Resolve terminal tools if terminal_id is set (outside tool_ids check # so system terminals work even when no other tools are selected) - terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( - 'terminal', True - ) + terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True) if terminal_id and terminal_capability: try: terminal_result = await get_terminal_tools( @@ -3110,11 +3152,13 @@ async def outlet_filter_handler(ctx): # Append the full assistant message (content, output, usage, etc.) if assistant_message: - message_list.append({ - 'id': message_id, - 'role': 'assistant', - **assistant_message, - }) + message_list.append( + { + 'id': message_id, + 'role': 'assistant', + **assistant_message, + } + ) else: messages_map = await Chats.get_messages_map_by_chat_id(chat_id) if not messages_map: @@ -4221,7 +4265,6 @@ async def streaming_chat_response_handler(response, ctx): ) reasoning_item['status'] = 'completed' - if response_tool_calls: tool_calls.append(_split_tool_calls(response_tool_calls)) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 33642a339d..6b12515ba1 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -413,7 +413,6 @@ async def check_model_access(user, model, db=None): raise Exception('Model not found') - async def get_filtered_models(models, user, db=None): # Filter out models that the user does not have access to if ( diff --git a/package-lock.json b/package-lock.json index 3e52640600..8efa79c1b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 204f35d51f..bc1a1c5da3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 751fc823f2..028371386c 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -953,11 +953,7 @@ export const deleteSharedChatById = async (token: string, id: string) => { return res; }; -export const updateChatAccessGrants = async ( - token: string, - id: string, - accessGrants: object[] -) => { +export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, { diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 041c511493..92de0c3d43 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -496,11 +496,8 @@ ); let terminalCapableModels = []; - $: terminalCapableModels = ( - atSelectedModel?.id ? [atSelectedModel.id] : selectedModels - ).filter( - (model) => - $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true + $: terminalCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter( + (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true ); let toggleFilters = []; @@ -1760,12 +1757,18 @@