plugin-ai-chat-file-preview 1.0.11 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,642 +0,0 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
- import { Modal, Button, Spin, Alert, message } from 'antd';
12
- import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
13
- import { useAPIClient } from '@nocobase/client';
14
- import { SessionBlobCache } from './SessionBlobCache';
15
- import { AppRamCache } from './ChatFilePreviewProvider';
16
- import { useTranslation } from './locale';
17
-
18
- // ─── Supported MIME / extension lists ──────────────────────────────
19
-
20
- const PDF_MIME = ['application/pdf'];
21
- const IMAGE_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp'];
22
- const TEXT_MIME = [
23
- 'text/plain',
24
- 'text/csv',
25
- 'text/html',
26
- 'text/css',
27
- 'text/javascript',
28
- 'application/json',
29
- 'application/xml',
30
- 'text/xml',
31
- 'text/yaml',
32
- 'application/x-yaml',
33
- ];
34
- const DOCX_MIME = ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
35
- const XLSX_MIME = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
36
-
37
- const PDF_EXT = ['pdf'];
38
- const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
39
- const TEXT_EXT = ['txt', 'csv', 'html', 'css', 'js', 'json', 'xml', 'log', 'md', 'yaml', 'yml', 'xaml'];
40
- const DOCX_EXT = ['docx'];
41
- const XLSX_EXT = ['xlsx', 'xls'];
42
-
43
- // ─── Utilities ─────────────────────────────────────────────────────
44
-
45
- export function getFileExt(file: any): string {
46
- const value = typeof file === 'string' ? file : file?.extname || file?.name || file?.filename || file?.url || '';
47
- const clean = value.split('?')[0].split('#')[0];
48
- const idx = clean.lastIndexOf('.');
49
- return idx !== -1
50
- ? clean
51
- .slice(idx + 1)
52
- .toLowerCase()
53
- .replace(/^\./, '')
54
- : '';
55
- }
56
-
57
- export function resolveFileUrl(file: any): string {
58
- const url = typeof file === 'string' ? file : file?.url;
59
- if (!url) return '';
60
- return url.startsWith('http://') || url.startsWith('https://') ? url : `${location.origin}/${url.replace(/^\//, '')}`;
61
- }
62
-
63
- function matchType(file: any, mimes: string[], exts: string[]): boolean {
64
- if (file?.mimetype && mimes.includes(file.mimetype)) return true;
65
- const ext = getFileExt(file);
66
- return !!ext && exts.includes(ext);
67
- }
68
-
69
- export const isPdfFile = (f: any) => matchType(f, PDF_MIME, PDF_EXT);
70
- export const isImageFile = (f: any) => matchType(f, IMAGE_MIME, IMAGE_EXT);
71
- export const isTextFile = (f: any) => matchType(f, TEXT_MIME, TEXT_EXT);
72
- export const isDocxFile = (f: any) => matchType(f, DOCX_MIME, DOCX_EXT);
73
- export const isXlsxFile = (f: any) => matchType(f, XLSX_MIME, XLSX_EXT);
74
- export const isPreviewableFile = (f: any) => isPdfFile(f) || isImageFile(f) || isTextFile(f) || isDocxFile(f) || isXlsxFile(f);
75
-
76
- export function isPreviewableUrl(url: string): boolean {
77
- return isPreviewableFile({ url });
78
- }
79
-
80
- async function fetchFileAsBlob(apiClient: any, url: string): Promise<Blob> {
81
- const res = await apiClient.axios.get(url, { responseType: 'blob' });
82
- return res.data;
83
- }
84
-
85
- async function resolveFileBlob(file: any, apiClient: any, sessionId: string): Promise<Blob> {
86
- const fileUrl = resolveFileUrl(file);
87
- const fileId = String(file?.id || file?.uid || fileUrl);
88
- const name = file?.filename || file?.name || file?.title;
89
-
90
- if (name && AppRamCache.has(name)) {
91
- return AppRamCache.get(name) as Blob;
92
- }
93
-
94
- // Also try display name or fallback
95
- if (file?.title && AppRamCache.has(file.title)) {
96
- return AppRamCache.get(file.title) as Blob;
97
- }
98
-
99
- if (sessionId && fileId) {
100
- const sessionBlob = await SessionBlobCache.get(sessionId, fileId).catch(() => null);
101
- if (sessionBlob) return sessionBlob;
102
- }
103
-
104
- if (!fileUrl) throw new Error('No valid URL');
105
- const serverBlob = await fetchFileAsBlob(apiClient, fileUrl);
106
-
107
- if (sessionId && fileId) {
108
- SessionBlobCache.put(sessionId, fileId, serverBlob).catch(() => {});
109
- }
110
- return serverBlob;
111
- }
112
-
113
- // ─── Download helper ───────────────────────────────────────────────
114
-
115
- async function downloadFileWithAuth(file: any, apiClient: any) {
116
- const blob = await resolveFileBlob(file, apiClient, '');
117
- const name = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'download';
118
- const a = document.createElement('a');
119
- const objectUrl = URL.createObjectURL(blob);
120
- a.href = objectUrl;
121
- a.download = name;
122
- document.body.appendChild(a);
123
- a.click();
124
- document.body.removeChild(a);
125
- setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
126
- }
127
-
128
- // ─── Shared components ─────────────────────────────────────────────
129
-
130
- function LoadingIndicator({ msg }: { msg: string }) {
131
- return (
132
- <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', width: '100%' }}>
133
- <Spin tip={msg} />
134
- </div>
135
- );
136
- }
137
-
138
- function ErrorMsg({ msg }: { msg: string }) {
139
- return <div style={{ padding: 20, textAlign: 'center', color: '#ff4d4f' }}>{msg}</div>;
140
- }
141
-
142
- // ─── Hook: load blob with session cache ────────────────────────────
143
-
144
- function useCachedBlobUrl(file: any, apiClient: any, sessionId: string) {
145
- const [blobUrl, setBlobUrl] = useState<string | null>(null);
146
- const [loading, setLoading] = useState(true);
147
- const [error, setError] = useState<string | null>(null);
148
- const blobUrlRef = useRef<string | null>(null);
149
-
150
- const fileId = file?.id || file?.uid || '';
151
- const fileUrl = resolveFileUrl(file);
152
-
153
- useEffect(() => {
154
- let cancelled = false;
155
- if (!fileUrl) {
156
- setLoading(false);
157
- setError('No file URL');
158
- return;
159
- }
160
-
161
- setLoading(true);
162
- setError(null);
163
-
164
- (async () => {
165
- try {
166
- // Check IndexedDB cache first
167
- if (sessionId && fileId) {
168
- const cached = await SessionBlobCache.get(sessionId, String(fileId));
169
- if (cached && !cancelled) {
170
- const url = URL.createObjectURL(cached);
171
- blobUrlRef.current = url;
172
- setBlobUrl(url);
173
- setLoading(false);
174
- return;
175
- }
176
- }
177
-
178
- // Fetch from server
179
- const blob = await fetchFileAsBlob(apiClient, fileUrl);
180
- if (cancelled) return;
181
-
182
- // Cache to IndexedDB
183
- if (sessionId && fileId) {
184
- SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
185
- }
186
-
187
- const url = URL.createObjectURL(blob);
188
- blobUrlRef.current = url;
189
- setBlobUrl(url);
190
- setLoading(false);
191
- } catch (err: any) {
192
- if (cancelled) return;
193
- setError(err.message || 'Failed to load');
194
- setLoading(false);
195
- }
196
- })();
197
-
198
- return () => {
199
- cancelled = true;
200
- if (blobUrlRef.current) {
201
- URL.revokeObjectURL(blobUrlRef.current);
202
- blobUrlRef.current = null;
203
- }
204
- };
205
- }, [fileUrl, apiClient, sessionId, fileId]);
206
-
207
- return { blobUrl, loading, error };
208
- }
209
-
210
- function useCachedTextContent(file: any, apiClient: any, sessionId: string) {
211
- const [text, setText] = useState<string | null>(null);
212
- const [loading, setLoading] = useState(true);
213
- const [error, setError] = useState<string | null>(null);
214
-
215
- const fileId = file?.id || file?.uid || '';
216
- const fileUrl = resolveFileUrl(file);
217
-
218
- useEffect(() => {
219
- let cancelled = false;
220
- if (!fileUrl) {
221
- setLoading(false);
222
- setError('No file URL');
223
- return;
224
- }
225
-
226
- setLoading(true);
227
- setError(null);
228
-
229
- (async () => {
230
- try {
231
- // Check cache
232
- if (sessionId && fileId) {
233
- const cached = await SessionBlobCache.get(sessionId, String(fileId));
234
- if (cached && !cancelled) {
235
- const content = await cached.text();
236
- setText(content);
237
- setLoading(false);
238
- return;
239
- }
240
- }
241
-
242
- const content = await fetchFileAsText(apiClient, fileUrl);
243
- if (cancelled) return;
244
-
245
- // Cache as blob
246
- if (sessionId && fileId) {
247
- const blob = new Blob([content], { type: 'text/plain' });
248
- SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
249
- }
250
-
251
- setText(content);
252
- setLoading(false);
253
- } catch (err: any) {
254
- if (cancelled) return;
255
- setError(err.message || 'Failed to load');
256
- setLoading(false);
257
- }
258
- })();
259
-
260
- return () => {
261
- cancelled = true;
262
- };
263
- }, [fileUrl, apiClient, sessionId, fileId]);
264
-
265
- return { text, loading, error };
266
- }
267
-
268
- // ─── Inline Previewers ─────────────────────────────────────────────
269
-
270
- function PdfPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
271
- const apiClient = useAPIClient();
272
- const { t } = useTranslation();
273
- const { blobUrl, loading, error } = useCachedBlobUrl(file, apiClient, sessionId);
274
-
275
- if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
276
- if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
277
- return <iframe src={blobUrl} width="100%" height="100%" style={{ border: 'none' }} />;
278
- }
279
-
280
- function ImagePreviewer({ file, sessionId }: { file: any; sessionId: string }) {
281
- const apiClient = useAPIClient();
282
- const { t } = useTranslation();
283
- const { blobUrl, loading, error } = useCachedBlobUrl(file, apiClient, sessionId);
284
-
285
- if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
286
- if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
287
- return <img src={blobUrl} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} alt={file?.filename || ''} />;
288
- }
289
-
290
- function TextPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
291
- const apiClient = useAPIClient();
292
- const { t } = useTranslation();
293
- const { text, loading, error } = useCachedTextContent(file, apiClient, sessionId);
294
-
295
- if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
296
- if (error || text === null) return <ErrorMsg msg={t('Failed to load file preview')} />;
297
- return (
298
- <pre
299
- style={{
300
- width: '100%',
301
- height: '100%',
302
- overflow: 'auto',
303
- padding: 16,
304
- margin: 0,
305
- fontSize: 13,
306
- lineHeight: 1.6,
307
- whiteSpace: 'pre-wrap',
308
- wordWrap: 'break-word',
309
- background: '#f5f5f5',
310
- border: 'none',
311
- }}
312
- >
313
- {text}
314
- </pre>
315
- );
316
- }
317
-
318
- function DocxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
319
- const apiClient = useAPIClient();
320
- const { t } = useTranslation();
321
- const containerRef = useRef<HTMLDivElement>(null);
322
- const [loading, setLoading] = useState(true);
323
- const [error, setError] = useState<string | null>(null);
324
- const fileUrl = resolveFileUrl(file);
325
- const fileId = file?.id || file?.uid || '';
326
-
327
- useEffect(() => {
328
- let cancelled = false;
329
- if (!fileUrl || !containerRef.current) {
330
- setLoading(false);
331
- setError('No file URL');
332
- return;
333
- }
334
-
335
- setLoading(true);
336
- setError(null);
337
-
338
- (async () => {
339
- try {
340
- let blob: Blob;
341
-
342
- if (sessionId && fileId) {
343
- const cached = await SessionBlobCache.get(sessionId, String(fileId));
344
- if (cached && !cancelled) {
345
- blob = cached;
346
- } else {
347
- blob = await fetchFileAsBlob(apiClient, fileUrl);
348
- if (cancelled) return;
349
- SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
350
- }
351
- } else {
352
- blob = await fetchFileAsBlob(apiClient, fileUrl);
353
- }
354
-
355
- if (cancelled || !containerRef.current) return;
356
- const docxPreview = await import('docx-preview');
357
- if (cancelled || !containerRef.current) return;
358
- containerRef.current.innerHTML = '';
359
- await docxPreview.renderAsync(blob, containerRef.current, undefined, {
360
- className: 'docx-preview-wrapper',
361
- inWrapper: true,
362
- ignoreWidth: false,
363
- ignoreHeight: false,
364
- ignoreFonts: false,
365
- breakPages: true,
366
- ignoreLastRenderedPageBreak: true,
367
- experimental: false,
368
- trimXmlDeclaration: true,
369
- useBase64URL: true,
370
- renderHeaders: true,
371
- renderFooters: true,
372
- renderFootnotes: true,
373
- renderEndnotes: true,
374
- });
375
- setLoading(false);
376
- } catch (err: any) {
377
- if (cancelled) return;
378
- setError(err.message || 'Failed to render DOCX');
379
- setLoading(false);
380
- }
381
- })();
382
-
383
- return () => {
384
- cancelled = true;
385
- };
386
- }, [fileUrl, apiClient, sessionId, fileId]);
387
-
388
- return (
389
- <div style={{ width: '100%', height: '100%', position: 'relative' }}>
390
- {loading && <LoadingIndicator msg={t('Loading preview...')} />}
391
- {error && <ErrorMsg msg={t('Failed to load file preview')} />}
392
- <div
393
- ref={containerRef}
394
- style={{
395
- width: '100%',
396
- height: '100%',
397
- overflow: 'auto',
398
- display: loading || error ? 'none' : 'block',
399
- }}
400
- />
401
- </div>
402
- );
403
- }
404
-
405
- function XlsxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
406
- const apiClient = useAPIClient();
407
- const { t } = useTranslation();
408
- const [loading, setLoading] = useState(true);
409
- const [error, setError] = useState<string | null>(null);
410
- const [sheetNames, setSheetNames] = useState<string[]>([]);
411
- const [activeSheet, setActiveSheet] = useState('');
412
- const [sheetsHtml, setSheetsHtml] = useState<Record<string, string>>({});
413
- const fileUrl = resolveFileUrl(file);
414
- const fileId = file?.id || file?.uid || '';
415
-
416
- useEffect(() => {
417
- let cancelled = false;
418
- if (!fileUrl) {
419
- setLoading(false);
420
- setError('No file URL');
421
- return;
422
- }
423
-
424
- setLoading(true);
425
- setError(null);
426
-
427
- (async () => {
428
- try {
429
- let blob: Blob;
430
-
431
- if (sessionId && fileId) {
432
- const cached = await SessionBlobCache.get(sessionId, String(fileId));
433
- if (cached && !cancelled) {
434
- blob = cached;
435
- } else {
436
- blob = await fetchFileAsBlob(apiClient, fileUrl);
437
- if (cancelled) return;
438
- SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
439
- }
440
- } else {
441
- blob = await fetchFileAsBlob(apiClient, fileUrl);
442
- }
443
-
444
- if (cancelled) return;
445
- const XLSX = await import('xlsx');
446
- if (cancelled) return;
447
- const arrayBuffer = await blob.arrayBuffer();
448
- if (cancelled) return;
449
- const workbook = XLSX.read(arrayBuffer, { type: 'array' });
450
- const names = workbook.SheetNames as string[];
451
- const htmlMap: Record<string, string> = {};
452
- for (const name of names) {
453
- htmlMap[name] = XLSX.utils.sheet_to_html(workbook.Sheets[name], { id: 'xlsx-preview-table' });
454
- }
455
- if (cancelled) return;
456
- setSheetNames(names);
457
- setActiveSheet(names[0] || '');
458
- setSheetsHtml(htmlMap);
459
- setLoading(false);
460
- } catch (err: any) {
461
- if (cancelled) return;
462
- setError(err.message || 'Failed to render XLSX');
463
- setLoading(false);
464
- }
465
- })();
466
-
467
- return () => {
468
- cancelled = true;
469
- };
470
- }, [fileUrl, apiClient, sessionId, fileId]);
471
-
472
- return (
473
- <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
474
- {loading && <LoadingIndicator msg={t('Loading preview...')} />}
475
- {error && <ErrorMsg msg={t('Failed to load file preview')} />}
476
- {!loading && !error && (
477
- <>
478
- {sheetNames.length > 1 && (
479
- <div
480
- style={{
481
- display: 'flex',
482
- gap: 0,
483
- borderBottom: '1px solid #e8e8e8',
484
- background: '#fafafa',
485
- padding: '0 8px',
486
- flexShrink: 0,
487
- overflowX: 'auto',
488
- }}
489
- >
490
- {sheetNames.map((name) => (
491
- <button
492
- key={name}
493
- onClick={() => setActiveSheet(name)}
494
- style={{
495
- padding: '8px 16px',
496
- border: 'none',
497
- borderBottom: activeSheet === name ? '2px solid #1890ff' : '2px solid transparent',
498
- background: activeSheet === name ? '#fff' : 'transparent',
499
- color: activeSheet === name ? '#1890ff' : '#666',
500
- fontWeight: activeSheet === name ? 600 : 400,
501
- cursor: 'pointer',
502
- fontSize: 13,
503
- whiteSpace: 'nowrap',
504
- transition: 'all 0.2s',
505
- }}
506
- >
507
- {name}
508
- </button>
509
- ))}
510
- </div>
511
- )}
512
- <div
513
- style={{ flex: 1, overflow: 'auto', padding: 0 }}
514
- dangerouslySetInnerHTML={{ __html: sheetsHtml[activeSheet] || '' }}
515
- />
516
- <style>{`
517
- #xlsx-preview-table { border-collapse: collapse; width: 100%; font-size: 13px; }
518
- #xlsx-preview-table td, #xlsx-preview-table th {
519
- border: 1px solid #e8e8e8; padding: 6px 10px; text-align: left;
520
- max-width: 33vw; white-space: normal; word-break: break-word;
521
- }
522
- #xlsx-preview-table tr:first-child td, #xlsx-preview-table tr:first-child th {
523
- background: #fafafa; font-weight: 600; position: sticky; top: 0; z-index: 1;
524
- }
525
- #xlsx-preview-table tr:nth-child(even) { background: #fafafa; }
526
- #xlsx-preview-table tr:hover { background: #f0f7ff; }
527
- `}</style>
528
- </>
529
- )}
530
- </div>
531
- );
532
- }
533
-
534
- // ─── Main Preview Modal ────────────────────────────────────────────
535
-
536
- export interface PreviewFile {
537
- id?: string | number;
538
- uid?: string;
539
- url?: string;
540
- filename?: string;
541
- name?: string;
542
- title?: string;
543
- extname?: string;
544
- mimetype?: string;
545
- size?: number;
546
- }
547
-
548
- interface PreviewModalProps {
549
- open: boolean;
550
- file: PreviewFile | null;
551
- sessionId: string;
552
- onClose: () => void;
553
- }
554
-
555
- export const PreviewModal: React.FC<PreviewModalProps> = ({ open, file, sessionId, onClose }) => {
556
- const apiClient = useAPIClient();
557
- const { t } = useTranslation();
558
- const [downloading, setDownloading] = useState(false);
559
-
560
- const fileId = file?.id || file?.uid || '';
561
-
562
- const onDownload = useCallback(async () => {
563
- if (!file) return;
564
- setDownloading(true);
565
- try {
566
- await downloadFileWithAuth(file, apiClient);
567
- } catch {
568
- message.error(t('Failed to download file'));
569
- } finally {
570
- setDownloading(false);
571
- }
572
- }, [file, apiClient, t]);
573
-
574
- const onClearCache = useCallback(async () => {
575
- if (!sessionId || !fileId) return;
576
- await SessionBlobCache.delete(sessionId, String(fileId));
577
- message.success(t('Cache cleared'));
578
- }, [sessionId, fileId, t]);
579
-
580
- const PreviewerComponent = useMemo(() => {
581
- if (!file) return null;
582
- if (isPdfFile(file)) return PdfPreviewer;
583
- if (isImageFile(file)) return ImagePreviewer;
584
- if (isTextFile(file)) return TextPreviewer;
585
- if (isDocxFile(file)) return DocxPreviewer;
586
- if (isXlsxFile(file)) return XlsxPreviewer;
587
- return null;
588
- }, [file]);
589
-
590
- const canPreview = PreviewerComponent != null;
591
- const title = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'File';
592
-
593
- return (
594
- <Modal
595
- open={open}
596
- title={title}
597
- onCancel={onClose}
598
- destroyOnClose
599
- footer={[
600
- sessionId && fileId ? (
601
- <Button key="clear-cache" icon={<DeleteOutlined />} onClick={onClearCache}>
602
- {t('Clear cache')}
603
- </Button>
604
- ) : null,
605
- <Button key="download" icon={<DownloadOutlined />} onClick={onDownload} loading={downloading}>
606
- {t('Download')}
607
- </Button>,
608
- <Button key="close" onClick={onClose}>
609
- {t('Close')}
610
- </Button>,
611
- ].filter(Boolean)}
612
- width={canPreview ? '60vw' : 520}
613
- centered
614
- >
615
- <div
616
- style={{
617
- maxWidth: '100%',
618
- maxHeight: canPreview ? 'calc(100vh - 256px)' : 'auto',
619
- height: canPreview ? '70vh' : 'auto',
620
- width: '100%',
621
- background: 'white',
622
- display: 'flex',
623
- flexDirection: 'column',
624
- justifyContent: 'center',
625
- alignItems: 'center',
626
- overflowY: 'auto',
627
- }}
628
- >
629
- {canPreview && file ? (
630
- <PreviewerComponent file={file} sessionId={sessionId} />
631
- ) : (
632
- <Alert
633
- type="info"
634
- style={{ width: '100%' }}
635
- description={t('This file type cannot be previewed. Click Download to save the file.')}
636
- showIcon
637
- />
638
- )}
639
- </div>
640
- </Modal>
641
- );
642
- };