This commit is contained in:
Timothy Jaeryang Baek 2026-10-29 16:39:42 +09:00
parent be8ebed784
commit f2cdbbebe3
7 changed files with 159 additions and 66 deletions

View file

@ -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/<file_id>/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/<file_id>/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,

View file

@ -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

View file

@ -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', {})

View file

@ -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<string, unknown> | 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 @@
<audio id="audioElement" style="display: none;"></audio>
<Modal bind:show={showDisplayedFile} size="xl">
<button
class="float-right p-2"
aria-label={$i18n.t('Close')}
on:click={() => (showDisplayedFile = false)}
>
<XMark className="size-4" />
</button>
<ToolOutputFile item={displayedFile} chatId={$chatId} />
</Modal>
{#if getChatVariablesForm(selectedModelIds, chatVariables, $models).conflicts.length > 0}
<Modal bind:show={showChatVariablesModal} size="md">
<div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import TerminalOutputFile from './TerminalOutputFile.svelte';
import ToolOutputFile from './ToolOutputFile.svelte';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
import { settings } from '$lib/stores';
import { toast } from 'svelte-sonner';
@ -169,7 +169,7 @@
</ConsecutiveDetailsGroup>
{:else if displayItem.type === 'file'}
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
<TerminalOutputFile item={displayItem.item} {chatId} />
<ToolOutputFile item={displayItem.item} {chatId} />
{/if}
{:else if displayItem.type === 'context_compaction'}
<ContextCompactionLabel summary={displayItem.summary} {compactPreview} />

View file

@ -15,6 +15,7 @@
terminalServers
} from '$lib/stores';
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
import { getFileContentById } from '$lib/apis/files';
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import { fileIconName } from '$lib/components/chat/FileNav/fileIcon';
@ -51,13 +52,18 @@
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'xls', 'pptx']);
$: path = String(item?.full_path || item?.path || '');
$: fileId =
item?.source === 'open_webui'
? (String(item.path ?? '').match(/^\/api\/v1\/files\/([^/]+)\/content$/)?.[1] ?? '')
: '';
// Content URLs have no extension; use the filename to select the preview.
$: path = String(fileId ? item.name : item?.full_path || item?.path || '');
$: name = String(item?.name || path.split('/').filter(Boolean).at(-1) || 'file');
$: targetPage = normalizeDocumentTargetPage(item?.page);
$: selector = item?.terminal_selector;
$: terminal = resolveTerminal();
$: unavailable = !terminal;
$: previewKey = terminal ? `${terminal.url}|${terminal.key}|${path}|${chatId}` : '';
$: unavailable = !terminal && !fileId;
$: previewKey = fileId || (terminal ? `${terminal.url}|${terminal.key}|${path}|${chatId}` : '');
$: isPerChatTerminal =
($terminalServers ?? []).find((server: any) => server.id === selector)?.contexts?.chat
?.context_id === 'chat_id';
@ -74,7 +80,7 @@
getExt(path) === 'svg'
? 'h-96'
: 'h-72';
$: if (expanded && terminal && path && previewKey !== loadedKey && !loading) {
$: if (expanded && !unavailable && path && previewKey !== loadedKey && !loading) {
void loadPreview(previewKey);
}
@ -125,6 +131,12 @@
}
async function blobForPreview() {
if (fileId) {
const data = await getFileContentById(fileId).catch(() => null);
return data
? { blob: new Blob([data], { type: item?.content_type ?? '' }), filename: name }
: null;
}
if (!terminal) return null;
return downloadFileBlob(terminal.url, terminal.key, path, chatId || undefined);
}
@ -167,12 +179,9 @@
const arrayBuffer = await result.blob.arrayBuffer();
fileSqliteData = arrayBuffer;
} else if (ext === 'docx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
const preview = terminal
? await downloadFilePreview(terminal.url, terminal.key, path, chatId || undefined)
: null;
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
@ -191,12 +200,9 @@
await loadExcelSheet(excelSheetNames[0]);
}
} else if (ext === 'pptx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
const preview = terminal
? await downloadFilePreview(terminal.url, terminal.key, path, chatId || undefined)
: null;
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
@ -208,6 +214,10 @@
fileOfficeSlides = resultImages.images;
}
}
} else if (fileId) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
fileContent = await result.blob.text();
} else if (terminal) {
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
}
@ -225,8 +235,8 @@
}
async function downloadFile() {
if (!terminal || !path) return;
const result = await downloadFileBlob(terminal.url, terminal.key, path, chatId || undefined);
if (unavailable || !path) return;
const result = await blobForPreview();
if (!result) {
toast.error(t('Download failed'));
return;
@ -274,15 +284,17 @@
>
<Icon name="download" size={13} />
</button>
<button
type="button"
class="mr-1 flex size-6 shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-700 disabled:opacity-40 disabled:hover:text-gray-400 dark:text-gray-500 dark:hover:text-gray-200 dark:disabled:hover:text-gray-500"
disabled={unavailable}
on:click|stopPropagation={openInFiles}
aria-label={t('Open')}
>
<Icon name="external-link" size={13} />
</button>
{#if !fileId}
<button
type="button"
class="mr-1 flex size-6 shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-700 disabled:opacity-40 disabled:hover:text-gray-400 dark:text-gray-500 dark:hover:text-gray-200 dark:disabled:hover:text-gray-500"
disabled={unavailable}
on:click|stopPropagation={openInFiles}
aria-label={t('Open')}
>
<Icon name="external-link" size={13} />
</button>
{/if}
</div>
{#if expanded}

View file

@ -181,14 +181,15 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI
}
const result = parseJSONStringValue(getToolResultText(resultItem)) as Record<string, unknown>;
const isTerminalFile = result?.source === 'open_terminal' && !!result.terminal_selector;
const isOpenWebUIFile = result?.source === 'open_webui';
if (
!result ||
typeof result !== 'object' ||
result.type !== 'file' ||
result.source !== 'open_terminal' ||
result.exists === false ||
!result.path ||
!result.terminal_selector
result.exists === false ||
!(isTerminalFile || isOpenWebUIFile)
) {
return null;
}