mirror of
https://github.com/lobehub/lobe-chat.git
synced 2025-12-21 01:44:46 +08:00
✨ 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:
@@ -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": "نص",
|
||||
|
||||
@@ -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": "Текст",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "متن",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "テキスト",
|
||||
|
||||
@@ -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": "텍스트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Текст",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "文本",
|
||||
|
||||
@@ -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": "文本",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
361
src/features/ShareModal/SharePdf/PdfPreview.tsx
Normal file
361
src/features/ShareModal/SharePdf/PdfPreview.tsx
Normal 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;
|
||||
194
src/features/ShareModal/SharePdf/index.tsx
Normal file
194
src/features/ShareModal/SharePdf/index.tsx
Normal 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;
|
||||
90
src/features/ShareModal/SharePdf/usePdfGeneration.ts
Normal file
90
src/features/ShareModal/SharePdf/usePdfGeneration.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '文本',
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user