feat: add PDF export functionality to share modal (#9300)

* feat: add PDF export functionality to share modal

- Create usePdfExport hook with jsPDF and html2canvas
- Add "Export as PDF" button to screenshot tab in share modal
- Support multi-page PDFs for long conversations
- Add required dependencies: jspdf@^2.5.2 and html2canvas@^1.4.1
- Add localization support for PDF export button

Fixes #9299

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: LobeHub Bot <lobehubbot@users.noreply.github.com>

* ♻️ refactor: convert PDF export to separate tab with backend generation

- Create new SharePdf tab component with PDF.js preview
- Move PDF generation from frontend to backend via tRPC
- Add server-side PDF generation using jsPDF
- Remove old PDF export button from ShareImage component
- Add proper loading states and error handling
- Update localization for PDF tab

Co-authored-by: Shinji-Li <ONLY-yours@users.noreply.github.com>

* 🐛 fix: resolve unicorn/no-await-expression-member lint error in PDF exporter

Split await expression member access to avoid linting error in exporter.ts

Co-authored-by: Shinji-Li <ONLY-yours@users.noreply.github.com>

* feat: add i18n

* feat: use pdfkit to export a pdf

* feat: add fullscreen preview

* feat: update pdf preview styles

* feat: add i18n locales

* feat: add single pdf share modal

* feat: update css & client mode cant use pdf genertate

* fix: mobile style fixed

* fix: delete console.log & useless packagejson

* feat: use online otf link

---------

Co-authored-by: Shinji-Li <ONLY-yours@users.noreply.github.com>
This commit is contained in:
Shinji-Li
2025-10-21 16:32:17 +08:00
committed by GitHub
parent 6734a47759
commit 2b7761c36e
33 changed files with 1840 additions and 60 deletions

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "نسخ",
"download": "تحميل اللقطة",
"downloadError": "فشل التنزيل",
"downloadFile": "تحميل الملف",
"downloadPdf": "تنزيل PDF",
"downloadSuccess": "تم التنزيل بنجاح",
"exportPdf": "تصدير إلى PDF",
"exportTitle": "العنوان الافتراضي",
"generatePdf": "إنشاء ملف PDF",
"generatingPdf": "جارٍ إنشاء PDF...",
"imageType": "نوع الصورة",
"includeTool": "تضمين رسالة الأداة",
"includeUser": "تضمين رسالة المستخدم",
"loadingPdf": "جارٍ تحميل ملف PDF...",
"noPdfData": "لا توجد بيانات PDF",
"pdf": "PDF",
"pdfErrorDescription": "حدث خطأ أثناء إنشاء PDF، يرجى المحاولة مرة أخرى",
"pdfGenerationError": "فشل إنشاء PDF",
"pdfReady": "تم تجهيز PDF",
"regeneratePdf": "إعادة إنشاء ملف PDF",
"screenshot": "لقطة شاشة",
"settings": "إعدادات التصدير",
"text": "نص",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Копирай",
"download": "Изтегли екранна снимка",
"downloadError": "Грешка при изтегляне",
"downloadFile": "Изтегли файла",
"downloadPdf": "Изтегляне на PDF",
"downloadSuccess": "Изтеглянето е успешно",
"exportPdf": "Експортиране като PDF",
"exportTitle": "По подразбиране заглавие",
"generatePdf": "Генериране на PDF",
"generatingPdf": "Генериране на PDF...",
"imageType": "Формат на изображението",
"includeTool": "Включи съобщения от инструмента",
"includeUser": "Включи съобщения от потребителя",
"loadingPdf": "Зареждане на PDF...",
"noPdfData": "Няма налични PDF данни",
"pdf": "PDF",
"pdfErrorDescription": "Възникна грешка при генерирането на PDF, моля опитайте отново",
"pdfGenerationError": "Грешка при генериране на PDF",
"pdfReady": "PDF е готов",
"regeneratePdf": "Генериране на PDF отново",
"screenshot": "Екранна снимка",
"settings": "Настройки за експортиране",
"text": "Текст",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Kopieren",
"download": "Screenshot herunterladen",
"downloadError": "Download fehlgeschlagen",
"downloadFile": "Datei herunterladen",
"downloadPdf": "PDF herunterladen",
"downloadSuccess": "Download erfolgreich",
"exportPdf": "Als PDF exportieren",
"exportTitle": "Standardtitel",
"generatePdf": "PDF erstellen",
"generatingPdf": "PDF wird erstellt...",
"imageType": "Bildformat",
"includeTool": "Plugin-Nachricht einfügen",
"includeUser": "Benutzernachricht einfügen",
"loadingPdf": "PDF wird geladen...",
"noPdfData": "Keine PDF-Daten vorhanden",
"pdf": "PDF",
"pdfErrorDescription": "Beim Erstellen des PDFs ist ein Fehler aufgetreten, bitte versuchen Sie es erneut",
"pdfGenerationError": "PDF-Erstellung fehlgeschlagen",
"pdfReady": "PDF ist bereit",
"regeneratePdf": "PDF neu erstellen",
"screenshot": "Screenshot",
"settings": "Exporteinstellungen",
"text": "Text",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Copy",
"download": "Download Screenshot",
"downloadError": "Download failed",
"downloadFile": "Download File",
"downloadPdf": "Download PDF",
"downloadSuccess": "Download successful",
"exportPdf": "Export as PDF",
"exportTitle": "Default Title",
"generatePdf": "Generate PDF",
"generatingPdf": "Generating PDF...",
"imageType": "Image Format",
"includeTool": "Include Plugin Messages",
"includeUser": "Include User Messages",
"loadingPdf": "Loading PDF...",
"noPdfData": "No PDF data available",
"pdf": "PDF",
"pdfErrorDescription": "An error occurred while generating the PDF, please try again",
"pdfGenerationError": "PDF generation failed",
"pdfReady": "PDF is ready",
"regeneratePdf": "Regenerate PDF",
"screenshot": "Screenshot",
"settings": "Export Settings",
"text": "Text",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Copiar",
"download": "Descargar captura de pantalla",
"downloadError": "Error al descargar",
"downloadFile": "Descargar archivo",
"downloadPdf": "Descargar PDF",
"downloadSuccess": "Descarga exitosa",
"exportPdf": "Exportar como PDF",
"exportTitle": "Título predeterminado",
"generatePdf": "Generar PDF",
"generatingPdf": "Generando PDF...",
"imageType": "Tipo de imagen",
"includeTool": "Incluir mensajes de herramientas",
"includeUser": "Incluir mensajes de usuario",
"loadingPdf": "Cargando PDF...",
"noPdfData": "No hay datos PDF disponibles",
"pdf": "PDF",
"pdfErrorDescription": "Se produjo un error al generar el PDF, por favor inténtelo de nuevo",
"pdfGenerationError": "Error al generar el PDF",
"pdfReady": "PDF listo",
"regeneratePdf": "Regenerar PDF",
"screenshot": "Captura de pantalla",
"settings": "Configuración de exportación",
"text": "Texto",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "کپی",
"download": "دانلود اسکرین‌شات",
"downloadError": "دانلود ناموفق بود",
"downloadFile": "دانلود فایل",
"downloadPdf": "دانلود PDF",
"downloadSuccess": "دانلود با موفقیت انجام شد",
"exportPdf": "صادر کردن به PDF",
"exportTitle": "عنوان پیش‌فرض",
"generatePdf": "ایجاد PDF",
"generatingPdf": "در حال تولید PDF...",
"imageType": "فرمت تصویر",
"includeTool": "شامل پیام‌های ابزار",
"includeUser": "شامل پیام‌های کاربر",
"loadingPdf": "در حال بارگذاری PDF...",
"noPdfData": "داده‌ای برای PDF موجود نیست",
"pdf": "PDF",
"pdfErrorDescription": "خطا در تولید PDF، لطفاً دوباره تلاش کنید",
"pdfGenerationError": "تولید PDF ناموفق بود",
"pdfReady": "PDF آماده است",
"regeneratePdf": "تولید مجدد PDF",
"screenshot": "اسکرین‌شات",
"settings": "تنظیمات خروجی",
"text": "متن",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Copier",
"download": "Télécharger la capture d'écran",
"downloadError": "Échec du téléchargement",
"downloadFile": "Télécharger le fichier",
"downloadPdf": "Télécharger le PDF",
"downloadSuccess": "Téléchargement réussi",
"exportPdf": "Exporter en PDF",
"exportTitle": "Titre par défaut",
"generatePdf": "Générer le PDF",
"generatingPdf": "Génération du PDF en cours...",
"imageType": "Type d'image",
"includeTool": "Inclure les messages de l'outil",
"includeUser": "Inclure les messages de l'utilisateur",
"loadingPdf": "Chargement du PDF...",
"noPdfData": "Aucune donnée PDF disponible",
"pdf": "PDF",
"pdfErrorDescription": "Une erreur est survenue lors de la génération du PDF, veuillez réessayer",
"pdfGenerationError": "Échec de la génération du PDF",
"pdfReady": "Le PDF est prêt",
"regeneratePdf": "Régénérer le PDF",
"screenshot": "Capture d'écran",
"settings": "Paramètres d'exportation",
"text": "Texte",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Copia",
"download": "Scarica screenshot",
"downloadError": "Download fallito",
"downloadFile": "Scarica file",
"downloadPdf": "Scarica PDF",
"downloadSuccess": "Download riuscito",
"exportPdf": "Esporta come PDF",
"exportTitle": "Titolo predefinito",
"generatePdf": "Genera PDF",
"generatingPdf": "Generazione PDF in corso...",
"imageType": "Tipo di immagine",
"includeTool": "Includi messaggio dello strumento",
"includeUser": "Includi messaggio dell'utente",
"loadingPdf": "Caricamento PDF...",
"noPdfData": "Nessun dato PDF disponibile",
"pdf": "PDF",
"pdfErrorDescription": "Si è verificato un errore durante la generazione del PDF, riprova",
"pdfGenerationError": "Generazione PDF fallita",
"pdfReady": "PDF pronto",
"regeneratePdf": "Rigenera PDF",
"screenshot": "Screenshot",
"settings": "Impostazioni di esportazione",
"text": "Testo",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "コピー",
"download": "スクリーンショットをダウンロード",
"downloadError": "ダウンロード失敗",
"downloadFile": "ファイルをダウンロード",
"downloadPdf": "PDFをダウンロード",
"downloadSuccess": "ダウンロード成功",
"exportPdf": "PDFとしてエクスポート",
"exportTitle": "デフォルトタイトル",
"generatePdf": "PDFを生成する",
"generatingPdf": "PDFを生成中...",
"imageType": "画像形式",
"includeTool": "ツールメッセージを含める",
"includeUser": "ユーザーメッセージを含める",
"loadingPdf": "PDFを読み込み中...",
"noPdfData": "PDFデータがありません",
"pdf": "PDF",
"pdfErrorDescription": "PDFの生成中にエラーが発生しました。再試行してください。",
"pdfGenerationError": "PDFの生成に失敗しました",
"pdfReady": "PDFの準備ができました",
"regeneratePdf": "PDFを再生成する",
"screenshot": "スクリーンショット",
"settings": "エクスポート設定",
"text": "テキスト",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "복사",
"download": "스크린샷 다운로드",
"downloadError": "다운로드 실패",
"downloadFile": "파일 다운로드",
"downloadPdf": "PDF 다운로드",
"downloadSuccess": "다운로드 성공",
"exportPdf": "PDF로 내보내기",
"exportTitle": "기본 제목",
"generatePdf": "PDF 생성",
"generatingPdf": "PDF 생성 중...",
"imageType": "이미지 형식",
"includeTool": "플러그인 메시지 포함",
"includeUser": "사용자 메시지 포함",
"loadingPdf": "PDF 로드 중...",
"noPdfData": "PDF 데이터가 없습니다",
"pdf": "PDF",
"pdfErrorDescription": "PDF 생성 중 오류가 발생했습니다. 다시 시도해 주세요.",
"pdfGenerationError": "PDF 생성 실패",
"pdfReady": "PDF가 준비되었습니다",
"regeneratePdf": "PDF 다시 생성",
"screenshot": "스크린샷",
"settings": "내보내기 설정",
"text": "텍스트",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Kopiëren",
"download": "Screenshot downloaden",
"downloadError": "Download mislukt",
"downloadFile": "Bestand downloaden",
"downloadPdf": "PDF downloaden",
"downloadSuccess": "Download geslaagd",
"exportPdf": "Exporteren als PDF",
"exportTitle": "Standaardtitel",
"generatePdf": "PDF genereren",
"generatingPdf": "PDF wordt gegenereerd...",
"imageType": "Afbeeldingstype",
"includeTool": "Inclusief pluginbericht",
"includeUser": "Inclusief gebruikersbericht",
"loadingPdf": "PDF laden...",
"noPdfData": "Geen PDF-gegevens beschikbaar",
"pdf": "PDF",
"pdfErrorDescription": "Er is een fout opgetreden bij het genereren van de PDF, probeer het opnieuw",
"pdfGenerationError": "PDF-generatie mislukt",
"pdfReady": "PDF is klaar",
"regeneratePdf": "PDF opnieuw genereren",
"screenshot": "Screenshot",
"settings": "Exportinstellingen",
"text": "Tekst",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Kopiuj",
"download": "Pobierz zrzut ekranu",
"downloadError": "Błąd pobierania",
"downloadFile": "Pobierz plik",
"downloadPdf": "Pobierz PDF",
"downloadSuccess": "Pobieranie zakończone sukcesem",
"exportPdf": "Eksportuj jako PDF",
"exportTitle": "Domyślny tytuł",
"generatePdf": "Generuj PDF",
"generatingPdf": "Generowanie PDF...",
"imageType": "Typ obrazu",
"includeTool": "Uwzględnij wiadomości z narzędzi",
"includeUser": "Uwzględnij wiadomości od użytkowników",
"loadingPdf": "Ładowanie PDF...",
"noPdfData": "Brak danych PDF",
"pdf": "PDF",
"pdfErrorDescription": "Wystąpił błąd podczas generowania PDF, spróbuj ponownie",
"pdfGenerationError": "Nie udało się wygenerować PDF",
"pdfReady": "PDF jest gotowy",
"regeneratePdf": "Wygeneruj PDF ponownie",
"screenshot": "Zrzut ekranu",
"settings": "Ustawienia eksportu",
"text": "Tekst",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Copiar",
"download": "Baixar Captura de Tela",
"downloadError": "Falha no download",
"downloadFile": "Baixar arquivo",
"downloadPdf": "Baixar PDF",
"downloadSuccess": "Download concluído com sucesso",
"exportPdf": "Exportar como PDF",
"exportTitle": "Título padrão",
"generatePdf": "Gerar PDF",
"generatingPdf": "Gerando PDF...",
"imageType": "Tipo de Imagem",
"includeTool": "Incluir mensagens de ferramentas",
"includeUser": "Incluir mensagens de usuários",
"loadingPdf": "Carregando PDF...",
"noPdfData": "Nenhum dado de PDF disponível",
"pdf": "PDF",
"pdfErrorDescription": "Ocorreu um erro ao gerar o PDF, por favor tente novamente",
"pdfGenerationError": "Falha na geração do PDF",
"pdfReady": "PDF está pronto",
"regeneratePdf": "Regenerar PDF",
"screenshot": "Captura de Tela",
"settings": "Configurações de Exportação",
"text": "Texto",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Копировать",
"download": "Скачать скриншот",
"downloadError": "Ошибка загрузки",
"downloadFile": "Скачать файл",
"downloadPdf": "Скачать PDF",
"downloadSuccess": "Загрузка успешна",
"exportPdf": "Экспорт в PDF",
"exportTitle": "Заголовок по умолчанию",
"generatePdf": "Создать PDF",
"generatingPdf": "Генерация PDF...",
"imageType": "Тип изображения",
"includeTool": "Включить сообщения плагина",
"includeUser": "Включить сообщения пользователя",
"loadingPdf": "Загрузка PDF...",
"noPdfData": "Данные PDF отсутствуют",
"pdf": "PDF",
"pdfErrorDescription": "Произошла ошибка при создании PDF, попробуйте снова",
"pdfGenerationError": "Не удалось создать PDF",
"pdfReady": "PDF готов",
"regeneratePdf": "Перегенерировать PDF",
"screenshot": "Скриншот",
"settings": "Настройки экспорта",
"text": "Текст",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Kopyala",
"download": "Ekran Görüntüsünü İndir",
"downloadError": "İndirme Başarısız",
"downloadFile": "Dosyayı İndir",
"downloadPdf": "PDF İndir",
"downloadSuccess": "İndirme Başarılı",
"exportPdf": "PDF Olarak Dışa Aktar",
"exportTitle": "Varsayılan Başlık",
"generatePdf": "PDF Oluştur",
"generatingPdf": "PDF Oluşturuluyor...",
"imageType": "Format",
"includeTool": "Eklenti mesajını dahil et",
"includeUser": "Kullanıcı mesajını dahil et",
"loadingPdf": "PDF Yükleniyor...",
"noPdfData": "PDF Verisi Yok",
"pdf": "PDF",
"pdfErrorDescription": "PDF oluşturulurken bir hata oluştu, lütfen tekrar deneyin",
"pdfGenerationError": "PDF oluşturma başarısız oldu",
"pdfReady": "PDF Hazır",
"regeneratePdf": "PDF'yi Yeniden Oluştur",
"screenshot": "Ekran Görüntüsü",
"settings": "Ayarlar",
"text": "Metin",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "Sao chép",
"download": "Tải xuống ảnh chụp màn hình",
"downloadError": "Tải xuống thất bại",
"downloadFile": "Tải tệp",
"downloadPdf": "Tải xuống PDF",
"downloadSuccess": "Tải xuống thành công",
"exportPdf": "Xuất ra PDF",
"exportTitle": "Tiêu đề mặc định",
"generatePdf": "Tạo PDF",
"generatingPdf": "Đang tạo PDF...",
"imageType": "Định dạng ảnh",
"includeTool": "Bao gồm thông điệp công cụ",
"includeUser": "Bao gồm thông điệp người dùng",
"loadingPdf": "Đang tải PDF...",
"noPdfData": "Chưa có dữ liệu PDF",
"pdf": "PDF",
"pdfErrorDescription": "Đã xảy ra lỗi khi tạo PDF, vui lòng thử lại",
"pdfGenerationError": "Tạo PDF thất bại",
"pdfReady": "PDF đã sẵn sàng",
"regeneratePdf": "Tạo lại PDF",
"screenshot": "Ảnh chụp màn hình",
"settings": "Cài đặt xuất",
"text": "Văn bản",

View File

@@ -305,10 +305,23 @@
"copy": "复制",
"download": "下载截图",
"downloadFile": "下载文件",
"downloadPdf": "下载 PDF",
"downloadSuccess": "下载成功",
"downloadError": "下载失败",
"exportPdf": "导出为 PDF",
"exportTitle": "默认标题",
"generatePdf": "生成 PDF",
"generatingPdf": "正在生成 PDF...",
"imageType": "图片格式",
"includeTool": "包含插件消息",
"includeUser": "包含用户消息",
"loadingPdf": "加载 PDF...",
"noPdfData": "暂无 PDF 数据",
"pdf": "PDF",
"pdfErrorDescription": "生成 PDF 时出现错误,请重试",
"pdfGenerationError": "PDF 生成失败",
"pdfReady": "PDF 已准备就绪",
"regeneratePdf": "重新生成 PDF",
"screenshot": "截图",
"settings": "导出设置",
"text": "文本",

View File

@@ -304,11 +304,24 @@
"shareModal": {
"copy": "複製",
"download": "下載截圖",
"downloadError": "下載失敗",
"downloadFile": "下載檔案",
"downloadPdf": "下載 PDF",
"downloadSuccess": "下載成功",
"exportPdf": "匯出為 PDF",
"exportTitle": "預設標題",
"generatePdf": "生成 PDF",
"generatingPdf": "正在產生 PDF...",
"imageType": "圖片格式",
"includeTool": "包含插件訊息",
"includeUser": "包含使用者訊息",
"loadingPdf": "載入 PDF...",
"noPdfData": "暫無 PDF 資料",
"pdf": "PDF",
"pdfErrorDescription": "產生 PDF 時發生錯誤,請重試",
"pdfGenerationError": "PDF 產生失敗",
"pdfReady": "PDF 已準備就緒",
"regeneratePdf": "重新生成 PDF",
"screenshot": "截圖",
"settings": "導出設置",
"text": "文本",

View File

@@ -201,7 +201,6 @@ const nextConfig: NextConfig = {
},
},
reactStrictMode: true,
redirects: async () => [
{
destination: '/sitemap-index.xml',
@@ -272,7 +271,7 @@ const nextConfig: NextConfig = {
],
// when external packages in dev mode with turbopack, this config will lead to bundle error
serverExternalPackages: isProd ? ['@electric-sql/pglite'] : undefined,
serverExternalPackages: isProd ? ['@electric-sql/pglite', "pdfkit"] : ["pdfkit"],
transpilePackages: ['pdfjs-dist', 'mermaid'],
typescript: {

View File

@@ -168,6 +168,7 @@
"@lobehub/icons": "^2.42.0",
"@lobehub/market-sdk": "^0.22.7",
"@lobehub/tts": "^2.0.1",
"@react-pdf/renderer": "^4.3.0",
"@lobehub/ui": "^2.13.2",
"@modelcontextprotocol/sdk": "^1.20.0",
"@neondatabase/serverless": "^1.0.2",
@@ -224,6 +225,7 @@
"lucide-react": "^0.544.0",
"mammoth": "^1.11.0",
"markdown-to-txt": "^2.0.1",
"marked": "^16.3.0",
"mdast-util-to-markdown": "^2.1.2",
"model-bank": "workspace:*",
"modern-screenshot": "^4.6.6",
@@ -244,6 +246,7 @@
"path-browserify-esm": "^1.0.6",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "4.8.69",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"pino": "^9.13.1",
"plaiceholder": "^3.0.0",
@@ -320,9 +323,11 @@
"@types/json-schema": "^7.0.15",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/marked": "^6.0.0",
"@types/node": "^22.18.9",
"@types/numeral": "^2.0.5",
"@types/oidc-provider": "^9.5.0",
"@types/pdfkit": "^0.17.3",
"@types/pg": "^8.15.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",

View File

@@ -0,0 +1,361 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Button } from '@lobehub/ui';
import { Input, Modal, Spin } from 'antd';
import { createStyles } from 'antd-style';
import { ChevronLeft, ChevronRight, Expand, FileText } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Document, Page, pdfjs } from 'react-pdf';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useContainerStyles } from './style';
// Set PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `https://registry.npmmirror.com/pdfjs-dist/${pdfjs.version}/files/build/pdf.worker.min.mjs`;
const useStyles = createStyles(({ css }) => ({
containerWrapper: css`
position: relative;
width: 100%;
height: 100%;
`,
documentLoading: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
`,
emptyState: css`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
`,
expandButton: css`
position: absolute;
z-index: 1000;
inset-block-start: 20px;
inset-inline-end: 20px;
`,
footerNavigation: css`
position: absolute;
z-index: 10;
inset-block-end: 0;
inset-inline: 0 0;
padding: 12px;
border-block-start: 1px solid rgba(0, 0, 0, 10%);
background: rgba(255, 255, 255, 90%);
backdrop-filter: blur(8px);
`,
fullscreenButton: css`
border-color: white;
color: white;
`,
fullscreenContent: css`
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100%;
padding: 20px;
`,
fullscreenModal: css`
position: relative;
overflow: auto;
height: 90vh;
`,
fullscreenNavigation: css`
position: fixed;
z-index: 1001;
inset-block-end: 20px;
inset-inline-start: 50%;
transform: translateX(-50%);
padding-block: 12px;
padding-inline: 20px;
border-radius: 8px;
background: rgba(0, 0, 0, 70%);
backdrop-filter: blur(8px);
`,
fullscreenPageInput: css`
width: 60px;
text-align: center;
`,
fullscreenPageText: css`
min-width: 20px;
font-size: 14px;
color: white;
`,
loadingState: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
`,
loadingText: css`
margin-block-start: 8px;
color: #666;
`,
pageInput: css`
width: 50px;
text-align: center;
`,
pageNumberText: css`
font-size: 12px;
color: #666;
`,
previewContainer: css`
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
`,
}));
interface PdfPreviewProps {
loading: boolean;
onGeneratePdf?: () => void;
pdfData: string | null;
}
const PdfPreview = memo<PdfPreviewProps>(({ loading, pdfData, onGeneratePdf }) => {
const { styles } = useContainerStyles();
const { styles: localStyles } = useStyles();
const { t } = useTranslation('chat');
const isMobile = useIsMobile();
// Page navigation state
const [numPages, setNumPages] = useState<number>(0);
const [pageNumber, setPageNumber] = useState<number>(1);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [fullscreenPageNumber, setFullscreenPageNumber] = useState<number>(1);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setPageNumber(1);
};
const goToPrevPage = () => {
if (pageNumber > 1) {
setPageNumber(pageNumber - 1);
}
};
const goToNextPage = () => {
if (pageNumber < numPages) {
setPageNumber(pageNumber + 1);
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= numPages) {
setPageNumber(page);
}
};
const handleFullscreen = () => {
if (pdfData) {
setFullscreenPageNumber(pageNumber);
setFullscreenOpen(true);
}
};
const goToFullscreenPrevPage = () => {
if (fullscreenPageNumber > 1) {
setFullscreenPageNumber(fullscreenPageNumber - 1);
}
};
const goToFullscreenNextPage = () => {
if (fullscreenPageNumber < numPages) {
setFullscreenPageNumber(fullscreenPageNumber + 1);
}
};
const goToFullscreenPage = (page: number) => {
if (page >= 1 && page <= numPages) {
setFullscreenPageNumber(page);
}
};
if (loading) {
return (
<div className={styles.preview} style={{ padding: 12 }}>
<div className={localStyles.loadingState}>
<Spin indicator={<LoadingOutlined spin style={{ fontSize: 24 }} />} />
<div className={localStyles.loadingText}>{t('shareModal.generatingPdf')}</div>
</div>
</div>
);
}
if (!pdfData) {
return (
<div className={styles.preview} style={{ padding: 12 }}>
<div className={localStyles.emptyState}>
<Button icon={<FileText size={20} />} onClick={onGeneratePdf} size="large" type="primary">
{t('shareModal.generatePdf', { defaultValue: '生成 PDF' })}
</Button>
</div>
</div>
);
}
// Convert base64 to data URI
const pdfDataUri = `data:application/pdf;base64,${pdfData}`;
return (
<>
<div className={localStyles.containerWrapper}>
{pdfData && (
<Button
className={localStyles.expandButton}
icon={<Expand size={16} />}
onClick={handleFullscreen}
size="small"
type="text"
/>
)}
<div className={`${styles.preview} ${localStyles.previewContainer}`}>
<Document
file={pdfDataUri}
loading={
<div className={localStyles.documentLoading}>
<Spin />
<div className={localStyles.loadingText}>
{t('shareModal.loadingPdf', { defaultValue: 'Loading PDF...' })}
</div>
</div>
}
onLoadSuccess={onDocumentLoadSuccess}
>
<Page
pageNumber={pageNumber}
renderAnnotationLayer={false}
renderTextLayer={false}
width={isMobile ? 300 : 400}
/>
</Document>
</div>
{/* 页脚导航 */}
{pdfData && numPages > 1 && (
<div className={localStyles.footerNavigation}>
<Flexbox align="center" gap={8} horizontal justify="center">
<Button
disabled={pageNumber <= 1}
icon={<ChevronLeft size={16} />}
onClick={goToPrevPage}
size="small"
type="text"
/>
<Flexbox align="center" gap={4} horizontal>
<Input
className={localStyles.pageInput}
max={numPages}
min={1}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) goToPage(value);
}}
size="small"
type="number"
value={pageNumber}
/>
<span className={localStyles.pageNumberText}>/ {numPages}</span>
</Flexbox>
<Button
disabled={pageNumber >= numPages}
icon={<ChevronRight size={16} />}
onClick={goToNextPage}
size="small"
type="text"
/>
</Flexbox>
</div>
)}
</div>
{/* 全屏模态框 */}
<Modal
centered
footer={null}
onCancel={() => setFullscreenOpen(false)}
open={fullscreenOpen}
styles={{
body: { padding: 0 },
content: { padding: 0 },
}}
width="95vw"
>
<div className={localStyles.fullscreenModal}>
<div className={localStyles.fullscreenContent}>
<Document file={pdfDataUri} onLoadSuccess={onDocumentLoadSuccess}>
<Page
pageNumber={fullscreenPageNumber}
renderAnnotationLayer={false}
renderTextLayer={false}
width={Math.min(window.innerWidth * 0.8, 1000)}
/>
</Document>
</div>
{/* 全屏模式下的导航 */}
{numPages > 1 && (
<div className={localStyles.fullscreenNavigation}>
<Flexbox align="center" gap={12} horizontal>
<Button
className={localStyles.fullscreenButton}
disabled={fullscreenPageNumber <= 1}
icon={<ChevronLeft size={16} />}
onClick={goToFullscreenPrevPage}
size="small"
type="text"
/>
<Flexbox align="center" gap={8} horizontal>
<Input
className={localStyles.fullscreenPageInput}
max={numPages}
min={1}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) goToFullscreenPage(value);
}}
size="small"
type="number"
value={fullscreenPageNumber}
/>
<span className={localStyles.fullscreenPageText}>/ {numPages}</span>
</Flexbox>
<Button
className={localStyles.fullscreenButton}
disabled={fullscreenPageNumber >= numPages}
icon={<ChevronRight size={16} />}
onClick={goToFullscreenNextPage}
size="small"
type="text"
/>
</Flexbox>
</div>
)}
</div>
</Modal>
</>
);
});
export default PdfPreview;

