diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f37e0d273f..8dedd75c13 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2462,6 +2462,7 @@ async def grep_chat_files( """ Search exact text across files attached to the current chat. Pass file_id from the attached_files block to search one file. + Auto-detected regex uses RE2 syntax; no lookarounds/backreferences, and shorthand classes are ASCII-only. :param pattern: The text pattern to search for :param file_id: Optional attached file ID to search within a single file @@ -2638,6 +2639,7 @@ async def grep_knowledge_files( 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+"). + Regex uses RE2 syntax; no lookarounds/backreferences, and shorthand character classes are ASCII-only. Helpful for literal strings, identifiers, error messages, or regex-style searches. :param pattern: The text pattern to search for (regex auto-detected) diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index 53440b7bbf..355ae3cc58 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -17,7 +17,7 @@ from collections.abc import Callable from contextlib import contextmanager from typing import Optional -import regex +import re2 from fastapi import Request from open_webui.env import ( @@ -31,12 +31,9 @@ log = logging.getLogger(__name__) DEFAULT_HEAD_LINES = 10 DEFAULT_TAIL_LINES = 10 -# Matching time allowed per tool call. Backtracking cost is exponential in the length of the -# matched text, so capping the pattern or the line does not bound it. +# Total matching time allowed per tool call, checked between RE2's linear-time searches. MATCH_BUDGET_SECONDS = 2.0 -MAX_REGEX_QUANTIFIER_COUNT = 2_000 -MAX_REGEX_QUANTIFIER_EXPANSION = 100_000 -_COUNTED_QUANTIFIER_RE = re.compile(r'(? str: return pattern.replace(r'\|', '|').replace(r'\|', '|') -def validate_regex_quantifiers(pattern: str) -> str | None: - """Reject counted quantifiers that make regex compilation expand too much.""" - quantifier_expansion = 1 - for quantifier in _COUNTED_QUANTIFIER_RE.finditer(pattern): - count_text = quantifier.group(1) - count = int(count_text) if len(count_text) <= 6 else MAX_REGEX_QUANTIFIER_COUNT + 1 - if count > MAX_REGEX_QUANTIFIER_COUNT: - return f'Regex quantifier counts over {MAX_REGEX_QUANTIFIER_COUNT:g} are not supported' - - # ponytail: conservative expansion catches nested quantifier bombs without mirroring regex syntax. - quantifier_expansion *= max(count, 1) - if quantifier_expansion > MAX_REGEX_QUANTIFIER_EXPANSION: - return 'Regex quantifiers expand too much, lower the counts' - - return None - - 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 len(pattern) > MAX_SEARCH_PATTERN_LENGTH: + return None, f'Search patterns over {MAX_SEARCH_PATTERN_LENGTH} characters are not supported' + if not use_regex and is_regex_pattern(pattern): use_regex = True if use_regex: normalized = normalize_regex(pattern) - quantifier_error = validate_regex_quantifiers(normalized) - if quantifier_error: - return None, quantifier_error try: - re_flags = regex.IGNORECASE if case_insensitive else 0 - compiled = regex.compile(normalized, re_flags) - except regex.error as e: - return None, f'Invalid regex: {e}' + options = re2.Options() + options.case_sensitive = not case_insensitive + options.max_mem = 1 << 20 # Bound compiled programs and the engine's matching cache to 1 MiB. + options.log_errors = False + compiled = re2.compile(normalized, options=options) + except re2.error as e: + return None, f'Invalid or unsupported regex (RE2 syntax): {e}' budget = _active_budget.get() or MatchBudget() def matches(line: str) -> bool: started = time.monotonic() try: - # A negative timeout disables it, so an exhausted budget must not reach search(). if budget.remaining <= 0: raise TimeoutError - return bool(compiled.search(line, timeout=budget.remaining)) + matched = bool(compiled.search(line)) + if time.monotonic() - started >= budget.remaining: + raise TimeoutError + return matched except TimeoutError: raise MatchBudgetExceeded(f'Search exceeded {MATCH_BUDGET_SECONDS:g}s, narrow the pattern') from None finally: @@ -1188,6 +1173,7 @@ async def kb_exec( Pipes: grep "auth" | head -5 Files: reference by path (docs/api/auth.md), filename, or file ID + Regex: RE2 syntax; no lookarounds/backreferences. Shorthand character classes are ASCII-only. :param command: A filesystem command string :return: Command output as text diff --git a/backend/requirements-slim.txt b/backend/requirements-slim.txt index 8171a6f9e6..e482bf330d 100644 --- a/backend/requirements-slim.txt +++ b/backend/requirements-slim.txt @@ -19,7 +19,8 @@ authlib==1.7.2 joserfc==1.7.4 requests==2.34.2 -regex==2026.5.9 # supports a per-search timeout, which `re` does not +regex==2026.5.9 +google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches aiohttp==3.13.5 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 diff --git a/backend/requirements.txt b/backend/requirements.txt index e929beafb6..11282b2807 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,7 +14,8 @@ authlib==1.7.2 joserfc==1.7.4 requests==2.34.2 -regex==2026.5.9 # supports a per-search timeout, which `re` does not +regex==2026.5.9 +google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches aiohttp==3.13.5 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 diff --git a/pyproject.toml b/pyproject.toml index 2fc362ae97..0f67b1b120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "tiktoken==0.13.0", "regex==2026.5.9", + "google-re2==1.1.20251105", "mcp==1.27.2", "openai==2.29.0", diff --git a/src/lib/components/chat/Messages/Message.svelte b/src/lib/components/chat/Messages/Message.svelte index 97c8cd69a4..30904ee27d 100644 --- a/src/lib/components/chat/Messages/Message.svelte +++ b/src/lib/components/chat/Messages/Message.svelte @@ -87,7 +87,6 @@ {compactPreview} {editCodeBlock} {topPadding} - {onInsertToNote} /> {:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1} {:else} {#key messageId} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 3a3930c48a..48af8d0304 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -49,6 +49,7 @@ import RateComment from './RateComment.svelte'; import WebSearchResults from './ResponseMessage/WebSearchResults.svelte'; import Sparkles from '$lib/components/icons/Sparkles.svelte'; + import ArrowUpLeft from '$lib/components/icons/ArrowUpLeft.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; @@ -943,6 +944,22 @@ class="flex items-center justify-start overflow-x-auto whitespace-nowrap buttons text-gray-600 dark:text-gray-500 mt-0.5 [&>*]:shrink-0" > {#if message.done || siblings.length > 1} + {#if message.done && onInsertToNote && visibleResponseContent} + + + + {/if} + {#if siblings.length > 1}
- - {/if} - {#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))}