Skip to content

Commit 5d7766e

Browse files
committed
refac
1 parent eca5126 commit 5d7766e

3 files changed

Lines changed: 312 additions & 18 deletions

File tree

backend/open_webui/tools/builtin.py

Lines changed: 303 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,24 +1587,49 @@ async def search_knowledge_files(
15871587
return json.dumps({'error': str(e)})
15881588

15891589

1590+
# Hard cap for view_file / view_knowledge_file output
1591+
MAX_VIEW_FILE_CHARS = 100_000
1592+
DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
1593+
1594+
15901595
async def view_file(
15911596
file_id: str,
1597+
offset: int = 0,
1598+
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
15921599
__request__: Request = None,
15931600
__user__: dict = None,
15941601
__model_knowledge__: Optional[list[dict]] = None,
15951602
) -> str:
15961603
"""
1597-
Get the full content of a file by its ID.
1604+
Get the content of a file by its ID. Supports pagination for large files.
15981605
15991606
:param file_id: The ID of the file to retrieve
1600-
:return: JSON with the file's id, filename, and full text content
1607+
:param offset: Character offset to start reading from (default: 0)
1608+
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
1609+
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
16011610
"""
16021611
if __request__ is None:
16031612
return json.dumps({'error': 'Request context not available'})
16041613

16051614
if not __user__:
16061615
return json.dumps({'error': 'User context not available'})
16071616

1617+
# Coerce parameters from LLM tool calls (may come as strings)
1618+
if isinstance(offset, str):
1619+
try:
1620+
offset = int(offset)
1621+
except ValueError:
1622+
offset = 0
1623+
if isinstance(max_chars, str):
1624+
try:
1625+
max_chars = int(max_chars)
1626+
except ValueError:
1627+
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
1628+
1629+
# Enforce hard cap
1630+
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
1631+
offset = max(offset, 0)
1632+
16081633
try:
16091634
from open_webui.models.files import Files
16101635
from open_webui.utils.access_control.files import has_access_to_file
@@ -1634,38 +1659,69 @@ async def view_file(
16341659
if file.data:
16351660
content = file.data.get('content', '')
16361661

1637-
return json.dumps(
1638-
{
1639-
'id': file.id,
1640-
'filename': file.filename,
1641-
'content': content,
1642-
'updated_at': file.updated_at,
1643-
'created_at': file.created_at,
1644-
},
1645-
ensure_ascii=False,
1646-
)
1662+
total_chars = len(content)
1663+
sliced = content[offset:offset + max_chars]
1664+
is_truncated = (offset + len(sliced)) < total_chars
1665+
1666+
result = {
1667+
'id': file.id,
1668+
'filename': file.filename,
1669+
'content': sliced,
1670+
'updated_at': file.updated_at,
1671+
'created_at': file.created_at,
1672+
}
1673+
1674+
if is_truncated or offset > 0:
1675+
result['truncated'] = is_truncated
1676+
result['total_chars'] = total_chars
1677+
result['returned_chars'] = len(sliced)
1678+
result['offset'] = offset
1679+
if is_truncated:
1680+
result['next_offset'] = offset + len(sliced)
1681+
1682+
return json.dumps(result, ensure_ascii=False)
16471683
except Exception as e:
16481684
log.exception(f'view_file error: {e}')
16491685
return json.dumps({'error': str(e)})
16501686

16511687

16521688
async def view_knowledge_file(
16531689
file_id: str,
1690+
offset: int = 0,
1691+
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
16541692
__request__: Request = None,
16551693
__user__: dict = None,
16561694
) -> str:
16571695
"""
1658-
Get the full content of a file from a knowledge base.
1696+
Get the content of a file from a knowledge base. Supports pagination for large files.
16591697
16601698
:param file_id: The ID of the file to retrieve
1661-
:return: JSON with the file's id, filename, and full text content
1699+
:param offset: Character offset to start reading from (default: 0)
1700+
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
1701+
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
16621702
"""
16631703
if __request__ is None:
16641704
return json.dumps({'error': 'Request context not available'})
16651705

16661706
if not __user__:
16671707
return json.dumps({'error': 'User context not available'})
16681708

1709+
# Coerce parameters from LLM tool calls (may come as strings)
1710+
if isinstance(offset, str):
1711+
try:
1712+
offset = int(offset)
1713+
except ValueError:
1714+
offset = 0
1715+
if isinstance(max_chars, str):
1716+
try:
1717+
max_chars = int(max_chars)
1718+
except ValueError:
1719+
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
1720+
1721+
# Enforce hard cap
1722+
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
1723+
offset = max(offset, 0)
1724+
16691725
try:
16701726
from open_webui.models.files import Files
16711727
from open_webui.models.knowledge import Knowledges
@@ -1708,23 +1764,255 @@ async def view_knowledge_file(
17081764
if file.data:
17091765
content = file.data.get('content', '')
17101766

1767+
total_chars = len(content)
1768+
sliced = content[offset:offset + max_chars]
1769+
is_truncated = (offset + len(sliced)) < total_chars
1770+
17111771
result = {
17121772
'id': file.id,
17131773
'filename': file.filename,
1714-
'content': content,
1774+
'content': sliced,
17151775
'updated_at': file.updated_at,
17161776
'created_at': file.created_at,
17171777
}
17181778
if knowledge_info:
17191779
result['knowledge_id'] = knowledge_info['id']
17201780
result['knowledge_name'] = knowledge_info['name']
17211781

1782+
if is_truncated or offset > 0:
1783+
result['truncated'] = is_truncated
1784+
result['total_chars'] = total_chars
1785+
result['returned_chars'] = len(sliced)
1786+
result['offset'] = offset
1787+
if is_truncated:
1788+
result['next_offset'] = offset + len(sliced)
1789+
17221790
return json.dumps(result, ensure_ascii=False)
17231791
except Exception as e:
17241792
log.exception(f'view_knowledge_file error: {e}')
17251793
return json.dumps({'error': str(e)})
17261794

17271795

1796+
async def list_attached_knowledge(
1797+
__request__: Request = None,
1798+
__user__: dict = None,
1799+
__model_knowledge__: Optional[list[dict]] = None,
1800+
) -> str:
1801+
"""
1802+
List all knowledge bases, files, and notes attached to the current model.
1803+
Use this first to discover what knowledge is available before querying or reading files.
1804+
1805+
:return: JSON with knowledge_bases, files, and notes attached to this model
1806+
"""
1807+
if __request__ is None:
1808+
return json.dumps({'error': 'Request context not available'})
1809+
1810+
if not __user__:
1811+
return json.dumps({'error': 'User context not available'})
1812+
1813+
if not __model_knowledge__:
1814+
return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []})
1815+
1816+
try:
1817+
from open_webui.models.knowledge import Knowledges
1818+
from open_webui.models.files import Files
1819+
from open_webui.models.notes import Notes
1820+
from open_webui.models.access_grants import AccessGrants
1821+
1822+
user_id = __user__.get('id')
1823+
user_role = __user__.get('role', 'user')
1824+
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
1825+
1826+
knowledge_bases = []
1827+
files = []
1828+
notes = []
1829+
1830+
for item in __model_knowledge__:
1831+
item_type = item.get('type')
1832+
item_id = item.get('id')
1833+
1834+
if item_type == 'collection':
1835+
knowledge = Knowledges.get_knowledge_by_id(item_id)
1836+
if knowledge and (
1837+
user_role == 'admin'
1838+
or knowledge.user_id == user_id
1839+
or AccessGrants.has_access(
1840+
user_id=user_id,
1841+
resource_type='knowledge',
1842+
resource_id=knowledge.id,
1843+
permission='read',
1844+
user_group_ids=set(user_group_ids),
1845+
)
1846+
):
1847+
kb_files = Knowledges.get_files_by_id(knowledge.id)
1848+
file_count = len(kb_files) if kb_files else 0
1849+
1850+
kb_entry = {
1851+
'id': knowledge.id,
1852+
'name': knowledge.name,
1853+
'description': knowledge.description or '',
1854+
'file_count': file_count,
1855+
}
1856+
1857+
# Include file listing for each KB
1858+
if kb_files:
1859+
kb_entry['files'] = [
1860+
{'id': f.id, 'filename': f.filename}
1861+
for f in kb_files
1862+
]
1863+
1864+
knowledge_bases.append(kb_entry)
1865+
1866+
elif item_type == 'file':
1867+
file = Files.get_file_by_id(item_id)
1868+
if file:
1869+
files.append({
1870+
'id': file.id,
1871+
'filename': file.filename,
1872+
'updated_at': file.updated_at,
1873+
})
1874+
1875+
elif item_type == 'note':
1876+
note = Notes.get_note_by_id(item_id)
1877+
if note and (
1878+
user_role == 'admin'
1879+
or note.user_id == user_id
1880+
or AccessGrants.has_access(
1881+
user_id=user_id,
1882+
resource_type='note',
1883+
resource_id=note.id,
1884+
permission='read',
1885+
)
1886+
):
1887+
notes.append({
1888+
'id': note.id,
1889+
'title': note.title,
1890+
})
1891+
1892+
return json.dumps({
1893+
'knowledge_bases': knowledge_bases,
1894+
'files': files,
1895+
'notes': notes,
1896+
}, ensure_ascii=False)
1897+
except Exception as e:
1898+
log.exception(f'list_attached_knowledge error: {e}')
1899+
return json.dumps({'error': str(e)})
1900+
1901+
1902+
async def search_attached_files(
1903+
query: str,
1904+
knowledge_id: Optional[str] = None,
1905+
count: int = 10,
1906+
skip: int = 0,
1907+
__request__: Request = None,
1908+
__user__: dict = None,
1909+
__model_knowledge__: Optional[list[dict]] = None,
1910+
) -> str:
1911+
"""
1912+
Search files by filename within the attached knowledge scope.
1913+
Only searches knowledge bases and files that are attached to the current model.
1914+
1915+
:param query: The filename search query
1916+
:param knowledge_id: Optional KB id to limit search to a specific attached knowledge base
1917+
:param count: Maximum number of results to return (default: 10)
1918+
:param skip: Number of results to skip for pagination (default: 0)
1919+
:return: JSON with matching files containing id, filename, knowledge_id, and knowledge_name
1920+
"""
1921+
if __request__ is None:
1922+
return json.dumps({'error': 'Request context not available'})
1923+
1924+
if not __user__:
1925+
return json.dumps({'error': 'User context not available'})
1926+
1927+
if not __model_knowledge__:
1928+
return json.dumps([])
1929+
1930+
try:
1931+
from open_webui.models.knowledge import Knowledges
1932+
from open_webui.models.files import Files
1933+
from open_webui.models.access_grants import AccessGrants
1934+
1935+
user_id = __user__.get('id')
1936+
user_role = __user__.get('role', 'user')
1937+
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
1938+
1939+
# Collect attached KB IDs and direct file IDs
1940+
attached_kb_ids = set()
1941+
attached_file_ids = set()
1942+
1943+
for item in __model_knowledge__:
1944+
item_type = item.get('type')
1945+
item_id = item.get('id')
1946+
if item_type == 'collection':
1947+
attached_kb_ids.add(item_id)
1948+
elif item_type == 'file':
1949+
attached_file_ids.add(item_id)
1950+
1951+
# If knowledge_id is specified, verify it's in the attached set
1952+
if knowledge_id:
1953+
if knowledge_id not in attached_kb_ids:
1954+
return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'})
1955+
attached_kb_ids = {knowledge_id}
1956+
1957+
all_files = []
1958+
1959+
# Search within attached KBs
1960+
for kb_id in attached_kb_ids:
1961+
knowledge = Knowledges.get_knowledge_by_id(kb_id)
1962+
if not knowledge:
1963+
continue
1964+
1965+
if not (
1966+
user_role == 'admin'
1967+
or knowledge.user_id == user_id
1968+
or AccessGrants.has_access(
1969+
user_id=user_id,
1970+
resource_type='knowledge',
1971+
resource_id=knowledge.id,
1972+
permission='read',
1973+
user_group_ids=set(user_group_ids),
1974+
)
1975+
):
1976+
continue
1977+
1978+
result = Knowledges.search_files_by_id(
1979+
knowledge_id=kb_id,
1980+
user_id=user_id,
1981+
filter={'query': query},
1982+
skip=0,
1983+
limit=count + skip, # Fetch enough for pagination across KBs
1984+
)
1985+
1986+
for file in result.items:
1987+
all_files.append({
1988+
'id': file.id,
1989+
'filename': file.filename,
1990+
'knowledge_id': knowledge.id,
1991+
'knowledge_name': knowledge.name,
1992+
'updated_at': file.updated_at,
1993+
})
1994+
1995+
# Search within directly attached files (filename match)
1996+
if not knowledge_id and attached_file_ids:
1997+
query_lower = query.lower() if query else ''
1998+
for file_id in attached_file_ids:
1999+
file = Files.get_file_by_id(file_id)
2000+
if file and (not query_lower or query_lower in file.filename.lower()):
2001+
all_files.append({
2002+
'id': file.id,
2003+
'filename': file.filename,
2004+
'updated_at': file.updated_at,
2005+
})
2006+
2007+
# Apply pagination across combined results
2008+
all_files = all_files[skip:skip + count]
2009+
2010+
return json.dumps(all_files, ensure_ascii=False)
2011+
except Exception as e:
2012+
log.exception(f'search_attached_files error: {e}')
2013+
return json.dumps({'error': str(e)})
2014+
2015+
17282016
async def query_knowledge_files(
17292017
query: str,
17302018
knowledge_ids: Optional[list[str]] = None,

0 commit comments

Comments
 (0)