View File

@@ -0,0 +1,119 @@
import { Button } from '@lobehub/ui';
import { App } from 'antd';
import { DownloadIcon, FileText } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { ChatMessage } from '@/types/message';
import PdfPreview from './PdfPreview';
import { useContainerStyles, useStyles } from './style';
import { generateMarkdown } from './template';
import { usePdfGeneration } from './usePdfGeneration';
interface SharePdfProps {
message: ChatMessage;
}
const SharePdf = memo<SharePdfProps>(({ message }) => {
const { t } = useTranslation(['chat', 'common']);
const { styles } = useStyles();
const { styles: containerStyles } = useContainerStyles();
const { message: appMessage } = App.useApp();
const isMobile = useIsMobile();
// Get session info
const activeId = useChatStore((s) => s.activeId);
const topicId = useChatStore((s) => s.activeTopicId);
// Generate markdown content for single message
const markdownContent = generateMarkdown({
message,
}).replaceAll('\n\n\n', '\n');
const { generatePdf, downloadPdf, pdfData, loading, error } = usePdfGeneration();
const handleGeneratePdf = async () => {
if (activeId && markdownContent.trim()) {
await generatePdf({
content: markdownContent,
sessionId: activeId,
topicId: topicId || undefined,
});
}
};
const handleDownload = async () => {
if (pdfData) {
try {
await downloadPdf();
appMessage.success(t('shareModal.downloadSuccess'));
} catch {
appMessage.error(t('shareModal.downloadError'));
}
}
};
const generateButton = (
<Button
block
disabled={loading}
icon={loading ? undefined : FileText}
loading={loading}
onClick={handleGeneratePdf}
size={isMobile ? undefined : 'large'}
type="primary"
>
{loading
? t('shareModal.generatingPdf')
: pdfData
? t('shareModal.regeneratePdf', { defaultValue: '重新生成 PDF' })
: t('shareModal.generatePdf', { defaultValue: '生成 PDF' })}
</Button>
);
const downloadButton = pdfData ? (
<Button
block
icon={DownloadIcon}
onClick={handleDownload}
size={isMobile ? undefined : 'large'}
type="default"
>
{t('shareModal.downloadPdf')}
</Button>
) : null;
if (error) {
return (
<Flexbox className={styles.body} gap={16} horizontal={!isMobile}>
<div className={containerStyles.preview} style={{ padding: 12 }}>
<div style={{ color: 'red', textAlign: 'center' }}>
{t('shareModal.pdfGenerationError')}: {error}
</div>
</div>
<Flexbox className={styles.sidebar} gap={12}>
<div>{t('shareModal.pdfErrorDescription')}</div>
{generateButton}
</Flexbox>
</Flexbox>
);
}
return (
<Flexbox className={styles.body} gap={16} horizontal={!isMobile}>
<PdfPreview loading={loading} onGeneratePdf={handleGeneratePdf} pdfData={pdfData} />
{pdfData && (
<Flexbox className={styles.sidebar} gap={12}>
{pdfData && generateButton}
{downloadButton}
</Flexbox>
)}
</Flexbox>
);
});
export default SharePdf;

