plugin-ai-chat-file-preview 1.0.11 → 1.0.14
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.
- package/README.md +44 -44
- package/client.d.ts +2 -2
- package/client.js +1 -1
- package/dist/client/ChatFilePreviewProvider.d.ts +44 -0
- package/dist/client/index.d.ts +13 -0
- package/dist/client/index.js +1 -1
- package/dist/client/locale.d.ts +11 -0
- package/dist/externalVersion.js +3 -4
- package/dist/index.d.ts +10 -0
- package/dist/locale/en-US.json +10 -10
- package/dist/locale/vi-VN.json +10 -10
- package/dist/locale/zh-CN.json +10 -10
- package/dist/server/index.d.ts +9 -0
- package/dist/server/plugin.d.ts +13 -0
- package/package.json +33 -30
- package/server.d.ts +2 -2
- package/server.js +1 -1
- package/src/client/ChatFilePreviewProvider.tsx +615 -418
- package/src/client/index.tsx +19 -19
- package/src/client/locale.ts +17 -17
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +10 -10
- package/src/locale/vi-VN.json +10 -10
- package/src/locale/zh-CN.json +10 -10
- package/src/server/index.ts +10 -10
- package/src/server/plugin.ts +18 -18
- package/dist/client/0ef9cbd6bccd7aff.js +0 -30
- package/dist/client/7e04271656f34237.js +0 -10
- package/src/client/PreviewModal.tsx +0 -642
- package/src/client/SessionBlobCache.ts +0 -123
|
@@ -1,418 +1,615 @@
|
|
|
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, useRef, useState } from 'react';
|
|
11
|
-
import { useAPIClient } from '@nocobase/client';
|
|
12
|
-
import { useChatMessagesStore } from '@nocobase/plugin-ai/client';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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 { useAPIClient, attachmentFileTypes } from '@nocobase/client';
|
|
12
|
+
import { useChatMessagesStore } from '@nocobase/plugin-ai/client';
|
|
13
|
+
import { Modal, Button } from 'antd';
|
|
14
|
+
|
|
15
|
+
export interface PreviewFile {
|
|
16
|
+
id?: string | number;
|
|
17
|
+
uid?: string;
|
|
18
|
+
url?: string;
|
|
19
|
+
filename?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
extname?: string;
|
|
23
|
+
mimetype?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
path?: string;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Inline Fallback Previewer ───────────────────────────────────────────
|
|
30
|
+
// Hand-crafted fallback so if plugin-file-preview-auth is disabled or unavaliable,
|
|
31
|
+
// the AI chat still gets a 90% wide modal rather than doing nothing.
|
|
32
|
+
|
|
33
|
+
function FallbackModalPreviewer({ index, list, onSwitchIndex }: any) {
|
|
34
|
+
const file = list?.[index];
|
|
35
|
+
|
|
36
|
+
if (!file) return null;
|
|
37
|
+
|
|
38
|
+
const url = typeof file === 'string' ? file : file?.url;
|
|
39
|
+
const resolvedUrl = url && (url.startsWith('https://') || url.startsWith('http://'))
|
|
40
|
+
? url
|
|
41
|
+
: `${window.location.origin}/${(url || '').replace(/^\//, '')}`;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Modal
|
|
45
|
+
open={index != null}
|
|
46
|
+
title={file?.title || file?.filename || file?.name || 'File Preview (Fallback)'}
|
|
47
|
+
onCancel={() => onSwitchIndex(null)}
|
|
48
|
+
footer={
|
|
49
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
50
|
+
<Button onClick={() => window.open(resolvedUrl, '_blank')}>Open Default</Button>
|
|
51
|
+
<Button onClick={() => onSwitchIndex(null)}>Close</Button>
|
|
52
|
+
</div>
|
|
53
|
+
}
|
|
54
|
+
width="90%"
|
|
55
|
+
centered
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
style={{
|
|
59
|
+
width: '100%',
|
|
60
|
+
height: '70vh',
|
|
61
|
+
background: 'white',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
flexDirection: 'column',
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<iframe
|
|
67
|
+
src={resolvedUrl}
|
|
68
|
+
style={{ width: '100%', height: '100%', border: 'none', flex: 1 }}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</Modal>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Define a reliable, context-isolated module-level RAM cache independent of the window object
|
|
76
|
+
export const AppRamCache = new Map<string, File | Blob>();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract displayed filename from a FileListCard DOM element.
|
|
80
|
+
*/
|
|
81
|
+
function getDisplayNameFromCard(cardEl: HTMLElement): string {
|
|
82
|
+
if (cardEl.tagName === 'A') return cardEl.textContent?.trim() || '';
|
|
83
|
+
|
|
84
|
+
// 1. Try antd generic filename classes
|
|
85
|
+
const nameEl = cardEl.querySelector('[class*="-name"]') as HTMLElement;
|
|
86
|
+
if (nameEl?.textContent) return nameEl.textContent.trim();
|
|
87
|
+
|
|
88
|
+
// 2. Try link elements
|
|
89
|
+
const aNodes = cardEl.querySelectorAll('a');
|
|
90
|
+
for (let i = 0; i < aNodes.length; i++) {
|
|
91
|
+
const text = aNodes[i].textContent?.trim();
|
|
92
|
+
if (text) return text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Try ellipsis splits
|
|
96
|
+
const prefixEl = cardEl.querySelector('[class*="ellipsis-prefix"]');
|
|
97
|
+
const suffixEl = cardEl.querySelector('[class*="ellipsis-suffix"]');
|
|
98
|
+
if (prefixEl && suffixEl) {
|
|
99
|
+
return (prefixEl.textContent || '') + (suffixEl.textContent || '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. Last resort (will likely contain file size text like 100KB)
|
|
103
|
+
return cardEl.textContent?.trim() || '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function attToPreviewFile(att: any): PreviewFile {
|
|
107
|
+
return {
|
|
108
|
+
id: att.id,
|
|
109
|
+
uid: att.uid,
|
|
110
|
+
url: att.url,
|
|
111
|
+
filename: att.filename || att.name,
|
|
112
|
+
name: att.name || att.filename,
|
|
113
|
+
title: att.title,
|
|
114
|
+
extname: att.extname,
|
|
115
|
+
mimetype: att.mimetype,
|
|
116
|
+
size: att.size,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find file metadata matching the displayed filename across all message attachments.
|
|
122
|
+
*/
|
|
123
|
+
function findFileByDisplayName(displayName: string, messages: any[], pendingAttachments: any[]): PreviewFile | null {
|
|
124
|
+
if (!displayName) return null;
|
|
125
|
+
|
|
126
|
+
// Search sent messages
|
|
127
|
+
for (const msg of messages) {
|
|
128
|
+
const content = msg.content || msg;
|
|
129
|
+
const attachments = content?.attachments;
|
|
130
|
+
if (!attachments?.length) continue;
|
|
131
|
+
|
|
132
|
+
for (const att of attachments) {
|
|
133
|
+
const attName = att.filename || att.name || '';
|
|
134
|
+
const attTitleExt = `${att.title || ''}${att.extname || ''}`;
|
|
135
|
+
if (attName === displayName || attTitleExt === displayName) {
|
|
136
|
+
return attToPreviewFile(att);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Relaxed match for NocoBase hashed file names
|
|
140
|
+
if (
|
|
141
|
+
(attName && displayName.includes(attName.replace(/\.[^/.]+$/, ''))) ||
|
|
142
|
+
(att.title && displayName.includes(att.title))
|
|
143
|
+
) {
|
|
144
|
+
return attToPreviewFile(att);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Search pending (not yet sent) attachments
|
|
150
|
+
for (const att of pendingAttachments || []) {
|
|
151
|
+
const attName = att.filename || att.name || '';
|
|
152
|
+
const attTitleExt = `${att.title || ''}${att.extname || ''}`;
|
|
153
|
+
if (attName === displayName || attTitleExt === displayName) {
|
|
154
|
+
return attToPreviewFile(att);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Relaxed match for NocoBase hashed file names (e.g. report.docx-c2ywti.docx)
|
|
158
|
+
if (
|
|
159
|
+
(attName && displayName.includes(attName.replace(/\.[^/.]+$/, ''))) ||
|
|
160
|
+
(att.title && displayName.includes(att.title))
|
|
161
|
+
) {
|
|
162
|
+
return attToPreviewFile(att);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find file metadata matching the extracted URL
|
|
171
|
+
*/
|
|
172
|
+
function findFileByUrl(url: string, messages: any[], pendingAttachments: any[]): PreviewFile | null {
|
|
173
|
+
if (!url) return null;
|
|
174
|
+
const matchUrl = (u1: string, u2: string) => {
|
|
175
|
+
if (!u1 || !u2) return false;
|
|
176
|
+
const clean1 = u1.split('?')[0].replace(location.origin, '').replace(/^\//, '');
|
|
177
|
+
const clean2 = u2.split('?')[0].replace(location.origin, '').replace(/^\//, '');
|
|
178
|
+
return clean1 === clean2;
|
|
179
|
+
};
|
|
180
|
+
for (const msg of messages) {
|
|
181
|
+
const content = msg.content || msg;
|
|
182
|
+
const attachments = content?.attachments;
|
|
183
|
+
if (!attachments?.length) continue;
|
|
184
|
+
for (const att of attachments) {
|
|
185
|
+
if (matchUrl(att.url, url) || matchUrl(att.preview, url)) return attToPreviewFile(att);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const att of pendingAttachments || []) {
|
|
189
|
+
if (matchUrl(att.url, url) || matchUrl(att.preview, url)) return attToPreviewFile(att);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Inner component that reads from plugin-ai's zustand stores via hooks.
|
|
196
|
+
* Uses refs to make latest state available inside the DOM click handler.
|
|
197
|
+
*/
|
|
198
|
+
const ChatFilePreviewInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
199
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
200
|
+
const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null);
|
|
201
|
+
const [sessionId, setSessionId] = useState('');
|
|
202
|
+
const apiClient = useAPIClient();
|
|
203
|
+
|
|
204
|
+
// Read zustand stores via hooks — these re-render on changes
|
|
205
|
+
const messages = useChatMessagesStore.use.messages();
|
|
206
|
+
const pendingAttachments = useChatMessagesStore.use.attachments();
|
|
207
|
+
|
|
208
|
+
// Keep latest values in refs for the click handler (avoids stale closures)
|
|
209
|
+
const messagesRef = useRef(messages);
|
|
210
|
+
const pendingAttachmentsRef = useRef(pendingAttachments);
|
|
211
|
+
messagesRef.current = messages;
|
|
212
|
+
pendingAttachmentsRef.current = pendingAttachments;
|
|
213
|
+
|
|
214
|
+
// We don't have direct access to useChatConversationsStore (not exported).
|
|
215
|
+
// Instead, we'll extract sessionId from the URL or from a data attribute on the DOM.
|
|
216
|
+
// A simpler approach: use a global ref that gets populated via the axios interceptor.
|
|
217
|
+
const currentSessionIdRef = useRef<string>('');
|
|
218
|
+
|
|
219
|
+
// Track the current sessionId by intercepting the getMessages API call
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const reqInterceptor = apiClient.axios.interceptors.request.use((config) => {
|
|
222
|
+
const url = config.url || '';
|
|
223
|
+
// When loadMessages is called, the sessionId appears in the URL
|
|
224
|
+
if (url.includes('aiConversations:getMessages')) {
|
|
225
|
+
const match = url.match(/sessionId=([^&]+)/);
|
|
226
|
+
if (match) {
|
|
227
|
+
currentSessionIdRef.current = decodeURIComponent(match[1]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// When sendMessages is called, sessionId is in the request body
|
|
231
|
+
if (url.includes('aiConversations:sendMessages') && config.data) {
|
|
232
|
+
try {
|
|
233
|
+
const data = typeof config.data === 'string' ? JSON.parse(config.data) : config.data;
|
|
234
|
+
if (data?.sessionId) {
|
|
235
|
+
currentSessionIdRef.current = data.sessionId;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return config;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
apiClient.axios.interceptors.request.eject(reqInterceptor);
|
|
246
|
+
};
|
|
247
|
+
}, [apiClient]);
|
|
248
|
+
|
|
249
|
+
// Track global drop and input change events to intercept file object selection ONLY for AI chat
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
// Modify href attributes of native NocoBase file links in chat to use the proxy
|
|
252
|
+
const rewriteObtrusiveLinks = () => {
|
|
253
|
+
const links = document.querySelectorAll<HTMLAnchorElement>('.ant-attachment-list-card-name');
|
|
254
|
+
links.forEach(link => {
|
|
255
|
+
const href = link.getAttribute('href');
|
|
256
|
+
if (href && !href.includes('/api/filePreviewAuth:download')) {
|
|
257
|
+
link.setAttribute('href', `/api/filePreviewAuth:download?url=${encodeURIComponent(href)}`);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Also rewrite ai-attachment-link anchors
|
|
261
|
+
const aiLinks = document.querySelectorAll<HTMLAnchorElement>('a.ai-attachment-link');
|
|
262
|
+
aiLinks.forEach(link => {
|
|
263
|
+
const href = link.getAttribute('href');
|
|
264
|
+
if (href && !href.includes('/api/filePreviewAuth:download')) {
|
|
265
|
+
link.setAttribute('href', `/api/filePreviewAuth:download?url=${encodeURIComponent(href)}`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Run periodically to catch newly rendered chat messages
|
|
271
|
+
const timer = setInterval(rewriteObtrusiveLinks, 1000);
|
|
272
|
+
return () => clearInterval(timer);
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const handleDrop = (e: DragEvent) => {
|
|
277
|
+
const target = e.target as HTMLElement;
|
|
278
|
+
if (!target || !target.closest) return;
|
|
279
|
+
if (!target.closest('.ant-x-sender') && !target.closest('.ant-x-attachments')) return;
|
|
280
|
+
|
|
281
|
+
if (e.dataTransfer?.files) {
|
|
282
|
+
Array.from(e.dataTransfer.files).forEach((f) => {
|
|
283
|
+
if (f.name) {
|
|
284
|
+
AppRamCache.set(f.name, f);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handleChange = (e: Event) => {
|
|
291
|
+
const target = e.target as HTMLInputElement;
|
|
292
|
+
if (!target || !target.closest) return;
|
|
293
|
+
if (!target.closest('.ant-x-sender') && !target.closest('.ant-x-attachments')) return;
|
|
294
|
+
|
|
295
|
+
if (target?.type === 'file' && target.files) {
|
|
296
|
+
Array.from(target.files).forEach((f) => {
|
|
297
|
+
if (f.name) {
|
|
298
|
+
AppRamCache.set(f.name, f);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
window.addEventListener('drop', handleDrop, true);
|
|
305
|
+
document.addEventListener('change', handleChange, true);
|
|
306
|
+
return () => {
|
|
307
|
+
window.removeEventListener('drop', handleDrop, true);
|
|
308
|
+
document.removeEventListener('change', handleChange, true);
|
|
309
|
+
};
|
|
310
|
+
}, []);
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
// Intercept antd upload origin files via Zustand state store as duplicate safety net
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (!pendingAttachmentsRef.current?.length) return;
|
|
317
|
+
pendingAttachmentsRef.current.forEach((att: any) => {
|
|
318
|
+
const fileObj = att.originFileObj || att;
|
|
319
|
+
if (fileObj && (fileObj instanceof Blob || fileObj instanceof File || 'size' in fileObj)) {
|
|
320
|
+
const name = att.name || att.filename || fileObj.name;
|
|
321
|
+
if (name) {
|
|
322
|
+
AppRamCache.set(name, fileObj);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}, [pendingAttachments]);
|
|
327
|
+
|
|
328
|
+
// Periodically check pure RAM cache and ONLY mark DOM cards that physically exist in local JS memory.
|
|
329
|
+
// This automatically rules out external database checks and prevents 403s on old un-cached files.
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
const checkInterval = setInterval(() => {
|
|
332
|
+
// Isolate strictly to AI module components! (AI Chat outputs Ant Design X attachments)
|
|
333
|
+
const aiContainers = document.querySelectorAll('.ant-x-sender, .ant-x-attachments, .ant-x-message');
|
|
334
|
+
|
|
335
|
+
const cards: Element[] = [];
|
|
336
|
+
aiContainers.forEach(container => {
|
|
337
|
+
container.querySelectorAll('div[class*="attachment-list-card"]:not([class*="attachment-list-card-"])').forEach(c => cards.push(c));
|
|
338
|
+
container.querySelectorAll('a').forEach(a => {
|
|
339
|
+
const href = (a as HTMLAnchorElement).href;
|
|
340
|
+
if (href && (href.includes('/api/attachments/') || href.includes('/api/files/download/') || href.includes('/api/worker-monitor/') || href.includes('/api/skillHub:download'))) {
|
|
341
|
+
cards.push(a);
|
|
342
|
+
if (!a.classList.contains('ai-attachment-link')) {
|
|
343
|
+
a.classList.add('ai-attachment-link');
|
|
344
|
+
a.classList.add('attachment-list-card'); // Trick click interceptor
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
cards.forEach(card => {
|
|
351
|
+
const el = card as HTMLElement;
|
|
352
|
+
const displayName = getDisplayNameFromCard(el);
|
|
353
|
+
let fallbackUrl = '';
|
|
354
|
+
if (el.tagName === 'A') {
|
|
355
|
+
fallbackUrl = (el as HTMLAnchorElement).href;
|
|
356
|
+
} else {
|
|
357
|
+
const urlNodes = el.querySelectorAll('a');
|
|
358
|
+
urlNodes.forEach((node) => {
|
|
359
|
+
if (node.href) fallbackUrl = node.href;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Resolve real file name from data store
|
|
364
|
+
const file = findFileByDisplayName(displayName, messagesRef.current, pendingAttachmentsRef.current) ||
|
|
365
|
+
findFileByUrl(fallbackUrl, messagesRef.current, pendingAttachmentsRef.current);
|
|
366
|
+
const realName = file?.filename || file?.name || file?.title;
|
|
367
|
+
|
|
368
|
+
// Strict RAM cache check
|
|
369
|
+
const cacheHitName = realName && AppRamCache.has(realName) ? realName
|
|
370
|
+
: (displayName && AppRamCache.has(displayName) ? displayName : null);
|
|
371
|
+
|
|
372
|
+
const isAIGenerated = fallbackUrl && (fallbackUrl.includes('/api/attachments/') || fallbackUrl.includes('/api/files/download/') || fallbackUrl.includes('/api/worker-monitor/') || fallbackUrl.includes('/api/skillHub:download'));
|
|
373
|
+
|
|
374
|
+
if (file || cacheHitName || isAIGenerated) {
|
|
375
|
+
el.classList.add('is-cached-previewable');
|
|
376
|
+
} else {
|
|
377
|
+
el.classList.remove('is-cached-previewable');
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}, 1000);
|
|
381
|
+
return () => clearInterval(checkInterval);
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
384
|
+
// Inject global CSS for explicit UI and native z-index click interception for child texts/tags
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
const style = document.createElement('style');
|
|
387
|
+
style.innerHTML = `
|
|
388
|
+
.ant-attachment-list-card {
|
|
389
|
+
position: relative !important;
|
|
390
|
+
cursor: pointer !important;
|
|
391
|
+
}
|
|
392
|
+
/* Prevent pointer events on inner text and icons so the outer div receives the absolute click */
|
|
393
|
+
.ant-attachment-list-card a,
|
|
394
|
+
.ant-attachment-list-card [class*="-icon"],
|
|
395
|
+
.ant-attachment-list-card span {
|
|
396
|
+
pointer-events: none !important;
|
|
397
|
+
}
|
|
398
|
+
/* Re-enable pointer events for the delete button specifically */
|
|
399
|
+
.ant-attachment-list-card [class*="-remove"],
|
|
400
|
+
.ant-attachment-list-card button,
|
|
401
|
+
.ant-attachment-list-card .ant-btn {
|
|
402
|
+
pointer-events: auto !important;
|
|
403
|
+
}
|
|
404
|
+
/* Visual "Preview" badge at the top-left corner using proper SVG */
|
|
405
|
+
.ant-attachment-list-card.is-cached-previewable::after {
|
|
406
|
+
content: '';
|
|
407
|
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='64 64 896 896' xmlns='http://www.w3.org/2000/svg' fill='rgba(0,0,0,0.65)'%3E%3Cpath d='M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z'/%3E%3C/svg%3E");
|
|
408
|
+
background-size: contain;
|
|
409
|
+
background-repeat: no-repeat;
|
|
410
|
+
position: absolute;
|
|
411
|
+
top: 6px;
|
|
412
|
+
left: 6px;
|
|
413
|
+
width: 14px;
|
|
414
|
+
height: 14px;
|
|
415
|
+
z-index: 10;
|
|
416
|
+
pointer-events: none;
|
|
417
|
+
}
|
|
418
|
+
/* Hide native antd thumbnail icon if we placed an eye so it doesnt look messy */
|
|
419
|
+
.ant-attachment-list-card.is-cached-previewable .ant-upload-list-item-thumbnail {
|
|
420
|
+
opacity: 0.2;
|
|
421
|
+
}
|
|
422
|
+
/* Custom aesthetics for raw AI-generated links to match cards */
|
|
423
|
+
a.ai-attachment-link {
|
|
424
|
+
display: inline-flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
padding: 8px 12px;
|
|
427
|
+
margin: 4px;
|
|
428
|
+
border: 1px solid #d9d9d9;
|
|
429
|
+
border-radius: 8px;
|
|
430
|
+
background: #fafafa;
|
|
431
|
+
color: rgba(0, 0, 0, 0.88);
|
|
432
|
+
text-decoration: none !important;
|
|
433
|
+
position: relative;
|
|
434
|
+
cursor: pointer !important;
|
|
435
|
+
transition: all 0.2s;
|
|
436
|
+
line-height: 1.5;
|
|
437
|
+
}
|
|
438
|
+
a.ai-attachment-link:hover {
|
|
439
|
+
background: #f0f0f0;
|
|
440
|
+
}
|
|
441
|
+
a.ai-attachment-link::before {
|
|
442
|
+
content: '📄 ';
|
|
443
|
+
margin-right: 8px;
|
|
444
|
+
font-size: 14px;
|
|
445
|
+
}
|
|
446
|
+
a.ai-attachment-link.is-cached-previewable::after {
|
|
447
|
+
content: '';
|
|
448
|
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='64 64 896 896' xmlns='http://www.w3.org/2000/svg' fill='rgba(0,0,0,0.65)'%3E%3Cpath d='M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z'/%3E%3C/svg%3E");
|
|
449
|
+
background-size: contain;
|
|
450
|
+
background-repeat: no-repeat;
|
|
451
|
+
position: absolute;
|
|
452
|
+
top: -6px;
|
|
453
|
+
left: -6px;
|
|
454
|
+
width: 14px;
|
|
455
|
+
height: 14px;
|
|
456
|
+
z-index: 10;
|
|
457
|
+
pointer-events: none;
|
|
458
|
+
}
|
|
459
|
+
`;
|
|
460
|
+
document.head.appendChild(style);
|
|
461
|
+
return () => {
|
|
462
|
+
style.remove();
|
|
463
|
+
};
|
|
464
|
+
}, []);
|
|
465
|
+
|
|
466
|
+
// Click interceptor: capture clicks on FileCard elements
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
const handler = (e: MouseEvent) => {
|
|
469
|
+
const el = e.target as Element;
|
|
470
|
+
if (!el || typeof el.closest !== 'function') return;
|
|
471
|
+
|
|
472
|
+
// Find closest Anchor (A), FileCard, or Ant Tag (from chat input attachments)
|
|
473
|
+
let fallbackUrl = '';
|
|
474
|
+
let displayName = '';
|
|
475
|
+
|
|
476
|
+
const anchorNode = el.closest('a');
|
|
477
|
+
const cardEl = el.closest('.ant-attachment-list-card') as HTMLElement;
|
|
478
|
+
const antTagBtn = el.closest('.ant-tag');
|
|
479
|
+
|
|
480
|
+
if (!anchorNode && !cardEl && !antTagBtn) return;
|
|
481
|
+
|
|
482
|
+
// Skip remove button clicks
|
|
483
|
+
if (el.closest('[class*="-remove"]') || el.closest('.ant-tag-close-icon') || el.closest('.ant-btn')) return;
|
|
484
|
+
|
|
485
|
+
if (cardEl) {
|
|
486
|
+
displayName = getDisplayNameFromCard(cardEl);
|
|
487
|
+
if (cardEl.tagName === 'A') {
|
|
488
|
+
fallbackUrl = (cardEl as HTMLAnchorElement).href;
|
|
489
|
+
} else {
|
|
490
|
+
const urlNodes = cardEl.querySelectorAll('a');
|
|
491
|
+
urlNodes.forEach((node) => {
|
|
492
|
+
if (node.href) fallbackUrl = node.href;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
} else if (anchorNode) {
|
|
496
|
+
fallbackUrl = anchorNode.href;
|
|
497
|
+
displayName = anchorNode.textContent || 'download';
|
|
498
|
+
} else if (antTagBtn) {
|
|
499
|
+
displayName = antTagBtn.textContent?.trim() || '';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Decode original url if it's already a proxied url
|
|
503
|
+
let originalFallbackUrl = fallbackUrl;
|
|
504
|
+
if (fallbackUrl && fallbackUrl.includes('/api/filePreviewAuth:download?url=')) {
|
|
505
|
+
try {
|
|
506
|
+
const urlObj = new URL(fallbackUrl, window.location.origin);
|
|
507
|
+
originalFallbackUrl = decodeURIComponent(urlObj.searchParams.get('url') || fallbackUrl);
|
|
508
|
+
} catch {
|
|
509
|
+
// ignore
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let file = findFileByDisplayName(displayName, messagesRef.current, pendingAttachmentsRef.current) ||
|
|
514
|
+
findFileByUrl(originalFallbackUrl, messagesRef.current, pendingAttachmentsRef.current);
|
|
515
|
+
|
|
516
|
+
const isAIGenerated = originalFallbackUrl && (
|
|
517
|
+
originalFallbackUrl.includes('/api/attachments/') ||
|
|
518
|
+
originalFallbackUrl.includes('/api/files/download/') ||
|
|
519
|
+
originalFallbackUrl.includes('/api/worker-monitor/') ||
|
|
520
|
+
originalFallbackUrl.includes('/api/skillHub:download') ||
|
|
521
|
+
originalFallbackUrl.includes('/storage/uploads/') ||
|
|
522
|
+
originalFallbackUrl.startsWith('http') // External S3 urls
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (!file && isAIGenerated) {
|
|
526
|
+
const extname = originalFallbackUrl.match(/\.([a-z0-9]+)(?:[\?#]|$)/i)?.[1];
|
|
527
|
+
file = {
|
|
528
|
+
id: originalFallbackUrl,
|
|
529
|
+
uid: originalFallbackUrl,
|
|
530
|
+
url: originalFallbackUrl,
|
|
531
|
+
filename: displayName || 'attachment',
|
|
532
|
+
name: displayName || 'attachment',
|
|
533
|
+
extname: extname ? `.${extname}` : undefined,
|
|
534
|
+
mimetype: '',
|
|
535
|
+
} as PreviewFile;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!file && !isAIGenerated) return;
|
|
539
|
+
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
e.stopPropagation();
|
|
542
|
+
|
|
543
|
+
// Convert to secure proxy URL for everything
|
|
544
|
+
const secureUrl = originalFallbackUrl ? `/api/filePreviewAuth:download?url=${encodeURIComponent(originalFallbackUrl)}` : file.url;
|
|
545
|
+
file = { ...file, url: secureUrl };
|
|
546
|
+
|
|
547
|
+
setSessionId(currentSessionIdRef.current || '');
|
|
548
|
+
setPreviewFile(file);
|
|
549
|
+
setPreviewOpen(true);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
document.addEventListener('click', handler, { capture: true });
|
|
553
|
+
return () => document.removeEventListener('click', handler, { capture: true });
|
|
554
|
+
}, []);
|
|
555
|
+
|
|
556
|
+
const handleClose = useCallback(() => {
|
|
557
|
+
setPreviewOpen(false);
|
|
558
|
+
setPreviewFile(null);
|
|
559
|
+
}, []);
|
|
560
|
+
|
|
561
|
+
const SystemPreviewer = useMemo(() => {
|
|
562
|
+
if (!previewFile || !previewOpen) return null;
|
|
563
|
+
const type = attachmentFileTypes.getTypeByFile(previewFile);
|
|
564
|
+
return type?.Previewer || FallbackModalPreviewer;
|
|
565
|
+
}, [previewFile, previewOpen]);
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<>
|
|
569
|
+
{children}
|
|
570
|
+
{SystemPreviewer && previewOpen && previewFile && (
|
|
571
|
+
<SystemPreviewer
|
|
572
|
+
index={0}
|
|
573
|
+
list={[previewFile as any]}
|
|
574
|
+
onSwitchIndex={(idx: any) => {
|
|
575
|
+
if (idx === null) handleClose();
|
|
576
|
+
}}
|
|
577
|
+
/>
|
|
578
|
+
)}
|
|
579
|
+
</>
|
|
580
|
+
);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Top-level provider that wraps the app.
|
|
585
|
+
* Wrapped in try/catch ErrorBoundary so that if plugin-ai isn't loaded,
|
|
586
|
+
* the app still works normally without preview functionality.
|
|
587
|
+
*/
|
|
588
|
+
export class ChatFilePreviewErrorBoundary extends React.Component<
|
|
589
|
+
{ children: React.ReactNode },
|
|
590
|
+
{ hasError: boolean }
|
|
591
|
+
> {
|
|
592
|
+
constructor(props: { children: React.ReactNode }) {
|
|
593
|
+
super(props);
|
|
594
|
+
this.state = { hasError: false };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
static getDerivedStateFromError() {
|
|
598
|
+
return { hasError: true };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
render() {
|
|
602
|
+
if (this.state.hasError) {
|
|
603
|
+
return this.props.children;
|
|
604
|
+
}
|
|
605
|
+
return this.props.children;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export const ChatFilePreviewProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
610
|
+
return (
|
|
611
|
+
<ChatFilePreviewErrorBoundary>
|
|
612
|
+
<ChatFilePreviewInner>{children}</ChatFilePreviewInner>
|
|
613
|
+
</ChatFilePreviewErrorBoundary>
|
|
614
|
+
);
|
|
615
|
+
};
|