diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index ebfdd6993d..d2dd962641 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -277,6 +277,7 @@ def build_terminal_file_tool_result( return None mime_type, _ = mimetypes.guess_type(path) mime_type = mime_type or 'application/octet-stream' + page = tool_result.get('page') or tool_function_params.get('page') return { **tool_result, @@ -292,6 +293,7 @@ def build_terminal_file_tool_result( 'name': tool_result.get('name') or os.path.basename(path), 'mime_type': tool_result.get('mime_type') or tool_result.get('content_type') or mime_type, 'content_type': tool_result.get('content_type') or tool_result.get('mime_type') or mime_type, + **({'page': page} if page else {}), } @@ -1242,11 +1244,15 @@ async def terminal_event_handler( pass if isinstance(parsed, dict) and parsed.get('exists') is False: return + page = tool_function_params.get('page') await event_emitter( { 'type': f'terminal:{tool_function_name}', - 'data': {'path': path}, + 'data': { + 'path': path, + **({'page': page} if page else {}), + }, } ) elif tool_function_name in ('write_file', 'replace_file_content'): diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 19de805ea6..3c3d973480 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -968,6 +968,9 @@ def add_terminal_display_file_inline_param(spec: dict) -> dict: properties['inline'] = { 'type': 'boolean', 'description': 'Show the file inline in the chat message instead of opening the file viewer.', + } + 'minimum': 1, + 'description': 'For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number.', } return spec diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 906637e75a..2c2cbb9b31 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1139,7 +1139,7 @@ const terminalEventHandler = (type: string, data: any) => { if (type === 'terminal:display_file') { if (!data?.path) return; - displayFileHandler(data.path, { showControls, showFileNavPath }); + displayFileHandler(data.path, { showControls, showFileNavPath }, { page: data?.page }); } else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') { if (!data?.path) return; showFileNavDir.set(data.path); diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 56e423ff63..ec892a0c5d 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -37,6 +37,7 @@ } from '$lib/apis/terminal'; import { isCodeFile } from '$lib/utils/codeHighlight'; import { isSavedChatId, isTemporaryChatId } from '$lib/utils/chatId'; + import { normalizeDocumentTargetPage } from '$lib/utils/documentPreview'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; @@ -302,6 +303,7 @@ let fileLoading = false; let filePreviewRef: FilePreview; let fileSearchTarget: FileSearchTarget | null = null; + let documentTargetPage: number | null = null; // ── Office preview state ──────────────────────────────────────────── let fileOfficeHtml: string | null = null; @@ -691,6 +693,7 @@ // ── File preview management ────────────────────────────────────────── const clearFilePreview = () => { fileSearchTarget = null; + documentTargetPage = null; fileContent = null; if (fileImageUrl) { URL.revokeObjectURL(fileImageUrl); @@ -810,7 +813,7 @@ } }; - const openEntry = async (entry: FileEntry) => { + const openEntry = async (entry: FileEntry, options: { page?: unknown } = {}) => { const fullPath = 'fullPath' in entry ? (entry as BrowserRow).fullPath : entryPath(currentPath, entry); const parentPath = 'parentPath' in entry ? (entry as BrowserRow).parentPath : currentPath; @@ -832,6 +835,7 @@ selectedFile = filePath; fileLoading = true; clearFilePreview(); + documentTargetPage = normalizeDocumentTargetPage(options.page); if (isImage(filePath)) { const result = await downloadFileBlob( @@ -1317,10 +1321,12 @@ let handledDisplayFile = false; - const unsubFileNav = showFileNavPath.subscribe(async (filePath) => { - if (!filePath || !selectedTerminal) return; + const unsubFileNav = showFileNavPath.subscribe(async (request) => { + if (!request || !selectedTerminal) return; handledDisplayFile = true; showFileNavPath.set(null); + let filePath = typeof request === 'string' ? request : request.path; + const targetPage = typeof request === 'string' ? null : request.page; filePath = normalizePath(filePath); if (!isInsideFileRoot(filePath)) { await loadDir(fileRoot?.path ?? '/'); @@ -1337,10 +1343,10 @@ const entry = entries.find((e) => e.name === fileName); if (entry) { - await openEntry(entry); + await openEntry(entry, { page: targetPage }); } else { // File may not be in listing; open it directly - await openEntry({ name: fileName, type: 'file', size: 0 }); + await openEntry({ name: fileName, type: 'file', size: 0 }, { page: targetPage }); } }); @@ -1741,6 +1747,7 @@ {fileContent} {fileOfficeHtml} {fileOfficeSlides} + targetPage={documentTargetPage} {excelSheetNames} {selectedExcelSheet} searchTarget={fileSearchTarget} diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index 8588c371cc..b5ea5507bb 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -39,6 +39,7 @@ export let fileOfficeHtml: string | null = null; export let fileOfficeSlides: string[] | null = null; export let currentSlide = 0; + export let targetPage: number | null = null; export let excelSheetNames: string[] = []; export let selectedExcelSheet = ''; export let onSheetChange: ((sheet: string) => void) | null = null; @@ -320,11 +321,11 @@ {:else if filePdfData !== null} - + {:else if fileSqliteData !== null} {:else if fileDocxData !== null} - + {:else if fileOfficeHtml !== null}
@@ -353,6 +354,7 @@ bind:this={pptxPreviewRef} slides={fileOfficeSlides} bind:currentSlide + {targetPage} className="w-full h-full" /> {:else if fileContent !== null} diff --git a/src/lib/components/chat/Messages/TerminalOutputFile.svelte b/src/lib/components/chat/Messages/TerminalOutputFile.svelte index ac41a4e135..4f4caf2ba1 100644 --- a/src/lib/components/chat/Messages/TerminalOutputFile.svelte +++ b/src/lib/components/chat/Messages/TerminalOutputFile.svelte @@ -8,6 +8,7 @@ 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'; + import { normalizeDocumentTargetPage } from '$lib/utils/documentPreview'; export let item: any; export let chatId = ''; @@ -42,6 +43,7 @@ $: path = String(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; @@ -165,7 +167,7 @@ function openInFiles() { if (unavailable || !path) return; showControls.set(true); - showFileNavPath.set(path); + showFileNavPath.set(targetPage ? { path, page: targetPage } : path); } async function downloadFile() { @@ -249,6 +251,7 @@ {fileOfficeHtml} {fileOfficeSlides} {currentSlide} + {targetPage} {excelSheetNames} {selectedExcelSheet} onSheetChange={loadExcelSheet} diff --git a/src/lib/components/chat/Messages/structuredOutput.ts b/src/lib/components/chat/Messages/structuredOutput.ts index 317bed19f7..fc02b57892 100644 --- a/src/lib/components/chat/Messages/structuredOutput.ts +++ b/src/lib/components/chat/Messages/structuredOutput.ts @@ -187,7 +187,7 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI return null; } - return result; + return result.page === undefined && args.page !== undefined ? { ...result, page: args.page } : result; } function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record) { diff --git a/src/lib/components/common/DocxPreview.svelte b/src/lib/components/common/DocxPreview.svelte index 02a204161e..7f40a1e36e 100644 --- a/src/lib/components/common/DocxPreview.svelte +++ b/src/lib/components/common/DocxPreview.svelte @@ -2,6 +2,7 @@ import DOMPurify from 'dompurify'; import { getContext, onDestroy, onMount, tick } from 'svelte'; import type { Readable } from 'svelte/store'; + import { clampDocumentTargetPage } from '$lib/utils/documentPreview'; import Spinner from './Spinner.svelte'; @@ -13,6 +14,7 @@ export let data: ArrayBuffer | null = null; export let className = ''; + export let targetPage: number | null = null; let outerContainer: HTMLDivElement; let containerEl: HTMLDivElement; @@ -84,6 +86,16 @@ updateFitScale(); }; + const scrollToTargetPage = async () => { + if (!containerEl) return; + const pages = containerEl.querySelectorAll('section.docx'); + const page = clampDocumentTargetPage(targetPage, pages.length); + if (!page) return; + + await tick(); + (pages[page - 1] as HTMLElement | undefined)?.scrollIntoView({ block: 'start' }); + }; + const renderDocx = async (arrayBuffer: ArrayBuffer | null) => { const currentRender = ++renderId; clearPreview(); @@ -112,6 +124,7 @@ }); await tick(); updateFitScale(); + await scrollToTargetPage(); } catch (e) { console.error('Error rendering DOCX preview:', e); @@ -130,6 +143,10 @@ $: if (mounted) renderDocx(data); + $: if (!loading && targetPage && !fallbackHtml) { + void scrollToTargetPage(); + } + onMount(() => { mounted = true; void tick().then(() => { diff --git a/src/lib/components/common/PDFViewer.svelte b/src/lib/components/common/PDFViewer.svelte index eb3ba5b3c3..0836efe81c 100644 --- a/src/lib/components/common/PDFViewer.svelte +++ b/src/lib/components/common/PDFViewer.svelte @@ -1,12 +1,14 @@