View File

@@ -0,0 +1,63 @@
import { createStyles } from 'antd-style';
export const useContainerStyles = createStyles(({ css, token, stylish, cx, responsive }) => ({
preview: cx(
stylish.noScrollbar,
css`
overflow: hidden scroll;
width: 100%;
max-height: 70dvh;
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgLayout};
/* stylelint-disable selector-class-pattern */
.react-pdf__Document *,
.react-pdf__Page * {
pointer-events: none;
}
/* stylelint-enable selector-class-pattern */
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
${responsive.mobile} {
max-height: 40dvh;
}
`,
),
}));
export const useStyles = createStyles(({ responsive, token, css }) => ({
body: css`
${responsive.mobile} {
padding-block-end: 68px;
}
`,
footer: css`
${responsive.mobile} {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
width: 100%;
margin: 0;
padding: 16px;
background: ${token.colorBgContainer};
}
`,
sidebar: css`
flex: none;
width: max(240px, 25%);
${responsive.mobile} {
flex: 1;
width: unset;
margin-inline: -16px;
}
`,
}));

View File

@@ -0,0 +1,24 @@
import { template } from 'lodash-es';
import { LOADING_FLAT } from '@/const/message';
import { ChatMessage } from '@/types/message';
const markdownTemplate = template(`{{message.content}}`, {
evaluate: /<%([\S\s]+?)%>/g,
interpolate: /{{([\S\s]+?)}}/g,
});
interface MarkdownParams {
message: ChatMessage;
}
export const generateMarkdown = ({ message }: MarkdownParams) => {
// Filter out loading content
if (message.content === LOADING_FLAT) {
return '';
}
return markdownTemplate({
message,
});
};

