mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 03:32:25 +01:00
fix(chat): export the complete conversation across PDF pages (#6921)
This commit is contained in:
+12
-1
@@ -561,10 +561,21 @@ function initializeEventListeners() {
|
|||||||
|
|
||||||
// Export: PDF
|
// Export: PDF
|
||||||
const exportPdfBtn = el('export-pdf-btn');
|
const exportPdfBtn = el('export-pdf-btn');
|
||||||
|
let preparingPdf = false;
|
||||||
if (exportPdfBtn) {
|
if (exportPdfBtn) {
|
||||||
exportPdfBtn.addEventListener('click', (e) => {
|
exportPdfBtn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
exportMenu.classList.remove('open');
|
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 meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||||
const sessionName = meta ? meta.name : 'Odysseus Chat';
|
const sessionName = meta ? meta.name : 'Odysseus Chat';
|
||||||
const originalTitle = document.title;
|
const originalTitle = document.title;
|
||||||
|
|||||||
@@ -1701,6 +1701,7 @@
|
|||||||
|
|
||||||
/* ── Print / PDF Export ── */
|
/* ── Print / PDF Export ── */
|
||||||
@media print {
|
@media print {
|
||||||
|
html, body { height: auto !important; overflow: visible !important; }
|
||||||
body { background: #fff !important; color: #000 !important; }
|
body { background: #fff !important; color: #000 !important; }
|
||||||
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
|
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
|
||||||
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
|
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
|
||||||
|
|||||||
+44
-15
@@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
|||||||
handler: null,
|
handler: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadOlder = async () => {
|
const loadOlder = async (force = false) => {
|
||||||
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
|
const pager = _historyPager;
|
||||||
if (_historyPager.sessionId !== currentSessionId) return;
|
if (!pager || pager.loading || pager.done) return;
|
||||||
if (box.scrollTop > 90) return;
|
if (pager.sessionId !== currentSessionId) return;
|
||||||
|
if (!force && box.scrollTop > 90) return;
|
||||||
|
|
||||||
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
|
const nextOffset = Math.max(0, pager.offset - pager.limit);
|
||||||
const nextLimit = _historyPager.offset - nextOffset;
|
const nextLimit = pager.offset - nextOffset;
|
||||||
if (nextLimit <= 0) {
|
if (nextLimit <= 0) {
|
||||||
_historyPager.done = true;
|
pager.done = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_historyPager.loading = true;
|
pager.loading = true;
|
||||||
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
|
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
|
||||||
const beforeHeight = box.scrollHeight;
|
const beforeHeight = box.scrollHeight;
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
|
if (_historyPager !== pager || pager.sessionId !== currentSessionId) return;
|
||||||
const newEls = [];
|
const newEls = [];
|
||||||
for (const msg of data.history || []) {
|
for (const msg of data.history || []) {
|
||||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
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);
|
if (Array.isArray(els)) newEls.push(...els);
|
||||||
}
|
}
|
||||||
for (const el of newEls) {
|
for (const el of newEls) {
|
||||||
box.insertBefore(el, anchor || box.firstChild);
|
box.insertBefore(el, anchor || box.firstChild);
|
||||||
}
|
}
|
||||||
_historyPager.offset = Number(data.offset || nextOffset);
|
pager.offset = Number(data.offset || nextOffset);
|
||||||
_historyPager.done = !data.has_more_before;
|
pager.done = !data.has_more_before;
|
||||||
if (window.hljs) {
|
if (window.hljs) {
|
||||||
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to load older chat history:', e);
|
console.warn('Failed to load older chat history:', e);
|
||||||
} finally {
|
} finally {
|
||||||
if (_historyPager) _historyPager.loading = false;
|
pager.loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_historyPager.loadOlder = loadOlder;
|
||||||
_historyPager.handler = () => {
|
_historyPager.handler = () => {
|
||||||
if (box.scrollTop <= 90) loadOlder();
|
if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) {
|
||||||
|
_historyPager.pending = loadOlder();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
box.addEventListener('scroll', _historyPager.handler, { passive: true });
|
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() {
|
function _getIncognitoIds() {
|
||||||
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
||||||
}
|
}
|
||||||
@@ -4061,6 +4089,7 @@ const sessionModule = {
|
|||||||
renderSessionList,
|
renderSessionList,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
selectSession,
|
selectSession,
|
||||||
|
prepareHistoryForPrint,
|
||||||
createDirectChat,
|
createDirectChat,
|
||||||
materializePendingSession,
|
materializePendingSession,
|
||||||
preMaterializePendingSession,
|
preMaterializePendingSession,
|
||||||
|
|||||||
@@ -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(/<link\b[^>]*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}, () =>
|
||||||
|
'<p>This synthetic message must survive printing beyond the screen viewport. '
|
||||||
|
+ 'Every paragraph belongs to the saved conversation.</p>').join('');
|
||||||
|
return `<div class="msg ${index % 2 ? 'msg-ai' : 'msg-user'}"><div class="body"><p>${marker}</p>${paragraphs}</div></div>`;
|
||||||
|
}).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(`<html><head>${links}</head><body><main class="chat-container"><div id="chat-history">${messages}</div></main></body></html>`,{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)
|
||||||
Reference in New Issue
Block a user