This commit is contained in:
Timothy Jaeryang Baek
2026-05-13 15:55:21 +09:00
parent 5b125c24d4
commit ecec86dd32
3 changed files with 276 additions and 26 deletions
+233
View File
@@ -1733,12 +1733,175 @@ async def search_knowledge_files(
# Hard cap for view_file / view_knowledge_file output
MAX_VIEW_FILE_CHARS = 100_000
DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
MAX_GREP_RESULTS = 50
async def grep_knowledge_files(
pattern: str,
file_id: Optional[str] = None,
case_insensitive: bool = False,
count_only: bool = False,
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: Optional[list[dict]] = None,
) -> str:
"""
Search for exact text across knowledge files. Returns matching lines with line numbers.
Unlike query_knowledge_files (semantic/vector search), this performs exact string matching.
Automatically detects regex patterns (e.g. "error|warn", "version \\d+").
:param pattern: The text pattern to search for (regex auto-detected)
:param file_id: Optional file ID to search within a single file only
:param case_insensitive: If true, ignore case when matching (default: false)
:param count_only: If true, return only match counts per file (default: false)
:return: Matching lines with file IDs, filenames, and line numbers
"""
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 pattern or not pattern.strip():
return json.dumps({'error': 'Pattern is required'})
try:
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.tools.knowledge_fs import build_matcher
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
_matches, err = build_matcher(pattern, case_insensitive)
if err:
return json.dumps({'error': err})
# Collect files to search
files_to_search = []
if file_id:
# Single file mode — verify access
file = await Files.get_file_by_id(file_id)
if file:
from open_webui.utils.access_control.files import has_access_to_file
if (
file.user_id != user_id
and user_role != 'admin'
and not any(
item.get('type') == 'file' and item.get('id') == file_id
for item in (__model_knowledge__ or [])
)
and not await has_access_to_file(
file_id=file_id,
access_type='read',
user=UserModel(**__user__),
)
):
return json.dumps({'error': 'File not found'})
files_to_search.append(file)
elif __model_knowledge__:
# Scoped to model's attached knowledge
seen_ids = set()
for item in __model_knowledge__:
item_type = item.get('type')
item_id = item.get('id')
if item_type == 'file' and item_id not in seen_ids:
file = await Files.get_file_by_id(item_id)
if file:
files_to_search.append(file)
seen_ids.add(item_id)
elif item_type == 'collection':
knowledge = await Knowledges.get_knowledge_by_id(item_id)
if not knowledge:
continue
for fid in (knowledge.data or {}).get('file_ids', []):
if fid not in seen_ids:
file = await Files.get_file_by_id(fid)
if file:
files_to_search.append(file)
seen_ids.add(fid)
else:
# All accessible knowledge bases — use the same search pattern as list_knowledge_bases
result = await Knowledges.search_knowledge_bases(
user_id,
filter={
'query': '',
'user_id': user_id,
'group_ids': user_group_ids,
},
skip=0,
limit=200,
)
seen_ids = set()
for kb in result.items:
file_ids = []
# Get files attached to this KB
files_from_kb = await Knowledges.get_files_by_id(kb.id)
if files_from_kb:
file_ids = [f.id for f in files_from_kb]
for fid in file_ids:
if fid not in seen_ids:
file = await Files.get_file_by_id(fid)
if file:
files_to_search.append(file)
seen_ids.add(fid)
if not files_to_search:
return json.dumps({'error': 'No accessible files found'})
# Search
results = []
total_matches = 0
counts = []
for file in files_to_search:
content = ''
if file.data:
content = file.data.get('content', '')
if not content:
continue
lines = content.split('\n')
file_matches = 0
for i, line in enumerate(lines, 1):
if _matches(line):
file_matches += 1
total_matches += 1
if not count_only and len(results) < MAX_GREP_RESULTS:
results.append(f'{file.id} {file.filename}:{i}: {line}')
if file_matches > 0 and count_only:
counts.append(f'{file.id} {file.filename}: {file_matches}')
if count_only:
if not counts:
return f'No matches for "{pattern}"'
return '\n'.join(counts) + f'\n[{total_matches} total matches]'
if not results:
return f'No matches for "{pattern}"'
output = '\n'.join(results)
if total_matches > MAX_GREP_RESULTS:
output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]'
return output
except Exception as e:
log.exception(f'grep_knowledge_files error: {e}')
return json.dumps({'error': str(e)})
async def view_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
line_numbers: bool = False,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: Optional[list[dict]] = None,
@@ -1749,6 +1912,9 @@ async def view_file(
:param file_id: The ID of the file to retrieve
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
@@ -1803,9 +1969,38 @@ async def view_file(
content = file.data.get('content', '')
total_chars = len(content)
# Line-based addressing (overrides char-based offset/max_chars)
if start_line is not None:
all_lines = content.split('\n')
total_lines = len(all_lines)
s = max(1, int(start_line)) - 1 # 1-indexed to 0-indexed
e = min(total_lines, int(end_line) if end_line else s + 100)
selected = all_lines[s:e]
sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected))
is_truncated = e < total_lines
result = {
'id': file.id,
'filename': file.filename,
'content': sliced,
'updated_at': file.updated_at,
'created_at': file.created_at,
'total_lines': total_lines,
'showing_lines': f'{s + 1}-{e}',
}
if is_truncated:
result['truncated'] = True
result['next_start_line'] = e + 1
return json.dumps(result, ensure_ascii=False)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
if line_numbers:
start_ln = content[:offset].count('\n') + 1
lines = sliced.split('\n')
sliced = '\n'.join(f'{start_ln + i}: {line}' for i, line in enumerate(lines))
result = {
'id': file.id,
'filename': file.filename,
@@ -1832,6 +2027,9 @@ async def view_knowledge_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
line_numbers: bool = False,
start_line: Optional[int] = None,
end_line: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
@@ -1841,6 +2039,9 @@ async def view_knowledge_file(
:param file_id: The ID of the file to retrieve
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
@@ -1908,9 +2109,41 @@ async def view_knowledge_file(
content = file.data.get('content', '')
total_chars = len(content)
# Line-based addressing (overrides char-based offset/max_chars)
if start_line is not None:
all_lines = content.split('\n')
total_lines = len(all_lines)
s = max(1, int(start_line)) - 1
e = min(total_lines, int(end_line) if end_line else s + 100)
selected = all_lines[s:e]
sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected))
is_truncated = e < total_lines
result = {
'id': file.id,
'filename': file.filename,
'content': sliced,
'updated_at': file.updated_at,
'created_at': file.created_at,
'total_lines': total_lines,
'showing_lines': f'{s + 1}-{e}',
}
if knowledge_info:
result['knowledge_id'] = knowledge_info['id']
result['knowledge_name'] = knowledge_info['name']
if is_truncated:
result['truncated'] = True
result['next_start_line'] = e + 1
return json.dumps(result, ensure_ascii=False)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
if line_numbers:
start_ln = content[:offset].count('\n') + 1
lines = sliced.split('\n')
sliced = '\n'.join(f'{start_ln + i}: {line}' for i, line in enumerate(lines))
result = {
'id': file.id,
'filename': file.filename,
+39 -24
View File
@@ -27,6 +27,42 @@ DEFAULT_TAIL_LINES = 10
MAX_GREP_MATCHES = 50
# =============================================================================
# SHARED REGEX UTILITIES — also used by builtin.py grep_knowledge_files
# =============================================================================
def is_regex_pattern(pattern: str) -> bool:
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
return ('\|' in pattern or '.*' in pattern or '.+' in pattern
or '.?' in pattern or '\d' in pattern or '\w' in pattern
or '\s' in pattern or bool(re.search(r'\[.+\]', pattern)))
def normalize_regex(pattern: str) -> str:
"""Normalize POSIX BRE patterns to Python regex (\| → |)."""
return pattern.replace('\\|', '|').replace('\|', '|')
def build_matcher(pattern: str, case_insensitive: bool = False,
use_regex: bool = False) -> tuple:
"""Build a matcher function. Returns (match_fn, error_str_or_None)."""
if not use_regex and is_regex_pattern(pattern):
use_regex = True
if use_regex:
normalized = normalize_regex(pattern)
try:
re_flags = re.IGNORECASE if case_insensitive else 0
compiled = re.compile(normalized, re_flags)
except re.error as e:
return None, f'Invalid regex: {e}'
return (lambda line: bool(compiled.search(line))), None
else:
sp = pattern.lower() if case_insensitive else pattern
return (lambda line: sp in (line.lower() if case_insensitive else line)), None
# =============================================================================
# COMMAND PARSING
# =============================================================================
@@ -404,30 +440,9 @@ async def _kb_grep(args: list[str], flags: set[str], user: dict,
count_only = 'c' in flags
use_regex = 'E' in flags
# Auto-detect regex: models commonly use \| for alternation (POSIX BRE style),
# .* / .+ for wildcards, \d \w \s for character classes, or [...] brackets.
# If any of these are present, auto-promote to regex mode.
if not use_regex and ('\\|' in pattern or '.*' in pattern or '.+' in pattern
or '.?' in pattern or '\\d' in pattern or '\\w' in pattern
or '\\s' in pattern or re.search(r'\[.+\]', pattern)):
use_regex = True
# Build matcher
if use_regex:
# Normalize \| to | (models trained on POSIX grep use \| for alternation)
normalized = pattern.replace('\\|', '|').replace('\|', '|')
try:
re_flags = re.IGNORECASE if case_insensitive else 0
compiled = re.compile(normalized, re_flags)
except re.error as e:
return f'Invalid regex: {e}'
def _matches(line: str) -> bool:
return bool(compiled.search(line))
else:
search_pattern = pattern.lower() if case_insensitive else pattern
def _matches(line: str) -> bool:
s = line.lower() if case_insensitive else line
return search_pattern in s
_matches, err = build_matcher(pattern, case_insensitive, use_regex)
if err:
return err
# Grep on piped input
if piped_input is not None:
+4 -2
View File
@@ -59,6 +59,7 @@ from open_webui.tools.builtin import (
fetch_url,
generate_image,
get_current_timestamp,
grep_knowledge_files,
kb_exec,
list_automations,
list_knowledge,
@@ -441,7 +442,7 @@ async def get_builtin_tools(
if not model_knowledge:
builtin_functions.append(query_knowledge_bases)
elif model_knowledge:
builtin_functions.extend([list_knowledge, search_knowledge_files, query_knowledge_files])
builtin_functions.extend([list_knowledge, search_knowledge_files, grep_knowledge_files, query_knowledge_files])
knowledge_types = {item.get('type') for item in model_knowledge}
if 'file' in knowledge_types or 'collection' in knowledge_types:
@@ -451,7 +452,8 @@ async def get_builtin_tools(
else:
builtin_functions.extend([
list_knowledge_bases, search_knowledge_bases, query_knowledge_bases,
search_knowledge_files, query_knowledge_files, view_knowledge_file,
grep_knowledge_files, search_knowledge_files, query_knowledge_files,
view_knowledge_file,
])
# Chats tools - search and fetch user's chat history