View File

@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react';
import { lambdaQuery } from '@/libs/trpc/client/lambda';
interface PdfGenerationParams {
content: string;
sessionId: string;
title?: string;
topicId?: string;
}
interface PdfGenerationState {
downloadPdf: () => Promise<void>;
error: string | null;
generatePdf: (params: PdfGenerationParams) => Promise<void>;
loading: boolean;
pdfData: string | null;
}
export const usePdfGeneration = (): PdfGenerationState => {
const [pdfData, setPdfData] = useState<string | null>(null);
const [filename, setFilename] = useState<string>('chat-export.pdf');
const [error, setError] = useState<string | null>(null);
const [lastGeneratedKey, setLastGeneratedKey] = useState<string | null>(null);
const exportPdfMutation = lambdaQuery.exporter.exportPdf.useMutation();
const generatePdf = useCallback(
async (params: PdfGenerationParams) => {
const { content, sessionId, title, topicId } = params;
// Create a key to identify this specific request
const requestKey = `${sessionId}-${topicId || 'default'}-${content.length}`;
// Prevent multiple simultaneous requests or re-generating the same PDF
if (exportPdfMutation.isPending || lastGeneratedKey === requestKey) return;
try {
setError(null);
setPdfData(null);
const result = await exportPdfMutation.mutateAsync({
content,
sessionId,
title,
topicId,
});
setPdfData(result.pdf);
setFilename(result.filename);
setLastGeneratedKey(requestKey);
} catch (error) {
console.error('Failed to generate PDF:', error);
setError(error instanceof Error ? error.message : 'Failed to generate PDF');
}
},
[exportPdfMutation.mutateAsync, lastGeneratedKey],
);
const downloadPdf = useCallback(async () => {
if (!pdfData) return;
try {
// Convert base64 to blob
const byteCharacters = atob(pdfData);
const byteNumbers = Array.from({ length: byteCharacters.length }, (_, i) =>
byteCharacters.charCodeAt(i),
);
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download PDF:', error);
throw error;
}
}, [pdfData, filename]);
return {
downloadPdf,
error: error || (exportPdfMutation.error?.message ?? null),
generatePdf,
loading: exportPdfMutation.isPending,
pdfData,
};
};

