This commit is contained in:
Timothy Jaeryang Baek
2026-08-27 15:34:20 -04:00
parent 240c795efe
commit 061fb43432
5 changed files with 231 additions and 30 deletions
+23
View File
@@ -292,6 +292,29 @@ export const downloadFileBlob = async (
return { blob, filename };
};
export const downloadFilePreview = async (
baseUrl: string,
apiKey: string,
path: string,
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`;
const headers: Record<string, string> = bearerHeaders(apiKey);
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res) return null;
if (!res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
const filename = path.split('/').pop() ?? 'file';
if (!contentType.includes('application/pdf')) return null;
const blob = await res.blob().catch(() => null);
if (!blob) return null;
return { blob, filename };
};
export const archiveFromTerminal = async (
baseUrl: string,
apiKey: string,
+59 -20
View File
@@ -23,6 +23,7 @@
listFiles,
readFile,
downloadFileBlob,
downloadFilePreview,
archiveFromTerminal,
uploadToTerminal,
createDirectory,
@@ -865,19 +866,38 @@
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
try {
if (ext === 'docx') {
const ext = getFileExt(filePath);
try {
if (ext === 'docx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
fileDocxData = arrayBuffer;
} else if (ext === 'xlsx') {
toast.info($i18n.t('Preview may differ from download.'));
}
} else if (ext === 'xlsx') {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const arrayBuffer = await result.blob.arrayBuffer();
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
@@ -889,16 +909,35 @@
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
}
} else if (ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
currentSlide = 0;
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
} else if (ext === 'pptx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const fallback = await pptxToImages(arrayBuffer);
fileOfficeSlides = fallback.images;
currentSlide = 0;
toast.info($i18n.t('Preview may differ from download.'));
}
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
@@ -8,6 +8,7 @@
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PptxPdfPreview from '../../common/PptxPdfPreview.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import DocxPreview from '../../common/DocxPreview.svelte';
import PptxPreview from '../../common/PptxPreview.svelte';
@@ -17,6 +18,7 @@
import FileCodeEditor from './FileCodeEditor.svelte';
let pdfViewerRef: PDFViewer;
let pptxPdfPreviewRef: PptxPdfPreview;
let fileCodeEditorRef: FileCodeEditor;
const i18n = getContext('i18n');
@@ -116,6 +118,7 @@
$: isNotebook = getExt(selectedFile) === 'ipynb';
$: isCode = isCodeFile(selectedFile);
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
$: isPptx = getExt(selectedFile) === 'pptx';
// For HTML files on system terminals (proxy URL), use path-based serving
// so the iframe can resolve relative CSS/JS/image references via cookie auth.
@@ -279,6 +282,7 @@
};
export const resetPdfView = () => {
pptxPdfPreviewRef?.resetView();
pdfViewerRef?.resetView();
};
</script>
@@ -286,6 +290,7 @@
<div
class="flex-1 {fileImageUrl !== null ||
fileDocxData !== null ||
(filePdfData !== null && isPptx) ||
(fileOfficeSlides !== null && fileOfficeSlides.length > 0)
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
@@ -321,7 +326,22 @@
</audio>
</div>
{:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
{#if isPptx}
<PptxPdfPreview
bind:this={pptxPdfPreviewRef}
data={filePdfData}
bind:currentSlide
{targetPage}
className="w-full h-full"
/>
{:else}
<PDFViewer
bind:this={pdfViewerRef}
data={filePdfData}
{targetPage}
className="w-full h-full"
/>
{/if}
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileDocxData !== null}
@@ -10,7 +10,7 @@
showFileNavPath,
terminalServers
} from '$lib/stores';
import { downloadFileBlob, readFile } from '$lib/apis/terminal';
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
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';
@@ -143,26 +143,61 @@
else if (isVideo(path)) fileVideoUrl = url;
else fileAudioUrl = url;
} else if (isPdf(path) || isSqlite(path) || isOffice(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const ext = getExt(path);
if (isPdf(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
filePdfData = arrayBuffer;
} else if (isSqlite(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
fileSqliteData = arrayBuffer;
} else if (ext === 'docx') {
fileDocxData = arrayBuffer;
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
fileDocxData = await result.blob.arrayBuffer();
toast.info(t('Preview may differ from download.'));
}
} else if (ext === 'xlsx' || ext === 'xls') {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const XLSX = await import('xlsx');
excelWorkbook = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelSheetNames = excelWorkbook.SheetNames;
if (excelSheetNames.length > 0) await loadExcelSheet(excelSheetNames[0]);
if (excelSheetNames.length > 0) {
await loadExcelSheet(excelSheetNames[0]);
}
} else if (ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const resultImages = await pptxToImages(arrayBuffer);
fileOfficeSlides = resultImages.images;
toast.info(t('Preview may differ from download.'));
}
}
} else if (terminal) {
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
@@ -0,0 +1,84 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import Spinner from './Spinner.svelte';
import PptxPreview from './PptxPreview.svelte';
export let data: ArrayBuffer | Uint8Array | null = null;
export let currentSlide = 0;
export let className = '';
export let targetPage: number | null = null;
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
let loading = false;
let error = '';
let slides: string[] = [];
let pptxPreviewRef: PptxPreview;
let pdfDoc: PdfDocument | null = null;
let loadToken = 0;
const loadPdf = async (pdfData: ArrayBuffer | Uint8Array) => {
const token = ++loadToken;
loading = true;
error = '';
slides = [];
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
pdfDoc?.destroy();
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
const renderedSlides: string[] = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
if (token !== loadToken) return;
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
renderedSlides.push(canvas.toDataURL('image/png'));
}
if (token === loadToken) slides = renderedSlides;
} catch (e) {
console.error('PDF deck render error:', e);
if (token === loadToken) error = 'Failed to load preview.';
} finally {
if (token === loadToken) loading = false;
}
};
$: if (data) {
void loadPdf(data);
}
export const resetView = () => {
pptxPreviewRef?.resetView();
};
onDestroy(() => {
loadToken++;
pdfDoc?.destroy();
});
</script>
{#if loading}
<div class="flex h-full items-center justify-center {className}">
<Spinner className="size-5" />
</div>
{:else if error}
<div class="flex h-full items-center justify-center text-sm text-red-500 {className}">
{error}
</div>
{:else}
<PptxPreview bind:this={pptxPreviewRef} {slides} bind:currentSlide {targetPage} {className} />
{/if}