diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2dfe189f0..ad311a371 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 ca2f9e7cd..2451f50ae 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 be65a92c9..ba6611a81 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 808073a45..dec1848aa 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 1a042922f..37a3fea85 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 07c1ae521..93ba72ce1 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 b275c84e2..652211825 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 85686c4fc..ee8a9007f 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 7eba61770..06e8d0aba 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 2ed08feda..404e36cfa 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 33642a339..6b12515ba 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 3e5264060..8efa79c1b 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 204f35d51..bc1a1c5da 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 751fc823f..028371386 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 041c51149..92de0c3d4 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 @@