This commit is contained in:
Timothy Jaeryang Baek
2026-03-23 16:46:54 -05:00
parent eca51269bb
commit 5d7766e1b6
3 changed files with 312 additions and 18 deletions
+303 -15
View File
@@ -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,
+3 -2
View File
@@ -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',
]
+6 -1
View File
@@ -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: