diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 70ce20a98f..d0e5ba2ab5 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1587,17 +1587,26 @@ async def search_knowledge_files( return json.dumps({'error': str(e)}) +# Hard cap for view_file / view_knowledge_file output +MAX_VIEW_FILE_CHARS = 100_000 +DEFAULT_VIEW_FILE_MAX_CHARS = 10_000 + + async def view_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, __model_knowledge__: Optional[list[dict]] = None, ) -> str: """ - Get the full content of a file by its ID. + Get the content of a file by its ID. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: return json.dumps({'error': 'Request context not available'}) @@ -1605,6 +1614,22 @@ async def view_file( if not __user__: return json.dumps({'error': 'User context not available'}) + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) + try: from open_webui.models.files import Files from open_webui.utils.access_control.files import has_access_to_file @@ -1634,16 +1659,27 @@ async def view_file( if file.data: content = file.data.get('content', '') - return json.dumps( - { - 'id': file.id, - 'filename': file.filename, - 'content': content, - 'updated_at': file.updated_at, - 'created_at': file.created_at, - }, - ensure_ascii=False, - ) + total_chars = len(content) + sliced = content[offset:offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars + + result = { + 'id': file.id, + 'filename': file.filename, + 'content': sliced, + 'updated_at': file.updated_at, + 'created_at': file.created_at, + } + + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) + + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_file error: {e}') return json.dumps({'error': str(e)}) @@ -1651,14 +1687,18 @@ async def view_file( async def view_knowledge_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, ) -> str: """ - Get the full content of a file from a knowledge base. + Get the content of a file from a knowledge base. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: return json.dumps({'error': 'Request context not available'}) @@ -1666,6 +1706,22 @@ async def view_knowledge_file( if not __user__: return json.dumps({'error': 'User context not available'}) + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) + try: from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges @@ -1708,10 +1764,14 @@ async def view_knowledge_file( if file.data: content = file.data.get('content', '') + total_chars = len(content) + sliced = content[offset:offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars + result = { 'id': file.id, 'filename': file.filename, - 'content': content, + 'content': sliced, 'updated_at': file.updated_at, 'created_at': file.created_at, } @@ -1719,12 +1779,240 @@ async def view_knowledge_file( result['knowledge_id'] = knowledge_info['id'] result['knowledge_name'] = knowledge_info['name'] + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_knowledge_file error: {e}') return json.dumps({'error': str(e)}) +async def list_attached_knowledge( + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, +) -> str: + """ + List all knowledge bases, files, and notes attached to the current model. + Use this first to discover what knowledge is available before querying or reading files. + + :return: JSON with knowledge_bases, files, and notes attached to this model + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + if not __model_knowledge__: + return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []}) + + try: + from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.notes import Notes + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + knowledge_bases = [] + files = [] + notes = [] + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + + if item_type == 'collection': + knowledge = Knowledges.get_knowledge_by_id(item_id) + if knowledge and ( + user_role == 'admin' + or knowledge.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + kb_files = Knowledges.get_files_by_id(knowledge.id) + file_count = len(kb_files) if kb_files else 0 + + kb_entry = { + 'id': knowledge.id, + 'name': knowledge.name, + 'description': knowledge.description or '', + 'file_count': file_count, + } + + # Include file listing for each KB + if kb_files: + kb_entry['files'] = [ + {'id': f.id, 'filename': f.filename} + for f in kb_files + ] + + knowledge_bases.append(kb_entry) + + elif item_type == 'file': + file = Files.get_file_by_id(item_id) + if file: + files.append({ + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + }) + + elif item_type == 'note': + note = Notes.get_note_by_id(item_id) + if note and ( + user_role == 'admin' + or note.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='read', + ) + ): + notes.append({ + 'id': note.id, + 'title': note.title, + }) + + return json.dumps({ + 'knowledge_bases': knowledge_bases, + 'files': files, + 'notes': notes, + }, ensure_ascii=False) + except Exception as e: + log.exception(f'list_attached_knowledge error: {e}') + return json.dumps({'error': str(e)}) + + +async def search_attached_files( + query: str, + knowledge_id: Optional[str] = None, + count: int = 10, + skip: int = 0, + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, +) -> str: + """ + Search files by filename within the attached knowledge scope. + Only searches knowledge bases and files that are attached to the current model. + + :param query: The filename search query + :param knowledge_id: Optional KB id to limit search to a specific attached knowledge base + :param count: Maximum number of results to return (default: 10) + :param skip: Number of results to skip for pagination (default: 0) + :return: JSON with matching files containing id, filename, knowledge_id, and knowledge_name + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + if not __model_knowledge__: + return json.dumps([]) + + try: + from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + # Collect attached KB IDs and direct file IDs + attached_kb_ids = set() + attached_file_ids = set() + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + if item_type == 'collection': + attached_kb_ids.add(item_id) + elif item_type == 'file': + attached_file_ids.add(item_id) + + # If knowledge_id is specified, verify it's in the attached set + if knowledge_id: + if knowledge_id not in attached_kb_ids: + return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'}) + attached_kb_ids = {knowledge_id} + + all_files = [] + + # Search within attached KBs + for kb_id in attached_kb_ids: + knowledge = Knowledges.get_knowledge_by_id(kb_id) + if not knowledge: + continue + + if not ( + user_role == 'admin' + or knowledge.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + continue + + result = Knowledges.search_files_by_id( + knowledge_id=kb_id, + user_id=user_id, + filter={'query': query}, + skip=0, + limit=count + skip, # Fetch enough for pagination across KBs + ) + + for file in result.items: + all_files.append({ + 'id': file.id, + 'filename': file.filename, + 'knowledge_id': knowledge.id, + 'knowledge_name': knowledge.name, + 'updated_at': file.updated_at, + }) + + # Search within directly attached files (filename match) + if not knowledge_id and attached_file_ids: + query_lower = query.lower() if query else '' + for file_id in attached_file_ids: + file = Files.get_file_by_id(file_id) + if file and (not query_lower or query_lower in file.filename.lower()): + all_files.append({ + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + }) + + # Apply pagination across combined results + all_files = all_files[skip:skip + count] + + return json.dumps(all_files, ensure_ascii=False) + except Exception as e: + log.exception(f'search_attached_files error: {e}') + return json.dumps({'error': str(e)}) + + async def query_knowledge_files( query: str, knowledge_ids: Optional[list[str]] = None, diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 044c2974b3..cbf644b53c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -227,7 +227,7 @@ def get_citation_source_from_tool_result( Returns a list of sources (usually one, but query_knowledge_files may return multiple). """ _EXPECTS_LIST = {'search_web', 'query_knowledge_files'} - _EXPECTS_DICT = {'view_knowledge_file'} + _EXPECTS_DICT = {'view_knowledge_file', 'view_file'} try: try: @@ -271,7 +271,7 @@ def get_citation_source_from_tool_result( } ] - elif tool_name == 'view_knowledge_file': + elif tool_name in ('view_knowledge_file', 'view_file'): file_data = tool_result filename = file_data.get('filename', 'Unknown File') file_id = file_data.get('id', '') @@ -4143,6 +4143,7 @@ async def streaming_chat_response_handler(response, ctx): in [ 'search_web', 'fetch_url', + 'view_file', 'view_knowledge_file', 'query_knowledge_files', ] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index a098a83979..6f24739b0d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -79,6 +79,8 @@ from open_webui.tools.builtin import ( query_knowledge_bases, search_knowledge_files, query_knowledge_files, + list_attached_knowledge, + search_attached_files, view_file, view_knowledge_file, view_skill, @@ -405,12 +407,15 @@ def get_builtin_tools( model_knowledge = list(model_knowledge or []) + list(folder_knowledge) if is_builtin_tool_enabled('knowledge'): if model_knowledge: - # Model has attached knowledge - only allow semantic search within it + # Model has attached knowledge - provide discovery, search and semantic tools + builtin_functions.append(list_attached_knowledge) + builtin_functions.append(search_attached_files) builtin_functions.append(query_knowledge_files) knowledge_types = {item.get('type') for item in model_knowledge} if 'file' in knowledge_types or 'collection' in knowledge_types: builtin_functions.append(view_file) + builtin_functions.append(view_knowledge_file) if 'note' in knowledge_types: builtin_functions.append(view_note) else: