mirror of
https://github.com/lobehub/lobe-chat.git
synced 2025-12-22 18:44:24 +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": {
|
"shareModal": {
|
||||||
"copy": "نسخ",
|
"copy": "نسخ",
|
||||||
"download": "تحميل اللقطة",
|
"download": "تحميل اللقطة",
|
||||||
|
"downloadError": "فشل التنزيل",
|
||||||
"downloadFile": "تحميل الملف",
|
"downloadFile": "تحميل الملف",
|
||||||
|
"downloadPdf": "تنزيل PDF",
|
||||||
|
"downloadSuccess": "تم التنزيل بنجاح",
|
||||||
|
"exportPdf": "تصدير إلى PDF",
|
||||||
"exportTitle": "العنوان الافتراضي",
|
"exportTitle": "العنوان الافتراضي",
|
||||||
|
"generatePdf": "إنشاء ملف PDF",
|
||||||
|
"generatingPdf": "جارٍ إنشاء PDF...",
|
||||||
"imageType": "نوع الصورة",
|
"imageType": "نوع الصورة",
|
||||||
"includeTool": "تضمين رسالة الأداة",
|
"includeTool": "تضمين رسالة الأداة",
|
||||||
"includeUser": "تضمين رسالة المستخدم",
|
"includeUser": "تضمين رسالة المستخدم",
|
||||||
|
"loadingPdf": "جارٍ تحميل ملف PDF...",
|
||||||
|
"noPdfData": "لا توجد بيانات PDF",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "حدث خطأ أثناء إنشاء PDF، يرجى المحاولة مرة أخرى",
|
||||||
|
"pdfGenerationError": "فشل إنشاء PDF",
|
||||||
|
"pdfReady": "تم تجهيز PDF",
|
||||||
|
"regeneratePdf": "إعادة إنشاء ملف PDF",
|
||||||
"screenshot": "لقطة شاشة",
|
"screenshot": "لقطة شاشة",
|
||||||
"settings": "إعدادات التصدير",
|
"settings": "إعدادات التصدير",
|
||||||
"text": "نص",
|
"text": "نص",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Копирай",
|
"copy": "Копирай",
|
||||||
"download": "Изтегли екранна снимка",
|
"download": "Изтегли екранна снимка",
|
||||||
|
"downloadError": "Грешка при изтегляне",
|
||||||
"downloadFile": "Изтегли файла",
|
"downloadFile": "Изтегли файла",
|
||||||
|
"downloadPdf": "Изтегляне на PDF",
|
||||||
|
"downloadSuccess": "Изтеглянето е успешно",
|
||||||
|
"exportPdf": "Експортиране като PDF",
|
||||||
"exportTitle": "По подразбиране заглавие",
|
"exportTitle": "По подразбиране заглавие",
|
||||||
|
"generatePdf": "Генериране на PDF",
|
||||||
|
"generatingPdf": "Генериране на PDF...",
|
||||||
"imageType": "Формат на изображението",
|
"imageType": "Формат на изображението",
|
||||||
"includeTool": "Включи съобщения от инструмента",
|
"includeTool": "Включи съобщения от инструмента",
|
||||||
"includeUser": "Включи съобщения от потребителя",
|
"includeUser": "Включи съобщения от потребителя",
|
||||||
|
"loadingPdf": "Зареждане на PDF...",
|
||||||
|
"noPdfData": "Няма налични PDF данни",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "Възникна грешка при генерирането на PDF, моля опитайте отново",
|
||||||
|
"pdfGenerationError": "Грешка при генериране на PDF",
|
||||||
|
"pdfReady": "PDF е готов",
|
||||||
|
"regeneratePdf": "Генериране на PDF отново",
|
||||||
"screenshot": "Екранна снимка",
|
"screenshot": "Екранна снимка",
|
||||||
"settings": "Настройки за експортиране",
|
"settings": "Настройки за експортиране",
|
||||||
"text": "Текст",
|
"text": "Текст",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"download": "Screenshot herunterladen",
|
"download": "Screenshot herunterladen",
|
||||||
|
"downloadError": "Download fehlgeschlagen",
|
||||||
"downloadFile": "Datei herunterladen",
|
"downloadFile": "Datei herunterladen",
|
||||||
|
"downloadPdf": "PDF herunterladen",
|
||||||
|
"downloadSuccess": "Download erfolgreich",
|
||||||
|
"exportPdf": "Als PDF exportieren",
|
||||||
"exportTitle": "Standardtitel",
|
"exportTitle": "Standardtitel",
|
||||||
|
"generatePdf": "PDF erstellen",
|
||||||
|
"generatingPdf": "PDF wird erstellt...",
|
||||||
"imageType": "Bildformat",
|
"imageType": "Bildformat",
|
||||||
"includeTool": "Plugin-Nachricht einfügen",
|
"includeTool": "Plugin-Nachricht einfügen",
|
||||||
"includeUser": "Benutzernachricht 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",
|
"screenshot": "Screenshot",
|
||||||
"settings": "Exporteinstellungen",
|
"settings": "Exporteinstellungen",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"download": "Download Screenshot",
|
"download": "Download Screenshot",
|
||||||
|
"downloadError": "Download failed",
|
||||||
"downloadFile": "Download File",
|
"downloadFile": "Download File",
|
||||||
|
"downloadPdf": "Download PDF",
|
||||||
|
"downloadSuccess": "Download successful",
|
||||||
|
"exportPdf": "Export as PDF",
|
||||||
"exportTitle": "Default Title",
|
"exportTitle": "Default Title",
|
||||||
|
"generatePdf": "Generate PDF",
|
||||||
|
"generatingPdf": "Generating PDF...",
|
||||||
"imageType": "Image Format",
|
"imageType": "Image Format",
|
||||||
"includeTool": "Include Plugin Messages",
|
"includeTool": "Include Plugin Messages",
|
||||||
"includeUser": "Include User 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",
|
"screenshot": "Screenshot",
|
||||||
"settings": "Export Settings",
|
"settings": "Export Settings",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"download": "Descargar captura de pantalla",
|
"download": "Descargar captura de pantalla",
|
||||||
|
"downloadError": "Error al descargar",
|
||||||
"downloadFile": "Descargar archivo",
|
"downloadFile": "Descargar archivo",
|
||||||
|
"downloadPdf": "Descargar PDF",
|
||||||
|
"downloadSuccess": "Descarga exitosa",
|
||||||
|
"exportPdf": "Exportar como PDF",
|
||||||
"exportTitle": "Título predeterminado",
|
"exportTitle": "Título predeterminado",
|
||||||
|
"generatePdf": "Generar PDF",
|
||||||
|
"generatingPdf": "Generando PDF...",
|
||||||
"imageType": "Tipo de imagen",
|
"imageType": "Tipo de imagen",
|
||||||
"includeTool": "Incluir mensajes de herramientas",
|
"includeTool": "Incluir mensajes de herramientas",
|
||||||
"includeUser": "Incluir mensajes de usuario",
|
"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",
|
"screenshot": "Captura de pantalla",
|
||||||
"settings": "Configuración de exportación",
|
"settings": "Configuración de exportación",
|
||||||
"text": "Texto",
|
"text": "Texto",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "کپی",
|
"copy": "کپی",
|
||||||
"download": "دانلود اسکرینشات",
|
"download": "دانلود اسکرینشات",
|
||||||
|
"downloadError": "دانلود ناموفق بود",
|
||||||
"downloadFile": "دانلود فایل",
|
"downloadFile": "دانلود فایل",
|
||||||
|
"downloadPdf": "دانلود PDF",
|
||||||
|
"downloadSuccess": "دانلود با موفقیت انجام شد",
|
||||||
|
"exportPdf": "صادر کردن به PDF",
|
||||||
"exportTitle": "عنوان پیشفرض",
|
"exportTitle": "عنوان پیشفرض",
|
||||||
|
"generatePdf": "ایجاد PDF",
|
||||||
|
"generatingPdf": "در حال تولید PDF...",
|
||||||
"imageType": "فرمت تصویر",
|
"imageType": "فرمت تصویر",
|
||||||
"includeTool": "شامل پیامهای ابزار",
|
"includeTool": "شامل پیامهای ابزار",
|
||||||
"includeUser": "شامل پیامهای کاربر",
|
"includeUser": "شامل پیامهای کاربر",
|
||||||
|
"loadingPdf": "در حال بارگذاری PDF...",
|
||||||
|
"noPdfData": "دادهای برای PDF موجود نیست",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "خطا در تولید PDF، لطفاً دوباره تلاش کنید",
|
||||||
|
"pdfGenerationError": "تولید PDF ناموفق بود",
|
||||||
|
"pdfReady": "PDF آماده است",
|
||||||
|
"regeneratePdf": "تولید مجدد PDF",
|
||||||
"screenshot": "اسکرینشات",
|
"screenshot": "اسکرینشات",
|
||||||
"settings": "تنظیمات خروجی",
|
"settings": "تنظیمات خروجی",
|
||||||
"text": "متن",
|
"text": "متن",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"download": "Télécharger la capture d'écran",
|
"download": "Télécharger la capture d'écran",
|
||||||
|
"downloadError": "Échec du téléchargement",
|
||||||
"downloadFile": "Télécharger le fichier",
|
"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",
|
"exportTitle": "Titre par défaut",
|
||||||
|
"generatePdf": "Générer le PDF",
|
||||||
|
"generatingPdf": "Génération du PDF en cours...",
|
||||||
"imageType": "Type d'image",
|
"imageType": "Type d'image",
|
||||||
"includeTool": "Inclure les messages de l'outil",
|
"includeTool": "Inclure les messages de l'outil",
|
||||||
"includeUser": "Inclure les messages de l'utilisateur",
|
"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",
|
"screenshot": "Capture d'écran",
|
||||||
"settings": "Paramètres d'exportation",
|
"settings": "Paramètres d'exportation",
|
||||||
"text": "Texte",
|
"text": "Texte",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Copia",
|
"copy": "Copia",
|
||||||
"download": "Scarica screenshot",
|
"download": "Scarica screenshot",
|
||||||
|
"downloadError": "Download fallito",
|
||||||
"downloadFile": "Scarica file",
|
"downloadFile": "Scarica file",
|
||||||
|
"downloadPdf": "Scarica PDF",
|
||||||
|
"downloadSuccess": "Download riuscito",
|
||||||
|
"exportPdf": "Esporta come PDF",
|
||||||
"exportTitle": "Titolo predefinito",
|
"exportTitle": "Titolo predefinito",
|
||||||
|
"generatePdf": "Genera PDF",
|
||||||
|
"generatingPdf": "Generazione PDF in corso...",
|
||||||
"imageType": "Tipo di immagine",
|
"imageType": "Tipo di immagine",
|
||||||
"includeTool": "Includi messaggio dello strumento",
|
"includeTool": "Includi messaggio dello strumento",
|
||||||
"includeUser": "Includi messaggio dell'utente",
|
"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",
|
"screenshot": "Screenshot",
|
||||||
"settings": "Impostazioni di esportazione",
|
"settings": "Impostazioni di esportazione",
|
||||||
"text": "Testo",
|
"text": "Testo",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"download": "スクリーンショットをダウンロード",
|
"download": "スクリーンショットをダウンロード",
|
||||||
|
"downloadError": "ダウンロード失敗",
|
||||||
"downloadFile": "ファイルをダウンロード",
|
"downloadFile": "ファイルをダウンロード",
|
||||||
|
"downloadPdf": "PDFをダウンロード",
|
||||||
|
"downloadSuccess": "ダウンロード成功",
|
||||||
|
"exportPdf": "PDFとしてエクスポート",
|
||||||
"exportTitle": "デフォルトタイトル",
|
"exportTitle": "デフォルトタイトル",
|
||||||
|
"generatePdf": "PDFを生成する",
|
||||||
|
"generatingPdf": "PDFを生成中...",
|
||||||
"imageType": "画像形式",
|
"imageType": "画像形式",
|
||||||
"includeTool": "ツールメッセージを含める",
|
"includeTool": "ツールメッセージを含める",
|
||||||
"includeUser": "ユーザーメッセージを含める",
|
"includeUser": "ユーザーメッセージを含める",
|
||||||
|
"loadingPdf": "PDFを読み込み中...",
|
||||||
|
"noPdfData": "PDFデータがありません",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "PDFの生成中にエラーが発生しました。再試行してください。",
|
||||||
|
"pdfGenerationError": "PDFの生成に失敗しました",
|
||||||
|
"pdfReady": "PDFの準備ができました",
|
||||||
|
"regeneratePdf": "PDFを再生成する",
|
||||||
"screenshot": "スクリーンショット",
|
"screenshot": "スクリーンショット",
|
||||||
"settings": "エクスポート設定",
|
"settings": "エクスポート設定",
|
||||||
"text": "テキスト",
|
"text": "テキスト",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "복사",
|
"copy": "복사",
|
||||||
"download": "스크린샷 다운로드",
|
"download": "스크린샷 다운로드",
|
||||||
|
"downloadError": "다운로드 실패",
|
||||||
"downloadFile": "파일 다운로드",
|
"downloadFile": "파일 다운로드",
|
||||||
|
"downloadPdf": "PDF 다운로드",
|
||||||
|
"downloadSuccess": "다운로드 성공",
|
||||||
|
"exportPdf": "PDF로 내보내기",
|
||||||
"exportTitle": "기본 제목",
|
"exportTitle": "기본 제목",
|
||||||
|
"generatePdf": "PDF 생성",
|
||||||
|
"generatingPdf": "PDF 생성 중...",
|
||||||
"imageType": "이미지 형식",
|
"imageType": "이미지 형식",
|
||||||
"includeTool": "플러그인 메시지 포함",
|
"includeTool": "플러그인 메시지 포함",
|
||||||
"includeUser": "사용자 메시지 포함",
|
"includeUser": "사용자 메시지 포함",
|
||||||
|
"loadingPdf": "PDF 로드 중...",
|
||||||
|
"noPdfData": "PDF 데이터가 없습니다",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "PDF 생성 중 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"pdfGenerationError": "PDF 생성 실패",
|
||||||
|
"pdfReady": "PDF가 준비되었습니다",
|
||||||
|
"regeneratePdf": "PDF 다시 생성",
|
||||||
"screenshot": "스크린샷",
|
"screenshot": "스크린샷",
|
||||||
"settings": "내보내기 설정",
|
"settings": "내보내기 설정",
|
||||||
"text": "텍스트",
|
"text": "텍스트",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Kopiëren",
|
"copy": "Kopiëren",
|
||||||
"download": "Screenshot downloaden",
|
"download": "Screenshot downloaden",
|
||||||
|
"downloadError": "Download mislukt",
|
||||||
"downloadFile": "Bestand downloaden",
|
"downloadFile": "Bestand downloaden",
|
||||||
|
"downloadPdf": "PDF downloaden",
|
||||||
|
"downloadSuccess": "Download geslaagd",
|
||||||
|
"exportPdf": "Exporteren als PDF",
|
||||||
"exportTitle": "Standaardtitel",
|
"exportTitle": "Standaardtitel",
|
||||||
|
"generatePdf": "PDF genereren",
|
||||||
|
"generatingPdf": "PDF wordt gegenereerd...",
|
||||||
"imageType": "Afbeeldingstype",
|
"imageType": "Afbeeldingstype",
|
||||||
"includeTool": "Inclusief pluginbericht",
|
"includeTool": "Inclusief pluginbericht",
|
||||||
"includeUser": "Inclusief gebruikersbericht",
|
"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",
|
"screenshot": "Screenshot",
|
||||||
"settings": "Exportinstellingen",
|
"settings": "Exportinstellingen",
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Kopiuj",
|
"copy": "Kopiuj",
|
||||||
"download": "Pobierz zrzut ekranu",
|
"download": "Pobierz zrzut ekranu",
|
||||||
|
"downloadError": "Błąd pobierania",
|
||||||
"downloadFile": "Pobierz plik",
|
"downloadFile": "Pobierz plik",
|
||||||
|
"downloadPdf": "Pobierz PDF",
|
||||||
|
"downloadSuccess": "Pobieranie zakończone sukcesem",
|
||||||
|
"exportPdf": "Eksportuj jako PDF",
|
||||||
"exportTitle": "Domyślny tytuł",
|
"exportTitle": "Domyślny tytuł",
|
||||||
|
"generatePdf": "Generuj PDF",
|
||||||
|
"generatingPdf": "Generowanie PDF...",
|
||||||
"imageType": "Typ obrazu",
|
"imageType": "Typ obrazu",
|
||||||
"includeTool": "Uwzględnij wiadomości z narzędzi",
|
"includeTool": "Uwzględnij wiadomości z narzędzi",
|
||||||
"includeUser": "Uwzględnij wiadomości od użytkowników",
|
"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",
|
"screenshot": "Zrzut ekranu",
|
||||||
"settings": "Ustawienia eksportu",
|
"settings": "Ustawienia eksportu",
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"download": "Baixar Captura de Tela",
|
"download": "Baixar Captura de Tela",
|
||||||
|
"downloadError": "Falha no download",
|
||||||
"downloadFile": "Baixar arquivo",
|
"downloadFile": "Baixar arquivo",
|
||||||
|
"downloadPdf": "Baixar PDF",
|
||||||
|
"downloadSuccess": "Download concluído com sucesso",
|
||||||
|
"exportPdf": "Exportar como PDF",
|
||||||
"exportTitle": "Título padrão",
|
"exportTitle": "Título padrão",
|
||||||
|
"generatePdf": "Gerar PDF",
|
||||||
|
"generatingPdf": "Gerando PDF...",
|
||||||
"imageType": "Tipo de Imagem",
|
"imageType": "Tipo de Imagem",
|
||||||
"includeTool": "Incluir mensagens de ferramentas",
|
"includeTool": "Incluir mensagens de ferramentas",
|
||||||
"includeUser": "Incluir mensagens de usuários",
|
"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",
|
"screenshot": "Captura de Tela",
|
||||||
"settings": "Configurações de Exportação",
|
"settings": "Configurações de Exportação",
|
||||||
"text": "Texto",
|
"text": "Texto",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
"download": "Скачать скриншот",
|
"download": "Скачать скриншот",
|
||||||
|
"downloadError": "Ошибка загрузки",
|
||||||
"downloadFile": "Скачать файл",
|
"downloadFile": "Скачать файл",
|
||||||
|
"downloadPdf": "Скачать PDF",
|
||||||
|
"downloadSuccess": "Загрузка успешна",
|
||||||
|
"exportPdf": "Экспорт в PDF",
|
||||||
"exportTitle": "Заголовок по умолчанию",
|
"exportTitle": "Заголовок по умолчанию",
|
||||||
|
"generatePdf": "Создать PDF",
|
||||||
|
"generatingPdf": "Генерация PDF...",
|
||||||
"imageType": "Тип изображения",
|
"imageType": "Тип изображения",
|
||||||
"includeTool": "Включить сообщения плагина",
|
"includeTool": "Включить сообщения плагина",
|
||||||
"includeUser": "Включить сообщения пользователя",
|
"includeUser": "Включить сообщения пользователя",
|
||||||
|
"loadingPdf": "Загрузка PDF...",
|
||||||
|
"noPdfData": "Данные PDF отсутствуют",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "Произошла ошибка при создании PDF, попробуйте снова",
|
||||||
|
"pdfGenerationError": "Не удалось создать PDF",
|
||||||
|
"pdfReady": "PDF готов",
|
||||||
|
"regeneratePdf": "Перегенерировать PDF",
|
||||||
"screenshot": "Скриншот",
|
"screenshot": "Скриншот",
|
||||||
"settings": "Настройки экспорта",
|
"settings": "Настройки экспорта",
|
||||||
"text": "Текст",
|
"text": "Текст",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Kopyala",
|
"copy": "Kopyala",
|
||||||
"download": "Ekran Görüntüsünü İndir",
|
"download": "Ekran Görüntüsünü İndir",
|
||||||
|
"downloadError": "İndirme Başarısız",
|
||||||
"downloadFile": "Dosyayı İndir",
|
"downloadFile": "Dosyayı İndir",
|
||||||
|
"downloadPdf": "PDF İndir",
|
||||||
|
"downloadSuccess": "İndirme Başarılı",
|
||||||
|
"exportPdf": "PDF Olarak Dışa Aktar",
|
||||||
"exportTitle": "Varsayılan Başlık",
|
"exportTitle": "Varsayılan Başlık",
|
||||||
|
"generatePdf": "PDF Oluştur",
|
||||||
|
"generatingPdf": "PDF Oluşturuluyor...",
|
||||||
"imageType": "Format",
|
"imageType": "Format",
|
||||||
"includeTool": "Eklenti mesajını dahil et",
|
"includeTool": "Eklenti mesajını dahil et",
|
||||||
"includeUser": "Kullanıcı 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ü",
|
"screenshot": "Ekran Görüntüsü",
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
"text": "Metin",
|
"text": "Metin",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "Sao chép",
|
"copy": "Sao chép",
|
||||||
"download": "Tải xuống ảnh chụp màn hình",
|
"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",
|
"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",
|
"exportTitle": "Tiêu đề mặc định",
|
||||||
|
"generatePdf": "Tạo PDF",
|
||||||
|
"generatingPdf": "Đang tạo PDF...",
|
||||||
"imageType": "Định dạng ảnh",
|
"imageType": "Định dạng ảnh",
|
||||||
"includeTool": "Bao gồm thông điệp công cụ",
|
"includeTool": "Bao gồm thông điệp công cụ",
|
||||||
"includeUser": "Bao gồm thông điệp người dùng",
|
"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",
|
"screenshot": "Ảnh chụp màn hình",
|
||||||
"settings": "Cài đặt xuất",
|
"settings": "Cài đặt xuất",
|
||||||
"text": "Văn bản",
|
"text": "Văn bản",
|
||||||
|
|||||||
@@ -305,10 +305,23 @@
|
|||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"download": "下载截图",
|
"download": "下载截图",
|
||||||
"downloadFile": "下载文件",
|
"downloadFile": "下载文件",
|
||||||
|
"downloadPdf": "下载 PDF",
|
||||||
|
"downloadSuccess": "下载成功",
|
||||||
|
"downloadError": "下载失败",
|
||||||
|
"exportPdf": "导出为 PDF",
|
||||||
"exportTitle": "默认标题",
|
"exportTitle": "默认标题",
|
||||||
|
"generatePdf": "生成 PDF",
|
||||||
|
"generatingPdf": "正在生成 PDF...",
|
||||||
"imageType": "图片格式",
|
"imageType": "图片格式",
|
||||||
"includeTool": "包含插件消息",
|
"includeTool": "包含插件消息",
|
||||||
"includeUser": "包含用户消息",
|
"includeUser": "包含用户消息",
|
||||||
|
"loadingPdf": "加载 PDF...",
|
||||||
|
"noPdfData": "暂无 PDF 数据",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "生成 PDF 时出现错误,请重试",
|
||||||
|
"pdfGenerationError": "PDF 生成失败",
|
||||||
|
"pdfReady": "PDF 已准备就绪",
|
||||||
|
"regeneratePdf": "重新生成 PDF",
|
||||||
"screenshot": "截图",
|
"screenshot": "截图",
|
||||||
"settings": "导出设置",
|
"settings": "导出设置",
|
||||||
"text": "文本",
|
"text": "文本",
|
||||||
|
|||||||
@@ -304,11 +304,24 @@
|
|||||||
"shareModal": {
|
"shareModal": {
|
||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
"download": "下載截圖",
|
"download": "下載截圖",
|
||||||
|
"downloadError": "下載失敗",
|
||||||
"downloadFile": "下載檔案",
|
"downloadFile": "下載檔案",
|
||||||
|
"downloadPdf": "下載 PDF",
|
||||||
|
"downloadSuccess": "下載成功",
|
||||||
|
"exportPdf": "匯出為 PDF",
|
||||||
"exportTitle": "預設標題",
|
"exportTitle": "預設標題",
|
||||||
|
"generatePdf": "生成 PDF",
|
||||||
|
"generatingPdf": "正在產生 PDF...",
|
||||||
"imageType": "圖片格式",
|
"imageType": "圖片格式",
|
||||||
"includeTool": "包含插件訊息",
|
"includeTool": "包含插件訊息",
|
||||||
"includeUser": "包含使用者訊息",
|
"includeUser": "包含使用者訊息",
|
||||||
|
"loadingPdf": "載入 PDF...",
|
||||||
|
"noPdfData": "暫無 PDF 資料",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"pdfErrorDescription": "產生 PDF 時發生錯誤,請重試",
|
||||||
|
"pdfGenerationError": "PDF 產生失敗",
|
||||||
|
"pdfReady": "PDF 已準備就緒",
|
||||||
|
"regeneratePdf": "重新生成 PDF",
|
||||||
"screenshot": "截圖",
|
"screenshot": "截圖",
|
||||||
"settings": "導出設置",
|
"settings": "導出設置",
|
||||||
"text": "文本",
|
"text": "文本",
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
redirects: async () => [
|
redirects: async () => [
|
||||||
{
|
{
|
||||||
destination: '/sitemap-index.xml',
|
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
|
// 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'],
|
transpilePackages: ['pdfjs-dist', 'mermaid'],
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|||||||
@@ -168,6 +168,7 @@
|
|||||||
"@lobehub/icons": "^2.42.0",
|
"@lobehub/icons": "^2.42.0",
|
||||||
"@lobehub/market-sdk": "^0.22.7",
|
"@lobehub/market-sdk": "^0.22.7",
|
||||||
"@lobehub/tts": "^2.0.1",
|
"@lobehub/tts": "^2.0.1",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@lobehub/ui": "^2.13.2",
|
"@lobehub/ui": "^2.13.2",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.0",
|
"@modelcontextprotocol/sdk": "^1.20.0",
|
||||||
"@neondatabase/serverless": "^1.0.2",
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
@@ -224,6 +225,7 @@
|
|||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"markdown-to-txt": "^2.0.1",
|
"markdown-to-txt": "^2.0.1",
|
||||||
|
"marked": "^16.3.0",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"model-bank": "workspace:*",
|
"model-bank": "workspace:*",
|
||||||
"modern-screenshot": "^4.6.6",
|
"modern-screenshot": "^4.6.6",
|
||||||
@@ -244,6 +246,7 @@
|
|||||||
"path-browserify-esm": "^1.0.6",
|
"path-browserify-esm": "^1.0.6",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pdfjs-dist": "4.8.69",
|
"pdfjs-dist": "4.8.69",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pino": "^9.13.1",
|
"pino": "^9.13.1",
|
||||||
"plaiceholder": "^3.0.0",
|
"plaiceholder": "^3.0.0",
|
||||||
@@ -320,9 +323,11 @@
|
|||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
"@types/node": "^22.18.9",
|
"@types/node": "^22.18.9",
|
||||||
"@types/numeral": "^2.0.5",
|
"@types/numeral": "^2.0.5",
|
||||||
"@types/oidc-provider": "^9.5.0",
|
"@types/oidc-provider": "^9.5.0",
|
||||||
|
"@types/pdfkit": "^0.17.3",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@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 { memo, useId, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Flexbox } from 'react-layout-kit';
|
import { Flexbox } from 'react-layout-kit';
|
||||||
|
|
||||||
|
import { isServerMode } from '@/const/version';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import { ChatMessage } from '@/types/message';
|
import { ChatMessage } from '@/types/message';
|
||||||
|
|
||||||
import ShareImage from './ShareImage';
|
import ShareImage from './ShareImage';
|
||||||
import ShareText from './ShareText';
|
import ShareText from './ShareText';
|
||||||
|
import SharePdf from '@/features/ShareModal/SharePdf';
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
|
PDF = 'pdf',
|
||||||
Screenshot = 'screenshot',
|
Screenshot = 'screenshot',
|
||||||
Text = 'text',
|
Text = 'text',
|
||||||
}
|
}
|
||||||
@@ -24,26 +27,39 @@ const ShareModal = memo<ShareModalProps>(({ onCancel, open, message }) => {
|
|||||||
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
|
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const uniqueId = useId();
|
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 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
allowFullscreen
|
allowFullscreen
|
||||||
centered={false}
|
centered={false}
|
||||||
|
destroyOnHidden={true}
|
||||||
footer={null}
|
footer={null}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -54,15 +70,24 @@ const ShareModal = memo<ShareModalProps>(({ onCancel, open, message }) => {
|
|||||||
<Segmented
|
<Segmented
|
||||||
block
|
block
|
||||||
onChange={(value) => setTab(value as Tab)}
|
onChange={(value) => setTab(value as Tab)}
|
||||||
options={options}
|
options={tabItems.map((item) => {
|
||||||
|
return {
|
||||||
|
label: item?.label,
|
||||||
|
value: item?.key,
|
||||||
|
};
|
||||||
|
})}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={tab}
|
value={tab}
|
||||||
variant={'filled'}
|
variant={'filled'}
|
||||||
/>
|
/>
|
||||||
{tab === Tab.Screenshot && (
|
<Tabs
|
||||||
<ShareImage message={message} mobile={isMobile} uniqueId={uniqueId} />
|
activeKey={tab}
|
||||||
)}
|
indicator={{ align: 'center', size: (origin) => origin - 20 }}
|
||||||
{tab === Tab.Text && <ShareText item={message} />}
|
items={tabItems}
|
||||||
|
onChange={(key) => setTab(key as Tab)}
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
renderTabBar={() => <></>}
|
||||||
|
/>
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
</Modal>
|
</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 { memo, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Flexbox } from 'react-layout-kit';
|
import { Flexbox } from 'react-layout-kit';
|
||||||
|
|
||||||
|
import { isServerMode } from '@/const/version';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
import ShareImage from './ShareImage';
|
import ShareImage from './ShareImage';
|
||||||
import ShareJSON from './ShareJSON';
|
import ShareJSON from './ShareJSON';
|
||||||
|
import SharePdf from './SharePdf';
|
||||||
import ShareText from './ShareText';
|
import ShareText from './ShareText';
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
JSON = 'json',
|
JSON = 'json',
|
||||||
|
PDF = 'pdf',
|
||||||
Screenshot = 'screenshot',
|
Screenshot = 'screenshot',
|
||||||
Text = 'text',
|
Text = 'text',
|
||||||
}
|
}
|
||||||
@@ -18,30 +21,43 @@ enum Tab {
|
|||||||
const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
|
const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
|
||||||
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
|
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
|
||||||
const { t } = useTranslation('chat');
|
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 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
allowFullscreen
|
allowFullscreen
|
||||||
centered={false}
|
centered={false}
|
||||||
|
destroyOnHidden={true}
|
||||||
footer={null}
|
footer={null}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -52,14 +68,24 @@ const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
|
|||||||
<Segmented
|
<Segmented
|
||||||
block
|
block
|
||||||
onChange={(value) => setTab(value as Tab)}
|
onChange={(value) => setTab(value as Tab)}
|
||||||
options={options}
|
options={tabItems.map((item) => {
|
||||||
|
return {
|
||||||
|
label: item?.label,
|
||||||
|
value: item?.key,
|
||||||
|
};
|
||||||
|
})}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={tab}
|
value={tab}
|
||||||
variant={'filled'}
|
variant={'filled'}
|
||||||
/>
|
/>
|
||||||
{tab === Tab.Screenshot && <ShareImage mobile={isMobile} />}
|
<Tabs
|
||||||
{tab === Tab.Text && <ShareText />}
|
activeKey={tab}
|
||||||
{tab === Tab.JSON && <ShareJSON />}
|
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>
|
</Flexbox>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ export const useContainerStyles = createStyles(({ css, token, stylish, cx, respo
|
|||||||
|
|
||||||
background: ${token.colorBgLayout};
|
background: ${token.colorBgLayout};
|
||||||
|
|
||||||
* {
|
/* stylelint-disable selector-class-pattern */
|
||||||
|
.react-pdf__Document *,
|
||||||
|
.react-pdf__Page * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* stylelint-enable selector-class-pattern */
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
${responsive.mobile} {
|
${responsive.mobile} {
|
||||||
max-height: 40dvh;
|
max-height: 40dvh;
|
||||||
|
|||||||
@@ -335,10 +335,23 @@ export default {
|
|||||||
copy: '复制',
|
copy: '复制',
|
||||||
download: '下载截图',
|
download: '下载截图',
|
||||||
downloadFile: '下载文件',
|
downloadFile: '下载文件',
|
||||||
|
downloadPdf: '下载 PDF',
|
||||||
|
downloadSuccess: '下载成功',
|
||||||
|
downloadError: '下载失败',
|
||||||
|
exportPdf: '导出为 PDF',
|
||||||
exportTitle: '默认标题',
|
exportTitle: '默认标题',
|
||||||
|
generatePdf: '生成 PDF',
|
||||||
|
generatingPdf: '正在生成 PDF...',
|
||||||
imageType: '图片格式',
|
imageType: '图片格式',
|
||||||
includeTool: '包含插件消息',
|
includeTool: '包含插件消息',
|
||||||
includeUser: '包含用户消息',
|
includeUser: '包含用户消息',
|
||||||
|
loadingPdf: '加载 PDF...',
|
||||||
|
noPdfData: '暂无 PDF 数据',
|
||||||
|
pdf: 'PDF',
|
||||||
|
pdfErrorDescription: '生成 PDF 时出现错误,请重试',
|
||||||
|
pdfGenerationError: 'PDF 生成失败',
|
||||||
|
pdfReady: 'PDF 已准备就绪',
|
||||||
|
regeneratePdf: '重新生成 PDF',
|
||||||
screenshot: '截图',
|
screenshot: '截图',
|
||||||
settings: '导出设置',
|
settings: '导出设置',
|
||||||
text: '文本',
|
text: '文本',
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { DrizzleMigrationModel } from '@/database/models/drizzleMigration';
|
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 { DataExporterRepos } from '@/database/repositories/dataExporter';
|
||||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||||
@@ -8,18 +14,182 @@ const exportProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
|||||||
const { ctx } = opts;
|
const { ctx } = opts;
|
||||||
const dataExporterRepos = new DataExporterRepos(ctx.serverDB, ctx.userId);
|
const dataExporterRepos = new DataExporterRepos(ctx.serverDB, ctx.userId);
|
||||||
const drizzleMigration = new DrizzleMigrationModel(ctx.serverDB);
|
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({
|
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({
|
export const exporterRouter = router({
|
||||||
exportData: exportProcedure.mutation(async ({ ctx }): Promise<ExportDatabaseData> => {
|
exportData: exportProcedure.mutation(async ({ ctx }): Promise<ExportDatabaseData> => {
|
||||||
const data = await ctx.dataExporterRepos.export(5);
|
const data = await ctx.dataExporterRepos.export(5);
|
||||||
|
|
||||||
const schemaHash = await ctx.drizzleMigration.getLatestMigrationHash();
|
const schemaHash = await ctx.drizzleMigration.getLatestMigrationHash();
|
||||||
|
|
||||||
return { data, schemaHash };
|
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