From 1e34f938cc8a255a42f534c7c1d2fb84827082f5 Mon Sep 17 00:00:00 2001 From: red person Date: Tue, 3 Nov 2026 03:30:07 +0300 Subject: [PATCH] fix(chat): export the complete conversation across PDF pages (#6921) --- static/app.js | 13 +++++- static/css/02-compare.css | 1 + static/js/sessions.js | 59 +++++++++++++++++++++------- tests/test_chat_print_pagination.py | 61 +++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 tests/test_chat_print_pagination.py diff --git a/static/app.js b/static/app.js index e14121adb..6ba1dd0bc 100644 --- a/static/app.js +++ b/static/app.js @@ -561,10 +561,21 @@ function initializeEventListeners() { // Export: PDF const exportPdfBtn = el('export-pdf-btn'); + let preparingPdf = false; if (exportPdfBtn) { - exportPdfBtn.addEventListener('click', (e) => { + exportPdfBtn.addEventListener('click', async (e) => { e.stopPropagation(); exportMenu.classList.remove('open'); + if (preparingPdf) return; + preparingPdf = true; + try { + await sessionModule.prepareHistoryForPrint(); + } catch (error) { + uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.'); + return; + } finally { + preparingPdf = false; + } const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); const sessionName = meta ? meta.name : 'Odysseus Chat'; const originalTitle = document.title; diff --git a/static/css/02-compare.css b/static/css/02-compare.css index 21fd7b555..f15c24f22 100644 --- a/static/css/02-compare.css +++ b/static/css/02-compare.css @@ -1701,6 +1701,7 @@ /* ── Print / PDF Export ── */ @media print { + html, body { height: auto !important; overflow: visible !important; } body { background: #fff !important; color: #000 !important; } #sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper, #welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer, diff --git a/static/js/sessions.js b/static/js/sessions.js index c2426412f..d12f311be 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) { handler: null, }; - const loadOlder = async () => { - if (!_historyPager || _historyPager.loading || _historyPager.done) return; - if (_historyPager.sessionId !== currentSessionId) return; - if (box.scrollTop > 90) return; + const loadOlder = async (force = false) => { + const pager = _historyPager; + if (!pager || pager.loading || pager.done) return; + if (pager.sessionId !== currentSessionId) return; + if (!force && box.scrollTop > 90) return; - const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit); - const nextLimit = _historyPager.offset - nextOffset; + const nextOffset = Math.max(0, pager.offset - pager.limit); + const nextLimit = pager.offset - nextOffset; if (nextLimit <= 0) { - _historyPager.done = true; + pager.done = true; return; } - _historyPager.loading = true; + pager.loading = true; const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble'); const beforeHeight = box.scrollHeight; try { - const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset })); + const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset })); + if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`); const data = await res.json(); - if (!_historyPager || _historyPager.sessionId !== currentSessionId) return; + if (_historyPager !== pager || pager.sessionId !== currentSessionId) return; const newEls = []; for (const msg of data.history || []) { if (msg.role !== 'user' && msg.role !== 'assistant') continue; - const els = _renderHistoryMessage(msg, _historyPager.modelName); + const els = _renderHistoryMessage(msg, pager.modelName); if (Array.isArray(els)) newEls.push(...els); } for (const el of newEls) { box.insertBefore(el, anchor || box.firstChild); } - _historyPager.offset = Number(data.offset || nextOffset); - _historyPager.done = !data.has_more_before; + pager.offset = Number(data.offset || nextOffset); + pager.done = !data.has_more_before; if (window.hljs) { newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block))); } @@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) { } catch (e) { console.warn('Failed to load older chat history:', e); } finally { - if (_historyPager) _historyPager.loading = false; + pager.loading = false; } }; + _historyPager.loadOlder = loadOlder; _historyPager.handler = () => { - if (box.scrollTop <= 90) loadOlder(); + if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) { + _historyPager.pending = loadOlder(); + } }; box.addEventListener('scroll', _historyPager.handler, { passive: true }); } +export async function prepareHistoryForPrint() { + const pager = _historyPager; + if (!pager) return; + const ensureCurrentChat = () => { + if (_historyPager !== pager || currentSessionId !== pager.sessionId) { + throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.'); + } + }; + while (!pager.done) { + await pager.pending; + ensureCurrentChat(); + if (pager.done) break; + const previousOffset = pager.offset; + pager.pending = pager.loadOlder(true); + await pager.pending; + ensureCurrentChat(); + if (!pager.done && pager.offset >= previousOffset) { + throw new Error('Could not load the complete chat for PDF export. Please try again.'); + } + } + ensureCurrentChat(); +} + function _getIncognitoIds() { try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; } } @@ -4061,6 +4089,7 @@ const sessionModule = { renderSessionList, loadSessions, selectSession, + prepareHistoryForPrint, createDirectChat, materializePendingSession, preMaterializePendingSession, diff --git a/tests/test_chat_print_pagination.py b/tests/test_chat_print_pagination.py new file mode 100644 index 000000000..35858933a --- /dev/null +++ b/tests/test_chat_print_pagination.py @@ -0,0 +1,61 @@ +"""The shipped CSS must print every message beyond the screen viewport.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing") +def test_chat_print_preserves_the_complete_transcript(tmp_path): + script = r''' + import fs from 'node:fs'; + import path from 'node:path'; + import { chromium } from 'playwright'; + const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; + const source = fs.readFileSync('static/index.html', 'utf8'); + const links = [...source.matchAll(/]*rel="stylesheet"[^>]*>/g)] + .map(match => match[0]).join('\n'); + const messages = Array.from({length: 24}, (_, index) => { + const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0'); + const paragraphs = Array.from({length:4}, () => + '

This synthetic message must survive printing beyond the screen viewport. ' + + 'Every paragraph belongs to the saved conversation.

').join(''); + return `

${marker}

${paragraphs}
`; + }).join(''); + const browser = await chromium.launch({headless:true}); + try { + const results=[]; + for (const [layout,viewport] of [['desktop',{width:1440,height:1000}],['mobile',{width:390,height:844}]]) { + const page=await browser.newPage({viewport}); + await page.goto(origin+'/static/css/00-tokens.css'); + await page.setContent(`${links}
${messages}
`,{waitUntil:'load'}); + await page.evaluate(()=>document.fonts.ready); + const file=path.join(process.env.ODYSSEUS_PDF_OUTPUT_DIR,layout+'.pdf'); + await page.pdf({path:file,format:'A4',printBackground:true}); + results.push({layout,file}); + await page.close(); + } + console.log(JSON.stringify(results)); + } finally {await browser.close();} + ''' + result = subprocess.run( + ["node", "--input-type=module", "-e", script], cwd=ROOT, + capture_output=True, text=True, timeout=90, + env={**os.environ, "ODYSSEUS_PDF_OUTPUT_DIR": str(tmp_path)}, + ) + assert result.returncode == 0, result.stderr + results = json.loads(result.stdout) + assert {row["layout"] for row in results} == {"desktop", "mobile"} + for row in results: + pdf = PdfReader(row["file"]) + text = "".join("".join(page.extract_text().split()) for page in pdf.pages) + assert len(pdf.pages) > 1, row["layout"] + for index in range(1, 25): + assert f"EXPORTSENTINEL{index:02d}" in text, (row["layout"], index)