View File

@@ -1,15 +1,18 @@
import { Modal, Segmented, type SegmentedProps } from '@lobehub/ui';
import { Modal, Segmented, Tabs } from '@lobehub/ui';
import { memo, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { isServerMode } from '@/const/version';
import { useIsMobile } from '@/hooks/useIsMobile';
import { ChatMessage } from '@/types/message';
import ShareImage from './ShareImage';
import ShareText from './ShareText';
import SharePdf from '@/features/ShareModal/SharePdf';
enum Tab {
PDF = 'pdf',
Screenshot = 'screenshot',
Text = 'text',
}
@@ -24,26 +27,39 @@ const ShareModal = memo<ShareModalProps>(({ onCancel, open, message }) => {
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
const { t } = useTranslation('chat');
const uniqueId = useId();
const options: SegmentedProps['options'] = useMemo(
() => [
{
label: t('shareModal.screenshot'),
value: Tab.Screenshot,
},
{
label: t('shareModal.text'),
value: Tab.Text,
},
],
[],
);
const isMobile = useIsMobile();
const tabItems = useMemo(() => {
const items = [
{
children: <ShareImage message={message} mobile={isMobile} uniqueId={uniqueId} />,
key: Tab.Screenshot,
label: t('shareModal.screenshot'),
},
{
children: <ShareText item={message} />,
key: Tab.Text,
label: t('shareModal.text'),
},
];
// Only add PDF tab in server mode
if (isServerMode) {
items.push({
children: <SharePdf message={message} />,
key: Tab.PDF,
label: t('shareModal.pdf'),
});
}
return items;
}, [isMobile, message, uniqueId, t]);
return (
<Modal
allowFullscreen
centered={false}
destroyOnHidden={true}
footer={null}
onCancel={onCancel}
open={open}
@@ -54,15 +70,24 @@ const ShareModal = memo<ShareModalProps>(({ onCancel, open, message }) => {
<Segmented
block
onChange={(value) => setTab(value as Tab)}
options={options}
options={tabItems.map((item) => {
return {
label: item?.label,
value: item?.key,
};
})}
style={{ width: '100%' }}
value={tab}
variant={'filled'}
/>
{tab === Tab.Screenshot && (
<ShareImage message={message} mobile={isMobile} uniqueId={uniqueId} />
)}
{tab === Tab.Text && <ShareText item={message} />}
<Tabs
activeKey={tab}
indicator={{ align: 'center', size: (origin) => origin - 20 }}
items={tabItems}
onChange={(key) => setTab(key as Tab)}
// eslint-disable-next-line react/jsx-no-useless-fragment
renderTabBar={() => <></>}
/>
</Flexbox>
</Modal>
);

View File

@@ -0,0 +1,361 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Button } from '@lobehub/ui';
import { Input, Modal, Spin } from 'antd';
import { createStyles } from 'antd-style';
import { ChevronLeft, ChevronRight, Expand, FileText } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Document, Page, pdfjs } from 'react-pdf';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useContainerStyles } from '../style';
// Set PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `https://registry.npmmirror.com/pdfjs-dist/${pdfjs.version}/files/build/pdf.worker.min.mjs`;
const useStyles = createStyles(({ css }) => ({
containerWrapper: css`
position: relative;
width: 100%;
height: 100%;
`,
documentLoading: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
`,
emptyState: css`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
`,
expandButton: css`
position: absolute;
z-index: 1000;
inset-block-start: 20px;
inset-inline-end: 20px;
`,
footerNavigation: css`
position: absolute;
z-index: 10;
inset-block-end: 0;
inset-inline: 0 0;
padding: 12px;
border-block-start: 1px solid rgba(0, 0, 0, 10%);
background: rgba(255, 255, 255, 90%);
backdrop-filter: blur(8px);
`,
fullscreenButton: css`
border-color: white;
color: white;
`,
fullscreenContent: css`
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100%;
padding: 20px;
`,
fullscreenModal: css`
position: relative;
overflow: auto;
height: 90vh;
`,
fullscreenNavigation: css`
position: fixed;
z-index: 1001;
inset-block-end: 20px;
inset-inline-start: 50%;
transform: translateX(-50%);
padding-block: 12px;
padding-inline: 20px;
border-radius: 8px;
background: rgba(0, 0, 0, 70%);
backdrop-filter: blur(8px);
`,
fullscreenPageInput: css`
width: 60px;
text-align: center;
`,
fullscreenPageText: css`
min-width: 20px;
font-size: 14px;
color: white;
`,
loadingState: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
`,
loadingText: css`
margin-block-start: 8px;
color: #666;
`,
pageInput: css`
width: 50px;
text-align: center;
`,
pageNumberText: css`
font-size: 12px;
color: #666;
`,
previewContainer: css`
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
`,
}));
interface PdfPreviewProps {
loading: boolean;
onGeneratePdf?: () => void;
pdfData: string | null;
}
const PdfPreview = memo<PdfPreviewProps>(({ loading, pdfData, onGeneratePdf }) => {
const { styles } = useContainerStyles();
const { styles: localStyles } = useStyles();
const { t } = useTranslation('chat');
const isMobile = useIsMobile();
// Page navigation state
const [numPages, setNumPages] = useState<number>(0);
const [pageNumber, setPageNumber] = useState<number>(1);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [fullscreenPageNumber, setFullscreenPageNumber] = useState<number>(1);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setPageNumber(1);
};
const goToPrevPage = () => {
if (pageNumber > 1) {
setPageNumber(pageNumber - 1);
}
};
const goToNextPage = () => {
if (pageNumber < numPages) {
setPageNumber(pageNumber + 1);
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= numPages) {
setPageNumber(page);
}
};
const handleFullscreen = () => {
if (pdfData) {
setFullscreenPageNumber(pageNumber);
setFullscreenOpen(true);
}
};
const goToFullscreenPrevPage = () => {
if (fullscreenPageNumber > 1) {
setFullscreenPageNumber(fullscreenPageNumber - 1);
}
};
const goToFullscreenNextPage = () => {
if (fullscreenPageNumber < numPages) {
setFullscreenPageNumber(fullscreenPageNumber + 1);
}
};
const goToFullscreenPage = (page: number) => {
if (page >= 1 && page <= numPages) {
setFullscreenPageNumber(page);
}
};
if (loading) {
return (
<div className={styles.preview} style={{ padding: 12 }}>
<div className={localStyles.loadingState}>
<Spin indicator={<LoadingOutlined spin style={{ fontSize: 24 }} />} />
<div className={localStyles.loadingText}>{t('shareModal.generatingPdf')}</div>
</div>
</div>
);
}
if (!pdfData) {
return (
<div className={styles.preview} style={{ padding: 12 }}>
<div className={localStyles.emptyState}>
<Button icon={<FileText size={20} />} onClick={onGeneratePdf} size="large" type="primary">
{t('shareModal.generatePdf', { defaultValue: '生成 PDF' })}
</Button>
</div>
</div>
);
}
// Convert base64 to data URI
const pdfDataUri = `data:application/pdf;base64,${pdfData}`;
return (
<>
<div className={localStyles.containerWrapper}>
{pdfData && (
<Button
className={localStyles.expandButton}
icon={<Expand size={16} />}
onClick={handleFullscreen}
size="small"
type="text"
/>
)}
<div className={`${styles.preview} ${localStyles.previewContainer}`}>
<Document
file={pdfDataUri}
loading={
<div className={localStyles.documentLoading}>
<Spin />
<div className={localStyles.loadingText}>
{t('shareModal.loadingPdf', { defaultValue: 'Loading PDF...' })}
</div>
</div>
}
onLoadSuccess={onDocumentLoadSuccess}
>
<Page
pageNumber={pageNumber}
renderAnnotationLayer={false}
renderTextLayer={false}
width={isMobile ? 300 : 400}
/>
</Document>
</div>
{/* 页脚导航 */}
{pdfData && numPages > 1 && (
<div className={localStyles.footerNavigation}>
<Flexbox align="center" gap={8} horizontal justify="center">
<Button
disabled={pageNumber <= 1}
icon={<ChevronLeft size={16} />}
onClick={goToPrevPage}
size="small"
type="text"
/>
<Flexbox align="center" gap={4} horizontal>
<Input
className={localStyles.pageInput}
max={numPages}
min={1}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) goToPage(value);
}}
size="small"
type="number"
value={pageNumber}
/>
<span className={localStyles.pageNumberText}>/ {numPages}</span>
</Flexbox>
<Button
disabled={pageNumber >= numPages}
icon={<ChevronRight size={16} />}
onClick={goToNextPage}
size="small"
type="text"
/>
</Flexbox>
</div>
)}
</div>
{/* 全屏模态框 */}
<Modal
centered
footer={null}
onCancel={() => setFullscreenOpen(false)}
open={fullscreenOpen}
styles={{
body: { padding: 0 },
content: { padding: 0 },
}}
width="95vw"
>
<div className={localStyles.fullscreenModal}>
<div className={localStyles.fullscreenContent}>
<Document file={pdfDataUri} onLoadSuccess={onDocumentLoadSuccess}>
<Page
pageNumber={fullscreenPageNumber}
renderAnnotationLayer={false}
renderTextLayer={false}
width={Math.min(window.innerWidth * 0.8, 1000)}
/>
</Document>
</div>
{/* 全屏模式下的导航 */}
{numPages > 1 && (
<div className={localStyles.fullscreenNavigation}>
<Flexbox align="center" gap={12} horizontal>
<Button
className={localStyles.fullscreenButton}
disabled={fullscreenPageNumber <= 1}
icon={<ChevronLeft size={16} />}
onClick={goToFullscreenPrevPage}
size="small"
type="text"
/>
<Flexbox align="center" gap={8} horizontal>
<Input
className={localStyles.fullscreenPageInput}
max={numPages}
min={1}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) goToFullscreenPage(value);
}}
size="small"
type="number"
value={fullscreenPageNumber}
/>
<span className={localStyles.fullscreenPageText}>/ {numPages}</span>
</Flexbox>
<Button
className={localStyles.fullscreenButton}
disabled={fullscreenPageNumber >= numPages}
icon={<ChevronRight size={16} />}
onClick={goToFullscreenNextPage}
size="small"
type="text"
/>
</Flexbox>
</div>
)}
</div>
</Modal>
</>
);
});
export default PdfPreview;

