This commit is contained in:
Timothy Jaeryang Baek
2026-08-24 18:06:59 -04:00
parent 98ee2bdfd3
commit fd8cc2ba4a
14 changed files with 117 additions and 18 deletions
+7 -1
View File
@@ -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'):
+3
View File
@@ -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
+1 -1
View File
@@ -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);
+12 -5
View File
@@ -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}
@@ -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 @@
</audio>
</div>
{:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileDocxData !== null}
<DocxPreview data={fileDocxData} className="w-full h-full" />
<DocxPreview data={fileDocxData} {targetPage} className="w-full h-full" />
{:else if fileOfficeHtml !== null}
<div class="flex flex-col h-full">
<div class="office-preview overflow-auto flex-1 min-h-0">
@@ -353,6 +354,7 @@
bind:this={pptxPreviewRef}
slides={fileOfficeSlides}
bind:currentSlide
{targetPage}
className="w-full h-full"
/>
{:else if fileContent !== null}
@@ -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}
@@ -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<string, OutputItem>) {
@@ -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(() => {
+19 -1
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import panzoom, { type PanZoom } from 'panzoom';
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
import Spinner from './Spinner.svelte';
export let url: string | null = null;
export let data: ArrayBuffer | Uint8Array | null = null;
export let className = 'w-full h-[70vh]';
export let targetPage: number | null = null;
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
@@ -98,6 +100,17 @@
}
};
const scrollToTargetPage = async () => {
if (!outerContainer || !sceneElement || !pdfDoc) return;
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
if (!page) return;
await tick();
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
HTMLElement | undefined;
pageWrapper?.scrollIntoView({ block: 'start' });
};
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
const rerenderPages = async (forZoom: number) => {
if (!pdfDoc || !sceneElement) return;
@@ -223,6 +236,7 @@
lastRenderedZoom = 1;
initPanzoom();
await scrollToTargetPage();
};
const loadPdf = async () => {
@@ -258,6 +272,10 @@
loadPdf();
});
$: if (!loading && pdfDoc && targetPage) {
void scrollToTargetPage();
}
onDestroy(() => {
if (rerenderTimer) clearTimeout(rerenderTimer);
pzInstance?.dispose();
+17 -1
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
export let slides: string[] = [];
export let currentSlide = 0;
export let className = '';
export let targetPage: number | null = null;
let rootEl: HTMLDivElement;
let stageEl: HTMLElement;
@@ -22,6 +24,8 @@
let wheelDelta = 0;
let lastWheelNavigationAt = 0;
let lastScrolledSlide = -1;
let appliedTargetPage: number | null = null;
let appliedTargetSlides: string[] | null = null;
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
const slideShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
const wheelNavigationThreshold = 80;
@@ -158,7 +162,7 @@
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
e.preventDefault();
pzInstance?.moveBy(-e.deltaX, -e.deltaY);
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
zoomLevel = pzInstance?.getTransform().scale ?? 1;
return;
}
@@ -203,6 +207,18 @@
void tick().then(scrollSelectedThumbnailIntoView);
}
$: if (
mounted &&
targetPage &&
slides.length > 0 &&
(targetPage !== appliedTargetPage || slides !== appliedTargetSlides)
) {
appliedTargetPage = targetPage;
appliedTargetSlides = slides;
const page = clampDocumentTargetPage(targetPage, slides.length);
if (page) selectSlide(page - 1);
}
onDestroy(() => {
pzInstance?.dispose();
resizeObserver?.disconnect();
+2 -1
View File
@@ -140,7 +140,8 @@ export const showOverview = writable(false);
export const showArtifacts = writable(false);
export const showCallOverlay = writable(false);
export const showFileNav = writable(false);
export const showFileNavPath: Writable<string | null> = writable(null);
export type FileNavOpenRequest = string | { path: string; page?: number | null };
export const showFileNavPath: Writable<FileNavOpenRequest | null> = writable(null);
export const showFileNavDir: Writable<string | null> = writable(null);
export const selectedTerminalId: Writable<string | null> = writable(null);
+21
View File
@@ -0,0 +1,21 @@
export const normalizeDocumentTargetPage = (page: unknown): number | null => {
if (page === undefined || page === null || page === '') {
return null;
}
const value = Number(page);
if (!Number.isFinite(value)) {
return null;
}
const targetPage = Math.trunc(value);
return targetPage > 0 ? targetPage : null;
};
export const clampDocumentTargetPage = (page: number | null | undefined, pageCount: number) => {
if (!page || pageCount < 1) {
return null;
}
return Math.min(Math.max(1, page), pageCount);
};
+6 -2
View File
@@ -3,6 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
import sha256 from 'js-sha256';
import DOMPurify from 'dompurify';
import { WEBUI_BASE_URL } from '$lib/constants';
import type { FileNavOpenRequest } from '$lib/stores';
import { normalizeDocumentTargetPage } from '$lib/utils/documentPreview';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -2321,10 +2323,12 @@ export const formatSkillName = (name) => {
*/
export const displayFileHandler = (
path: string,
stores: { showControls: Writable<boolean>; showFileNavPath: Writable<string | null> }
stores: { showControls: Writable<boolean>; showFileNavPath: Writable<FileNavOpenRequest | null> },
options: { page?: unknown } = {}
) => {
if (path) {
stores.showControls.set(true);
stores.showFileNavPath.set(path);
const page = normalizeDocumentTargetPage(options.page);
stores.showFileNavPath.set(page ? { path, page } : path);
}
};
+3 -2
View File
@@ -481,7 +481,8 @@
full_path: result?.full_path ?? path,
name,
mime_type: contentType,
content_type: contentType
content_type: contentType,
page: result?.page ?? params?.page
};
};
@@ -519,7 +520,7 @@
if (data?.name === 'display_file' && params?.path && !inlineDisplayFile) {
if (result?.exists !== false) {
displayFileHandler(params.path, { showControls, showFileNavPath });
displayFileHandler(params.path, { showControls, showFileNavPath }, { page: params?.page });
}
}