mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
be8ebed784
commit
f2cdbbebe3
7 changed files with 159 additions and 66 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue