diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index c2d18366a4..999cca0a1c 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3136,6 +3136,67 @@ async def view_knowledge_file( return JSONCodec.dumps({'error': str(e)}) +async def display_file( + path: str, + page: int | None = None, + inline: bool = False, + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: list[dict] | None = None, +) -> str: + """ + Show a terminal, chat, or knowledge base file to the user. + After display_file succeeds, do not display the same file again or emit Markdown for it. + + :param path: Terminal file path, or file_id or /api/v1/files//content for a chat or knowledge base file + :param page: For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number + :param inline: Show the file inline in chat instead of opening the file viewer + :return: JSON with the file's path, name, content type, and display state + """ + if __request__ is None: + return JSONCodec.dumps({'error': 'Request context not available', 'exists': False}) + + if not __user__: + return JSONCodec.dumps({'error': 'User context not available', 'exists': False}) + + try: + from open_webui.models.files import Files + + if '/' not in path: + path = f'/api/v1/files/{path}/content' + parts = path.split('/') + if len(parts) != 6 or parts[:4] != ['', 'api', 'v1', 'files'] or parts[-1] != 'content': + return JSONCodec.dumps( + {'error': 'Use file_id or /api/v1/files//content for an uploaded file.', 'exists': False} + ) + + file = await Files.get_file_by_id(parts[4]) + if not file: + return JSONCodec.dumps({'error': 'File not found', 'exists': False}) + + if not await _has_read_access_to_file(file, __user__, __model_knowledge__): + return JSONCodec.dumps({'error': 'File not found', 'exists': False}) + + file_meta = file.meta or {} + result = { + 'type': 'file', + 'source': 'open_webui', + 'exists': True, + 'displayed': inline, + 'path': path, + 'full_path': path, + 'name': file_meta.get('name') or file.filename, + 'content_type': file_meta.get('content_type') or 'application/octet-stream', + 'mime_type': file_meta.get('content_type') or 'application/octet-stream', + } + if page: + result['page'] = page + return JSONCodec.dumps(result, ensure_ascii=False) + except Exception as e: + log.exception(f'display_file error: {e}') + return JSONCodec.dumps({'error': str(e), 'exists': False}) + + async def list_knowledge( knowledge_id: Optional[str] = None, skip: int = 0, diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 7b27832b08..31ed987dc8 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -155,7 +155,9 @@ from open_webui.utils.tool_approval import complete_tool_call, pending_tool_call from open_webui.utils.tool_search import apply_tool_search, resolve_tool_call from open_webui.utils.tools import ( connect_mcp_server, + display_file, get_attached_knowledge, + get_builtin_tool_spec, get_builtin_tools, get_terminal_tools, get_tools, @@ -332,7 +334,8 @@ def build_terminal_file_tool_result( **tool_result, 'type': 'file', 'source': 'open_terminal', - **({'displayed': True} if tool_function_params.get('inline') is True else {}), + 'exists': True, + 'displayed': tool_function_params.get('inline') is True, 'terminal_selector': terminal_selector, **({'terminal_id': terminal_id} if terminal_id else {}), **({'terminal_url': server_url} if server_url and not terminal_id else {}), @@ -979,6 +982,7 @@ async def terminal_event_handler( { 'type': f'terminal:{tool_function_name}', 'data': { + **result, 'path': path, **({'page': page} if page else {}), }, @@ -2810,7 +2814,11 @@ async def process_chat_payload(request, form_data, user, metadata, model): for tool in tool_specs: tools_dict[tool['name']] = { - 'spec': tool, + 'spec': ( + get_builtin_tool_spec(display_file) + if tool_server.get('is_terminal') and tool['name'] == 'display_file' + else tool + ), 'direct': True, 'server': tool_server, } @@ -2864,6 +2872,11 @@ async def process_chat_payload(request, form_data, user, metadata, model): for name, tool_dict in builtin_tools.items(): if name not in tools_dict: tools_dict[name] = tool_dict + elif name == 'display_file' and ( + tools_dict[name].get('type') == 'terminal' + or tools_dict[name].get('server', {}).get('is_terminal') is True + ): + tools_dict[name] = {**tools_dict[name], 'file_tool': tool_dict} # Connections can keep shell tools advertised across browser disconnects for prompt caching. shell_tools = { @@ -3103,6 +3116,14 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call): tool = metadata.get('tools', {}).get(name) if not tool: return params, f'Error: Tool "{name}" not found.', None, None, False + if name == 'display_file' and ( + str(params.get('path', '')).startswith('/api/v1/files/') + or re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', str(params.get('path', ''))) + ): + if tool.get('file_tool'): + tool = tool['file_tool'] + elif tool.get('type') == 'terminal' or tool.get('server', {}).get('is_terminal') is True: + return params, 'Error: Uploaded file display is not available.', None, None, False tool_type = tool.get('type', '') direct_tool = tool.get('direct', False) # MCP servers validate their own arguments diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index faedc23853..f04b7e07c7 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -56,6 +56,7 @@ from open_webui.tools.builtin import ( delete_automation, delete_calendar_event, delete_memory, + display_file, edit_image, execute_code, fetch_url, @@ -715,7 +716,7 @@ async def get_builtin_tools( and has_chat_files and await has_user_chat_permission('file_upload') ): - builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file]) + builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file, display_file]) # Knowledge base tools - conditional injection based on model knowledge # If model has attached knowledge (any type), only provide query_knowledge_files @@ -727,6 +728,7 @@ async def get_builtin_tools( if ENABLE_KB_EXEC: builtin_functions.append(kb_exec) builtin_functions.append(query_knowledge_files) + builtin_functions.append(display_file) # Notes attached to the model need view_note since kb_exec is file-only if model_knowledge: knowledge_types = {item.get('type') for item in model_knowledge} @@ -742,7 +744,7 @@ async def get_builtin_tools( knowledge_types = {item.get('type') for item in model_knowledge} if 'file' in knowledge_types or 'collection' in knowledge_types: - builtin_functions.extend([view_file, view_knowledge_file]) + builtin_functions.extend([view_file, view_knowledge_file, display_file]) if 'note' in knowledge_types: builtin_functions.append(view_note) else: @@ -755,6 +757,7 @@ async def get_builtin_tools( search_knowledge_files, query_knowledge_files, view_knowledge_file, + display_file, ] ) @@ -1075,32 +1078,6 @@ def clean_openai_tool_schema(spec: dict) -> dict: return cleaned_spec -def add_terminal_display_file_inline_param(spec: dict) -> dict: - spec = copy.deepcopy(spec) - if spec.get('name') != 'display_file': - return spec - - spec['description'] = ( - f'{spec.get("description", "")} ' - 'Set inline=true when the file should be shown inline in the chat message instead of opening the file viewer. ' - 'Set page for PDF, DOCX, and PPTX files when you want the preview to open at a specific 1-based page or slide. ' - 'After display_file succeeds, do not display the same file again or emit Markdown for it.' - ).strip() - parameters = spec.setdefault('parameters', {'type': 'object', 'properties': {}, 'required': []}) - parameters.setdefault('type', 'object') - properties = parameters.setdefault('properties', {}) - properties['inline'] = { - 'type': 'boolean', - 'description': 'Show the file inline in the chat message instead of opening the file viewer.', - } - properties['page'] = { - 'type': 'integer', - 'minimum': 1, - 'description': 'For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number.', - } - return spec - - @cache def get_builtin_function_introspection(func: Callable): try: @@ -1587,7 +1564,9 @@ async def get_terminal_tools( tools_dict = {} for spec in specs: function_name = spec['name'] - tool_spec = clean_openai_tool_schema(add_terminal_display_file_inline_param(spec)) + tool_spec = clean_openai_tool_schema( + get_builtin_tool_spec(display_file) if function_name == 'display_file' else spec + ) if function_name == 'run_command' and terminal_cwd: tool_spec['description'] = ( @@ -1748,9 +1727,7 @@ async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str, response = { 'openapi': response, 'info': response.get('info', {}), - 'specs': [ - add_terminal_display_file_inline_param(spec) for spec in convert_openapi_to_tool_payload(response) - ], + 'specs': convert_openapi_to_tool_payload(response), } openapi_data = response.get('openapi', {}) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index ba296f72c1..751e7c0e4d 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -136,6 +136,7 @@ import NotificationToast from '../NotificationToast.svelte'; import Spinner from '../common/Spinner.svelte'; import Modal from '../common/Modal.svelte'; + import ToolOutputFile from './Messages/ToolOutputFile.svelte'; import { isEmbedWindow } from '../common/FullHeightIframe.svelte'; import Tooltip from '../common/Tooltip.svelte'; import Sidebar from '../icons/Sidebar.svelte'; @@ -522,6 +523,8 @@ let params = {}; let chatVariables = {}; let showChatVariablesModal = false; + let showDisplayedFile = false; + let displayedFile: Record | null = null; let loadedChatIdProp = ''; let currentDraftKey = ''; @@ -1295,6 +1298,13 @@ }; const terminalEventHandler = (type: string, data: any, message: any) => { + if (type === 'terminal:display_file' && data?.source === 'open_webui') { + if ($settings?.terminalFileDisplay !== 'inline') { + displayedFile = data; + showDisplayedFile = true; + } + return; + } const attachment = history.messages[message?.parentId]?.files?.find( (file: any) => file.type === 'filesystem' ); @@ -5052,6 +5062,17 @@ + + + + + {#if getChatVariablesForm(selectedModelIds, chatVariables, $models).conflicts.length > 0}
diff --git a/src/lib/components/chat/Messages/StructuredOutputRenderer.svelte b/src/lib/components/chat/Messages/StructuredOutputRenderer.svelte index c11575cdf7..19080ede50 100644 --- a/src/lib/components/chat/Messages/StructuredOutputRenderer.svelte +++ b/src/lib/components/chat/Messages/StructuredOutputRenderer.svelte @@ -1,7 +1,7 @@