View File

@@ -0,0 +1,194 @@
import { Button, Form, type FormItemProps } from '@lobehub/ui';
import { App, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { DownloadIcon, FileText } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { generateMarkdown } from '../ShareText/template';
import { FieldType } from '../ShareText/type';
import { useContainerStyles, useStyles } from '../style';
import PdfPreview from './PdfPreview';
import { usePdfGeneration } from './usePdfGeneration';
import { ChatMessage } from '@/types/message';
const DEFAULT_FIELD_VALUE: FieldType = {
includeTool: true,
includeUser: true,
withRole: true,
withSystemRole: false,
};
const SharePdf = memo((props: {message?: ChatMessage}) => {
const [fieldValue, setFieldValue] = useState(DEFAULT_FIELD_VALUE);
const { t } = useTranslation(['chat', 'common']);
const { styles } = useStyles();
const { styles: containerStyles } = useContainerStyles();
const { message } = App.useApp();
const { message: outerMessage } = props;
const isMobile = useIsMobile();
const settings: FormItemProps[] = [
{
children: <Switch />,
label: t('shareModal.withSystemRole'),
layout: 'horizontal',
minWidth: undefined,
name: 'withSystemRole',
valuePropName: 'checked',
},
{
children: <Switch />,
label: t('shareModal.withRole'),
layout: 'horizontal',
minWidth: undefined,
name: 'withRole',
valuePropName: 'checked',
},
{
children: <Switch />,
label: t('shareModal.includeUser'),
layout: 'horizontal',
minWidth: undefined,
name: 'includeUser',
valuePropName: 'checked',
},
{
children: <Switch />,
label: t('shareModal.includeTool'),
layout: 'horizontal',
minWidth: undefined,
name: 'includeTool',
valuePropName: 'checked',
},
];
// Use the same data gathering logic as ShareText
const [systemRole] = useAgentStore((s) => [agentSelectors.currentAgentSystemRole(s)]);
const messages = useChatStore(chatSelectors.activeBaseChats, isEqual);
const topic = useChatStore(topicSelectors.currentActiveTopic, isEqual);
const activeId = useChatStore((s) => s.activeId);
const topicId = useChatStore((s) => s.activeTopicId);
const title = topic?.title || t('shareModal.exportTitle');
const { generatePdf, downloadPdf, pdfData, loading, error } = usePdfGeneration();
const handleGeneratePdf = async () => {
if (activeId && messages.length > 0) {
// Generate markdown with current field values
const currentMarkdownContent = generateMarkdown({
...fieldValue,
messages: outerMessage ? [outerMessage] : messages,
systemRole,
title,
}).replaceAll('\n\n\n', '\n');
if (currentMarkdownContent.trim()) {
await generatePdf({
content: currentMarkdownContent,
sessionId: activeId,
title,
topicId: topicId || undefined,
});
}
}
};
// Update configuration when form changes
const handleConfigChange = (_changedValues: any, allValues: FieldType) => {
setFieldValue(allValues);
};
const handleDownload = async () => {
if (pdfData) {
try {
await downloadPdf();
message.success(t('shareModal.downloadSuccess'));
} catch {
message.error(t('shareModal.downloadError'));
}
}
};
const generateButton = (
<Button
block
disabled={loading}
icon={loading ? undefined : FileText}
loading={loading}
onClick={handleGeneratePdf}
size={isMobile ? undefined : 'large'}
type="primary"
>
{loading
? t('shareModal.generatingPdf')
: pdfData
? t('shareModal.regeneratePdf', { defaultValue: '重新生成 PDF' })
: t('shareModal.generatePdf', { defaultValue: '生成 PDF' })}
</Button>
);
const downloadButton = pdfData ? (
<Button
block
icon={DownloadIcon}
onClick={handleDownload}
size={isMobile ? undefined : 'large'}
type="default"
>
{t('shareModal.downloadPdf')}
</Button>
) : null;
if (error) {
return (
<Flexbox className={styles.body} gap={16} horizontal={!isMobile}>
<div className={containerStyles.preview} style={{ padding: 12 }}>
<div style={{ color: 'red', textAlign: 'center' }}>
{t('shareModal.pdfGenerationError')}: {error}
</div>
</div>
<Flexbox className={styles.sidebar} gap={12}>
<div>{t('shareModal.pdfErrorDescription')}</div>
<Form
initialValues={DEFAULT_FIELD_VALUE}
items={settings}
itemsType={'flat'}
onValuesChange={handleConfigChange}
{...FORM_STYLE}
/>
{generateButton}
</Flexbox>
</Flexbox>
);
}
return (
<Flexbox className={styles.body} gap={16} horizontal={!isMobile}>
<PdfPreview loading={loading} onGeneratePdf={handleGeneratePdf} pdfData={pdfData} />
<Flexbox className={styles.sidebar} gap={12}>
<Form
initialValues={DEFAULT_FIELD_VALUE}
items={settings}
itemsType={'flat'}
onValuesChange={handleConfigChange}
{...FORM_STYLE}
/>
{pdfData && generateButton}
{downloadButton}
</Flexbox>
</Flexbox>
);
});
export default SharePdf;

View File

@@ -0,0 +1,90 @@
import { useCallback, useState } from 'react';
import { lambdaQuery } from '@/libs/trpc/client/lambda';
interface PdfGenerationParams {
content: string;
sessionId: string;
title: string;
topicId?: string;
}
interface PdfGenerationState {
downloadPdf: () => Promise<void>;
error: string | null;
generatePdf: (params: PdfGenerationParams) => Promise<void>;
loading: boolean;
pdfData: string | null;
}
export const usePdfGeneration = (): PdfGenerationState => {
const [pdfData, setPdfData] = useState<string | null>(null);
const [filename, setFilename] = useState<string>('chat-export.pdf');
const [error, setError] = useState<string | null>(null);
const [lastGeneratedKey, setLastGeneratedKey] = useState<string | null>(null);
const exportPdfMutation = lambdaQuery.exporter.exportPdf.useMutation();
const generatePdf = useCallback(async (params: PdfGenerationParams) => {
const { content, sessionId, title, topicId } = params;
// Create a key to identify this specific request
const requestKey = `${sessionId}-${topicId || 'default'}-${content.length}`;
// Prevent multiple simultaneous requests or re-generating the same PDF
if (exportPdfMutation.isPending || lastGeneratedKey === requestKey) return;
try {
setError(null);
setPdfData(null);
const result = await exportPdfMutation.mutateAsync({
content,
sessionId,
title,
topicId,
});
setPdfData(result.pdf);
setFilename(result.filename);
setLastGeneratedKey(requestKey);
} catch (error) {
console.error('Failed to generate PDF:', error);
setError(error instanceof Error ? error.message : 'Failed to generate PDF');
}
}, [exportPdfMutation.mutateAsync, lastGeneratedKey]);
const downloadPdf = useCallback(async () => {
if (!pdfData) return;
try {
// Convert base64 to blob
const byteCharacters = atob(pdfData);
const byteNumbers = Array.from({ length: byteCharacters.length }, (_, i) =>
byteCharacters.charCodeAt(i)
);
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download PDF:', error);
throw error;
}
}, [pdfData, filename]);
return {
downloadPdf,
error: error || (exportPdfMutation.error?.message ?? null),
generatePdf,
loading: exportPdfMutation.isPending,
pdfData,
};
};

View File

@@ -1,16 +1,19 @@
import { Modal, type ModalProps, Segmented, type SegmentedProps } from '@lobehub/ui';
import { Modal, type ModalProps, Segmented, Tabs } from '@lobehub/ui';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { isServerMode } from '@/const/version';
import { useIsMobile } from '@/hooks/useIsMobile';
import ShareImage from './ShareImage';
import ShareJSON from './ShareJSON';
import SharePdf from './SharePdf';
import ShareText from './ShareText';
enum Tab {
JSON = 'json',
PDF = 'pdf',
Screenshot = 'screenshot',
Text = 'text',
}
@@ -18,30 +21,43 @@ enum Tab {
const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
const { t } = useTranslation('chat');
const options: SegmentedProps['options'] = useMemo(
() => [
{
label: t('shareModal.screenshot'),
value: Tab.Screenshot,
},
{
label: t('shareModal.text'),
value: Tab.Text,
},
{
label: 'JSON',
value: Tab.JSON,
},
],
[],
);
const isMobile = useIsMobile();
const tabItems = useMemo(() => {
const items = [
{
children: <ShareImage mobile={isMobile} />,
key: Tab.Screenshot,
label: t('shareModal.screenshot'),
},
{
children: <ShareText />,
key: Tab.Text,
label: t('shareModal.text'),
},
{
children: <ShareJSON />,
key: Tab.JSON,
label: 'JSON',
},
];
// Only add PDF tab in server mode
if (isServerMode) {
items.splice(2, 0, {
children: <SharePdf />,
key: Tab.PDF,
label: t('shareModal.pdf'),
});
}
return items;
}, [isMobile, t]);
return (
<Modal
allowFullscreen
centered={false}
destroyOnHidden={true}
footer={null}
onCancel={onCancel}
open={open}
@@ -52,14 +68,24 @@ const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
<Segmented
block
onChange={(value) => setTab(value as Tab)}
options={options}
options={tabItems.map((item) => {
return {
label: item?.label,
value: item?.key,
};
})}
style={{ width: '100%' }}
value={tab}
variant={'filled'}
/>
{tab === Tab.Screenshot && <ShareImage mobile={isMobile} />}
{tab === Tab.Text && <ShareText />}
{tab === Tab.JSON && <ShareJSON />}
<Tabs
activeKey={tab}
indicator={{ align: 'center', size: (origin) => origin - 20 }}
items={tabItems}
onChange={(key) => setTab(key as Tab)}
// eslint-disable-next-line react/jsx-no-useless-fragment
renderTabBar={() => <></>}
/>
</Flexbox>
</Modal>
);

View File

@@ -13,14 +13,17 @@ export const useContainerStyles = createStyles(({ css, token, stylish, cx, respo
background: ${token.colorBgLayout};
* {
/* stylelint-disable selector-class-pattern */
.react-pdf__Document *,
.react-pdf__Page * {
pointer-events: none;
}
/* stylelint-enable selector-class-pattern */
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
${responsive.mobile} {
max-height: 40dvh;

View File

@@ -335,10 +335,23 @@ export default {
copy: '复制',
download: '下载截图',
downloadFile: '下载文件',
downloadPdf: '下载 PDF',
downloadSuccess: '下载成功',
downloadError: '下载失败',
exportPdf: '导出为 PDF',
exportTitle: '默认标题',
generatePdf: '生成 PDF',
generatingPdf: '正在生成 PDF...',
imageType: '图片格式',
includeTool: '包含插件消息',
includeUser: '包含用户消息',
loadingPdf: '加载 PDF...',
noPdfData: '暂无 PDF 数据',
pdf: 'PDF',
pdfErrorDescription: '生成 PDF 时出现错误,请重试',
pdfGenerationError: 'PDF 生成失败',
pdfReady: 'PDF 已准备就绪',
regeneratePdf: '重新生成 PDF',
screenshot: '截图',
settings: '导出设置',
text: '文本',

View File

@@ -1,4 +1,10 @@
import { marked } from 'marked';
import PDFDocument from 'pdfkit';
import { z } from 'zod';
import { DrizzleMigrationModel } from '@/database/models/drizzleMigration';
import { MessageModel } from '@/database/models/message';
import { SessionModel } from '@/database/models/session';
import { DataExporterRepos } from '@/database/repositories/dataExporter';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
@@ -8,18 +14,182 @@ const exportProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
const { ctx } = opts;
const dataExporterRepos = new DataExporterRepos(ctx.serverDB, ctx.userId);
const drizzleMigration = new DrizzleMigrationModel(ctx.serverDB);
const messageModel = new MessageModel(ctx.serverDB, ctx.userId);
const sessionModel = new SessionModel(ctx.serverDB, ctx.userId);
return opts.next({
ctx: { dataExporterRepos, drizzleMigration },
ctx: { dataExporterRepos, drizzleMigration, messageModel, sessionModel },
});
});
const REGULAR_FONT_URL =
'https://cdn.jsdelivr.net/gh/adobe-fonts/source-han-sans@2.004R/OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf';
let regularFontCache: Buffer | null = null;
const loadRegularFont = async (): Promise<Buffer> => {
if (regularFontCache) return regularFontCache;
const response = await fetch(REGULAR_FONT_URL);
if (!response.ok) {
throw new Error(`Failed to fetch font from CDN: ${response.status} ${response.statusText}`);
}
const fontBuffer = Buffer.from(await response.arrayBuffer());
regularFontCache = fontBuffer;
return fontBuffer;
};
const generatePdfFromMarkdown = async (
markdownContent: string,
title?: string,
): Promise<Buffer> => {
const regularFont = await loadRegularFont();
return new Promise((resolve, reject) => {
try {
const tokens = marked.lexer(markdownContent);
const doc = new PDFDocument({
bufferPages: true,
margins: {
bottom: 50,
left: 50,
right: 50,
top: 50,
},
size: 'A4',
});
const chunks: Buffer[] = [];
doc.registerFont('Regular', regularFont);
doc.font('Regular');
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => {
const pdfBuffer = Buffer.concat(chunks);
resolve(pdfBuffer);
});
doc.on('error', reject);
if (title) {
doc.fontSize(20).text(title, { align: 'center' });
}
doc.moveDown(2);
let currentY = doc.y;
for (const token of tokens) {
if (currentY > 700) {
doc.addPage();
currentY = 50;
}
switch (token.type) {
case 'heading': {
const headingSize = Math.max(16 - (token.depth - 1) * 2, 12);
doc.fontSize(headingSize).fillColor('#222').text(token.text, { continued: false });
doc.moveDown(0.5);
break;
}
case 'paragraph': {
doc.fontSize(12).fillColor('#333').text(token.text, { align: 'left', lineGap: 2 });
doc.moveDown(1);
break;
}
case 'list': {
for (const item of token.items) {
doc.fontSize(12).fillColor('#333').text(`${item.text}`, { indent: 20, lineGap: 2 });
}
doc.moveDown(1);
break;
}
case 'blockquote': {
doc.fontSize(12).fillColor('#666').text(token.text, { indent: 20, lineGap: 2 });
doc.moveDown(1);
break;
}
case 'code': {
doc.fontSize(10).fillColor('#333').text(token.text, {
continued: false,
indent: 20,
lineGap: 1,
});
doc.moveDown(1);
break;
}
case 'hr': {
doc.moveTo(50, doc.y).lineTo(545, doc.y).stroke();
doc.moveDown(1);
break;
}
default: {
if ('text' in token && token.text) {
doc.fontSize(12).fillColor('#333').text(token.text, { align: 'left', lineGap: 2 });
doc.moveDown(1);
}
break;
}
}
currentY = doc.y;
}
const pages = doc.bufferedPageRange();
for (let i = 0; i < pages.count; i++) {
doc.switchToPage(i);
doc
.fontSize(8)
.fillColor('#666')
.text(`Page ${i + 1} of ${pages.count}`, 50, 750, {
align: 'center',
width: 495,
});
}
// 完成文档
doc.end();
} catch (error) {
reject(
new Error(
`PDFKit PDF generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
),
);
}
});
};
export const exporterRouter = router({
exportData: exportProcedure.mutation(async ({ ctx }): Promise<ExportDatabaseData> => {
const data = await ctx.dataExporterRepos.export(5);
const schemaHash = await ctx.drizzleMigration.getLatestMigrationHash();
return { data, schemaHash };
}),
exportPdf: exportProcedure
.input(
z.object({
content: z.string(),
sessionId: z.string(),
title: z.string().optional(),
topicId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
const { content, title } = input;
const pdfBuffer = await generatePdfFromMarkdown(content, title);
return {
filename: `${title}.pdf`,
pdf: pdfBuffer.toString('base64'),
};
}),
});