Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ const LOCALES = {
mcp_deleted: 'MCP server deleted.',
mcp_delete_failed: 'Failed to delete MCP server.',
mcp_load_failed: 'Failed to load MCP servers.',
// PDF preview (#480)
pdf_loading: 'Loading PDF {0}…',
pdf_too_large: 'PDF too large for inline preview',
pdf_no_pages: 'PDF has no pages',
pdf_error: 'Failed to render PDF preview',
pdf_download: 'Download PDF',
// HTML sandbox preview (#482)
html_loading: 'Loading HTML preview…',
html_too_large: 'HTML too large for inline preview',
html_error: 'Failed to render HTML preview',
html_open_full: 'Open full page',
html_sandbox_label: 'HTML Preview (sandboxed)',
thinking: 'Thinking',
expand_all: 'Expand all',
collapse_all: 'Collapse all',
Expand Down Expand Up @@ -1425,6 +1437,17 @@ const LOCALES = {
composer_disabled_clarify: 'Ответьте на запрос о разъяснении',
composer_disabled_compression: 'Ожидание завершения сжатия',
composer_disabled_empty: 'Введите сообщение для отправки',

pdf_loading: 'Загрузка PDF {0}…',
pdf_too_large: 'PDF слишком большой для встроенного просмотра',
pdf_no_pages: 'Не удалось отобразить предварительный просмотр PDF',
pdf_error: 'Не удалось загрузить PDF',
pdf_download: 'Скачать PDF',
html_loading: 'Загрузка предпросмотра HTML…',
html_too_large: 'HTML слишком большой для встроенного просмотра',
html_error: 'Не удалось загрузить предпросмотр HTML',
html_open_full: 'Открыть на всю страницу',
html_sandbox_label: 'Предпросмотр HTML',
},

es: {
Expand Down Expand Up @@ -2103,6 +2126,17 @@ const LOCALES = {
composer_disabled_clarify: 'Responde a la solicitud de aclaración',
composer_disabled_compression: 'Esperando a que finalice la compresión',
composer_disabled_empty: 'Escribe un mensaje para enviar',

pdf_loading: 'Cargando PDF {0}…',
pdf_too_large: 'PDF demasiado grande para vista previa',
pdf_no_pages: 'No se pudo renderizar la vista previa del PDF',
pdf_error: 'Error al cargar el PDF',
pdf_download: 'Descargar PDF',
html_loading: 'Cargando vista previa de HTML…',
html_too_large: 'HTML demasiado grande para vista previa',
html_error: 'Error al cargar la vista previa de HTML',
html_open_full: 'Abrir página completa',
html_sandbox_label: 'Vista previa de HTML',
},

de: {
Expand Down Expand Up @@ -2556,6 +2590,17 @@ const LOCALES = {
composer_disabled_clarify: 'Auf die Klärungsanfrage antworten',
composer_disabled_compression: 'Warte auf Abschluss der Komprimierung',
composer_disabled_empty: 'Nachricht eingeben zum Senden',

pdf_loading: 'PDF wird geladen {0}…',
pdf_too_large: 'PDF zu groß für Inline-Vorschau',
pdf_no_pages: 'PDF-Vorschau konnte nicht gerendert werden',
pdf_error: 'PDF konnte nicht geladen werden',
pdf_download: 'PDF herunterladen',
html_loading: 'HTML-Vorschau wird geladen…',
html_too_large: 'HTML zu groß für Inline-Vorschau',
html_error: 'HTML-Vorschau konnte nicht geladen werden',
html_open_full: 'Vollständige Seite öffnen',
html_sandbox_label: 'HTML-Vorschau',
},

zh: {
Expand Down Expand Up @@ -3231,6 +3276,17 @@ const LOCALES = {
composer_disabled_clarify: '请回复上方的澄清请求',
composer_disabled_compression: '等待压缩完成',
composer_disabled_empty: '请输入消息后发送',

pdf_loading: '正在加载 PDF {0}…',
pdf_too_large: 'PDF 文件过大,无法内联预览',
pdf_no_pages: '无法渲染 PDF 预览',
pdf_error: 'PDF 加载失败',
pdf_download: '下载 PDF',
html_loading: '正在加载 HTML 预览…',
html_too_large: 'HTML 文件过大,无法内联预览',
html_error: 'HTML 预览加载失败',
html_open_full: '打开完整页面',
html_sandbox_label: 'HTML 预览',
},

// Traditional Chinese (zh-Hant)
Expand Down Expand Up @@ -3957,6 +4013,17 @@ const LOCALES = {
composer_disabled_clarify: '\u8acb\u56de\u8986\u4e0a\u65b9\u7684\u6f84\u6e05\u8981\u6c42',
composer_disabled_compression: '\u7b49\u5f85\u58d3\u7e2e\u5b8c\u6210',
composer_disabled_empty: '\u8acb\u8f38\u5165\u8a0a\u606f\u5f8c\u50b3\u9001',

pdf_loading: '正在載入 PDF {0}…',
pdf_too_large: 'PDF 檔案過大,無法內嵌預覽',
pdf_no_pages: '無法渲染 PDF 預覽',
pdf_error: 'PDF 載入失敗',
pdf_download: '下載 PDF',
html_loading: '正在載入 HTML 預覽…',
html_too_large: 'HTML 檔案過大,無法內嵌預覽',
html_error: 'HTML 預覽載入失敗',
html_open_full: '開啟完整頁面',
html_sandbox_label: 'HTML 預覽',
},


Expand Down Expand Up @@ -5322,6 +5389,17 @@ const LOCALES = {
composer_disabled_clarify: '위의 명확화 요청에 응답하세요',
composer_disabled_compression: '압축 완료 대기 중',
composer_disabled_empty: '메시지를 입력하세요',

pdf_loading: 'PDF {0} 로드 중…',
pdf_too_large: 'PDF가 인라인 미리보기에 너무 큼',
pdf_no_pages: 'PDF 미리보기를 렌더링할 수 없음',
pdf_error: 'PDF 로드 실패',
pdf_download: 'PDF 다운로드',
html_loading: 'HTML 미리보기 로드 중…',
html_too_large: 'HTML이 인라인 미리보기에 너무 큼',
html_error: 'HTML 미리보기 로드 실패',
html_open_full: '전체 페이지 열기',
html_sandbox_label: 'HTML 미리보기',
}
};

Expand Down
20 changes: 20 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2520,3 +2520,23 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
@media (hover:none) and (pointer:coarse){
input,textarea,select{font-size:max(16px,1em)!important;}
}

/* ── PDF preview ─────────────────────────────────────────────────────────── */
.pdf-preview-wrap{border:1px solid var(--border);border-radius:6px;overflow:hidden;margin:4px 0;background:var(--surface);}
.pdf-preview-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;}
.pdf-preview-header a{color:var(--accent);text-decoration:none;font-weight:500;}
.pdf-preview-header a:hover{text-decoration:underline;}
.pdf-preview-body{display:flex;justify-content:center;padding:8px;background:#fff;border-radius:0 0 6px 6px;max-height:500px;overflow:hidden;}
.pdf-preview-canvas{max-width:100%;height:auto;border-radius:2px;}
.pdf-preview-fallback{padding:8px;font-size:13px;}
.pdf-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
@keyframes pulse{0%,100%{opacity:0.4;}50%{opacity:1;}}

/* ── HTML sandbox preview ────────────────────────────────────────────────── */
.html-preview-wrap{border:1px solid var(--border);border-radius:6px;overflow:hidden;margin:4px 0;background:var(--surface);}
.html-preview-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;}
.html-preview-header a{color:var(--accent);text-decoration:none;font-weight:500;}
.html-preview-header a:hover{text-decoration:underline;}
.html-preview-iframe{width:100%;height:400px;border:none;display:block;background:#fff;}
.html-preview-fallback{padding:8px;font-size:13px;}
.html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
121 changes: 117 additions & 4 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ document.addEventListener('click', e => {
});

const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _PDF_EXTS=/\.pdf$/i;
const _HTML_EXTS=/\.(html?|htm)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;

// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
Expand Down Expand Up @@ -1199,6 +1201,14 @@ function renderMd(raw){
if(_IMAGE_EXTS.test(ref)){
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
}
// PDF files → render first page preview with lazy-load
if(_PDF_EXTS.test(ref)){
return `<div class="pdf-preview-load" data-path="${esc(ref)}"><span class="pdf-preview-spinner">⏳</span> ${t('pdf_loading')} ${fname}...</div>`;
}
// HTML files → render inline in sandboxed iframe with lazy-load
if(_HTML_EXTS.test(ref)){
return `<div class="html-preview-load" data-path="${esc(ref)}"><span class="html-preview-spinner">⏳</span> ${t('html_loading')}</div>`;
}
// Non-image local file — show download link with filename
const fname=esc(ref.split('/').pop()||ref);
// .patch/.diff files → render inline as colored diff instead of download
Expand Down Expand Up @@ -2505,8 +2515,8 @@ function renderMessages(){
inner.innerHTML=cached.html;
_sessionHtmlCacheSid=sid;
if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
return;
}
Expand Down Expand Up @@ -2898,8 +2908,8 @@ function renderMessages(){
scrollToBottom();
}
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
// Refresh todo panel if it's currently open
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
loadTodos();
Expand Down Expand Up @@ -3351,6 +3361,109 @@ function loadDiffInline(){
});
}

// ── PDF inline preview (first page) ────────────────────────────────────────
// NOTE: PDF.js is loaded from CDN (jsdelivr). Offline/air-gapped deployments
// will not get inline previews; the 15 s fallback timeout degrades to a
// download link in that case. The 4 MB size cap is checked client-side after
// the full buffer is received — ideally the server would enforce it before
// streaming (out of scope for this client-side PR).
let _pdfjsReady=false, _pdfjsLoading=false;
function loadPdfInline(){
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
const loadPdf=(pdfjsLib)=>{
fetch('api/media?path='+encodeURIComponent(path))
.then(r=>{if(!r.ok) throw new Error(r.status); return r.arrayBuffer();})
.then(buf=>{
if(buf.byteLength>PDF_MAX_SIZE){
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="api/media?path=${encodeURIComponent(path)}&download=1" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_too_large')}</span></div>`;
return;
}
return pdfjsLib.getDocument({data:buf}).promise;
})
.then(pdf=>{
if(!pdf) return;
pdf.getPage(1).then(page=>{
const canvas=document.createElement('canvas');
const scale=1.5;
const viewport=page.getViewport({scale});
canvas.width=viewport.width;
canvas.height=viewport.height;
canvas.className='pdf-preview-canvas';
page.render({canvasContext:canvas.getContext('2d'),viewport}).promise.then(()=>{
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
el.outerHTML=`<div class="pdf-preview-wrap"><div class="pdf-preview-header"><span>📄 ${esc(fname)}</span><a href="${dlUrl}" download="${esc(fname)}" class="pdf-download-link">${t('pdf_download')} ↓</a></div><div class="pdf-preview-body">${canvas.outerHTML}</div></div>`;
});
});
})
.catch(()=>{
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
});
};
// Lazy-load PDF.js from CDN
if(_pdfjsReady){
loadPdf(pdfjsLib);
} else if(!_pdfjsLoading){
_pdfjsLoading=true;
const s=document.createElement('script');
s.src='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.min.mjs';
s.type='module';
s.textContent=`
import * as pdfjsLib from '${s.src}';
pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs';
window._pdfjsLib=pdfjsLib;
window._pdfjsReady=true;
window.dispatchEvent(new Event('pdfjs-ready'));
`;
document.head.appendChild(s);
window.addEventListener('pdfjs-ready',()=>{ _pdfjsReady=true; loadPdf(window._pdfjsLib); },{once:true});
// Fallback timeout — if CDN fails after 15s, show download link
setTimeout(()=>{
if(!_pdfjsReady){
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
if(el.parentNode){
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
}
}
},15000);
} else {
// Already loading PDF.js, wait for ready event
window.addEventListener('pdfjs-ready',()=>{ loadPdf(window._pdfjsLib); },{once:true});
}
});
}

// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
function loadHtmlInline(){
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
fetch('api/media?path='+encodeURIComponent(path))
.then(r=>{if(!r.ok) throw new Error(r.status); return r.text();})
.then(html=>{
if(html.length>HTML_MAX_SIZE){
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_too_large')}</span></div>`;
return;
}
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
// Escape the HTML for safe embedding in srcdoc attribute
const safeHtml=html.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
el.outerHTML=`<div class="html-preview-wrap"><div class="html-preview-header"><span>${t('html_sandbox_label')}</span><a href="${dlUrl}" download="${esc(fname)}" class="html-open-link">${t('html_open_full')} ↗</a></div><iframe srcdoc="${safeHtml}" sandbox="allow-scripts" class="html-preview-iframe" loading="lazy"></iframe></div>`;
})
.catch(()=>{
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_error')}</span></div>`;
});
});
}

function renderMermaidBlocks(){
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
if(!blocks.length) return;
Expand Down
Loading
Loading