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.
@@ -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 { PreviewModal, PreviewFile, isPreviewableFile } from './PreviewModal';
14
- import { SessionBlobCache } from './SessionBlobCache';
15
-
16
- // Define a reliable, context-isolated module-level RAM cache independent of the window object
17
- export const AppRamCache = new Map<string, File | Blob>();
18
-
19
- /**
20
- * Extract displayed filename from a FileListCard DOM element.
21
- */
22
- function getDisplayNameFromCard(cardEl: HTMLElement): string {
23
- // 1. Try antd generic filename classes
24
- const nameEl = cardEl.querySelector('[class*="-name"]') as HTMLElement;
25
- if (nameEl?.textContent) return nameEl.textContent.trim();
26
-
27
- // 2. Try link elements
28
- const aNodes = cardEl.querySelectorAll('a');
29
- for (let i = 0; i < aNodes.length; i++) {
30
- const text = aNodes[i].textContent?.trim();
31
- if (text) return text;
32
- }
33
-
34
- // 3. Try ellipsis splits
35
- const prefixEl = cardEl.querySelector('[class*="ellipsis-prefix"]');
36
- const suffixEl = cardEl.querySelector('[class*="ellipsis-suffix"]');
37
- if (prefixEl && suffixEl) {
38
- return (prefixEl.textContent || '') + (suffixEl.textContent || '');
39
- }
40
-
41
- // 4. Last resort (will likely contain file size text like 100KB)
42
- return cardEl.textContent?.trim() || '';
43
- }
44
-
45
- function attToPreviewFile(att: any): PreviewFile {
46
- return {
47
- id: att.id,
48
- uid: att.uid,
49
- url: att.url,
50
- filename: att.filename || att.name,
51
- name: att.name || att.filename,
52
- title: att.title,
53
- extname: att.extname,
54
- mimetype: att.mimetype,
55
- size: att.size,
56
- };
57
- }
58
-
59
- /**
60
- * Find file metadata matching the displayed filename across all message attachments.
61
- */
62
- function findFileByDisplayName(displayName: string, messages: any[], pendingAttachments: any[]): PreviewFile | null {
63
- if (!displayName) return null;
64
-
65
- // Search sent messages
66
- for (const msg of messages) {
67
- const content = msg.content || msg;
68
- const attachments = content?.attachments;
69
- if (!attachments?.length) continue;
70
-
71
- for (const att of attachments) {
72
- const attName = att.filename || att.name || '';
73
- if (attName === displayName || `${att.title || ''}${att.extname || ''}` === displayName) {
74
- return attToPreviewFile(att);
75
- }
76
- }
77
- }
78
-
79
- // Search pending (not yet sent) attachments
80
- for (const att of pendingAttachments || []) {
81
- const attName = att.filename || att.name || '';
82
- if (attName === displayName || `${att.title || ''}${att.extname || ''}` === displayName) {
83
- return attToPreviewFile(att);
84
- }
85
- }
86
-
87
- return null;
88
- }
89
-
90
- /**
91
- * Find file metadata matching the extracted URL
92
- */
93
- function findFileByUrl(url: string, messages: any[], pendingAttachments: any[]): PreviewFile | null {
94
- if (!url) return null;
95
- const matchUrl = (u1: string, u2: string) => {
96
- if (!u1 || !u2) return false;
97
- const clean1 = u1.split('?')[0].replace(location.origin, '').replace(/^\//, '');
98
- const clean2 = u2.split('?')[0].replace(location.origin, '').replace(/^\//, '');
99
- return clean1 === clean2;
100
- };
101
- for (const msg of messages) {
102
- const content = msg.content || msg;
103
- const attachments = content?.attachments;
104
- if (!attachments?.length) continue;
105
- for (const att of attachments) {
106
- if (matchUrl(att.url, url) || matchUrl(att.preview, url)) return attToPreviewFile(att);
107
- }
108
- }
109
- for (const att of pendingAttachments || []) {
110
- if (matchUrl(att.url, url) || matchUrl(att.preview, url)) return attToPreviewFile(att);
111
- }
112
- return null;
113
- }
114
-
115
- /**
116
- * Inner component that reads from plugin-ai's zustand stores via hooks.
117
- * Uses refs to make latest state available inside the DOM click handler.
118
- */
119
- const ChatFilePreviewInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
120
- const [previewOpen, setPreviewOpen] = useState(false);
121
- const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null);
122
- const [sessionId, setSessionId] = useState('');
123
- const apiClient = useAPIClient();
124
-
125
- // Read zustand stores via hooks — these re-render on changes
126
- const messages = useChatMessagesStore.use.messages();
127
- const pendingAttachments = useChatMessagesStore.use.attachments();
128
-
129
- // Keep latest values in refs for the click handler (avoids stale closures)
130
- const messagesRef = useRef(messages);
131
- const pendingAttachmentsRef = useRef(pendingAttachments);
132
- messagesRef.current = messages;
133
- pendingAttachmentsRef.current = pendingAttachments;
134
-
135
- // We don't have direct access to useChatConversationsStore (not exported).
136
- // Instead, we'll extract sessionId from the URL or from a data attribute on the DOM.
137
- // A simpler approach: use a global ref that gets populated via the axios interceptor.
138
- const currentSessionIdRef = useRef<string>('');
139
-
140
- // Track the current sessionId by intercepting the getMessages API call
141
- useEffect(() => {
142
- const reqInterceptor = apiClient.axios.interceptors.request.use((config) => {
143
- const url = config.url || '';
144
- // When loadMessages is called, the sessionId appears in the URL
145
- if (url.includes('aiConversations:getMessages')) {
146
- const match = url.match(/sessionId=([^&]+)/);
147
- if (match) {
148
- currentSessionIdRef.current = decodeURIComponent(match[1]);
149
- }
150
- }
151
- // When sendMessages is called, sessionId is in the request body
152
- if (url.includes('aiConversations:sendMessages') && config.data) {
153
- try {
154
- const data = typeof config.data === 'string' ? JSON.parse(config.data) : config.data;
155
- if (data?.sessionId) {
156
- currentSessionIdRef.current = data.sessionId;
157
- }
158
- } catch {
159
- // ignore
160
- }
161
- }
162
- return config;
163
- });
164
-
165
- return () => {
166
- apiClient.axios.interceptors.request.eject(reqInterceptor);
167
- };
168
- }, [apiClient]);
169
-
170
- // Track global drop and input change events to intercept file object selection ONLY for AI chat
171
- useEffect(() => {
172
- const handleDrop = (e: DragEvent) => {
173
- const target = e.target as HTMLElement;
174
- if (!target || !target.closest) return;
175
- if (!target.closest('.ant-x-sender') && !target.closest('.ant-x-attachments')) return;
176
-
177
- if (e.dataTransfer?.files) {
178
- Array.from(e.dataTransfer.files).forEach((f) => {
179
- if (f.name) {
180
- AppRamCache.set(f.name, f);
181
- }
182
- });
183
- }
184
- };
185
-
186
- const handleChange = (e: Event) => {
187
- const target = e.target as HTMLInputElement;
188
- if (!target || !target.closest) return;
189
- if (!target.closest('.ant-x-sender') && !target.closest('.ant-x-attachments')) return;
190
-
191
- if (target?.type === 'file' && target.files) {
192
- Array.from(target.files).forEach((f) => {
193
- if (f.name) {
194
- AppRamCache.set(f.name, f);
195
- }
196
- });
197
- }
198
- };
199
-
200
- window.addEventListener('drop', handleDrop, true);
201
- document.addEventListener('change', handleChange, true);
202
- return () => {
203
- window.removeEventListener('drop', handleDrop, true);
204
- document.removeEventListener('change', handleChange, true);
205
- };
206
- }, []);
207
-
208
-
209
-
210
- // Intercept antd upload origin files via Zustand state store as duplicate safety net
211
- useEffect(() => {
212
- if (!pendingAttachmentsRef.current?.length) return;
213
- pendingAttachmentsRef.current.forEach((att: any) => {
214
- const fileObj = att.originFileObj || att;
215
- if (fileObj && (fileObj instanceof Blob || fileObj instanceof File || 'size' in fileObj)) {
216
- const name = att.name || att.filename || fileObj.name;
217
- if (name) {
218
- AppRamCache.set(name, fileObj);
219
- }
220
- }
221
- });
222
- }, [pendingAttachments]);
223
-
224
- // Periodically check pure RAM cache and ONLY mark DOM cards that physically exist in local JS memory.
225
- // This automatically rules out external database checks and prevents 403s on old un-cached files.
226
- useEffect(() => {
227
- const checkInterval = setInterval(() => {
228
- // Isolate strictly to AI module components! (AI Chat outputs Ant Design X attachments)
229
- const aiContainers = document.querySelectorAll('.ant-x-sender, .ant-x-attachments, .ant-x-message');
230
-
231
- const cards: Element[] = [];
232
- aiContainers.forEach(container => {
233
- container.querySelectorAll('div[class*="attachment-list-card"]:not([class*="attachment-list-card-"])').forEach(c => cards.push(c));
234
- });
235
-
236
- cards.forEach(card => {
237
- const el = card as HTMLElement;
238
- const displayName = getDisplayNameFromCard(el);
239
- const urlNodes = el.querySelectorAll('a');
240
- let fallbackUrl = '';
241
- urlNodes.forEach((node) => {
242
- if (node.href) fallbackUrl = node.href;
243
- });
244
-
245
- // Resolve real file name from data store
246
- const file = findFileByDisplayName(displayName, messagesRef.current, pendingAttachmentsRef.current) ||
247
- findFileByUrl(fallbackUrl, messagesRef.current, pendingAttachmentsRef.current);
248
- const realName = file?.filename || file?.name || file?.title;
249
-
250
- // Strict RAM cache check
251
- const cacheHitName = realName && AppRamCache.has(realName) ? realName
252
- : (displayName && AppRamCache.has(displayName) ? displayName : null);
253
-
254
- if (cacheHitName) {
255
- el.classList.add('is-cached-previewable');
256
- } else {
257
- el.classList.remove('is-cached-previewable');
258
- }
259
- });
260
- }, 1000);
261
- return () => clearInterval(checkInterval);
262
- }, []);
263
-
264
- // Inject global CSS for explicit UI and native z-index click interception for child texts/tags
265
- useEffect(() => {
266
- const style = document.createElement('style');
267
- style.innerHTML = `
268
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) {
269
- position: relative !important;
270
- cursor: pointer !important;
271
- }
272
- /* Prevent pointer events on inner text and icons so the outer div receives the absolute click */
273
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) a,
274
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) [class*="-icon"],
275
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) span {
276
- pointer-events: none !important;
277
- }
278
- /* Re-enable pointer events for the delete button specifically */
279
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) [class*="-remove"],
280
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) button,
281
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]) .ant-btn {
282
- pointer-events: auto !important;
283
- }
284
- /* Visual "Preview" badge at the top-left corner using proper SVG */
285
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]).is-cached-previewable::after {
286
- content: '';
287
- 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");
288
- background-size: contain;
289
- background-repeat: no-repeat;
290
- position: absolute;
291
- top: 6px;
292
- left: 6px;
293
- width: 14px;
294
- height: 14px;
295
- z-index: 10;
296
- pointer-events: none;
297
- }
298
- /* Hide native antd thumbnail icon if we placed an eye so it doesnt look messy */
299
- div[class*="attachment-list-card"]:not([class*="attachment-list-card-"]).is-cached-previewable .ant-upload-list-item-thumbnail {
300
- opacity: 0.2;
301
- }
302
- `;
303
- document.head.appendChild(style);
304
- return () => {
305
- style.remove();
306
- };
307
- }, []);
308
-
309
- // Click interceptor: capture clicks on FileCard elements
310
- useEffect(() => {
311
- const handler = (e: MouseEvent) => {
312
- const el = e.target as Element;
313
- if (!el || typeof el.closest !== 'function') return;
314
-
315
- // Find closest FileCard element — uses ant-design/x class pattern
316
- const cardEl = el.closest('[class*="attachment-list-card"]:not([class*="attachment-list-card-"])') as HTMLElement;
317
- if (!cardEl) return;
318
-
319
- // Skip remove button clicks
320
- if (el.closest('[class*="-remove"]') || el.closest('.ant-btn')) return;
321
-
322
- // Ensure we only hijack the click if we VERIFIED the file exists in our cache!
323
- // This strictly prevents the 403 API fallback scenario.
324
- if (!cardEl.classList.contains('is-cached-previewable')) return;
325
-
326
- const displayName = getDisplayNameFromCard(cardEl);
327
- const urlNodes = cardEl.querySelectorAll('a');
328
- let fallbackUrl = '';
329
- urlNodes.forEach((node) => {
330
- if (node.href) fallbackUrl = node.href;
331
- });
332
-
333
- const file = findFileByDisplayName(displayName, messagesRef.current, pendingAttachmentsRef.current) ||
334
- findFileByUrl(fallbackUrl, messagesRef.current, pendingAttachmentsRef.current);
335
-
336
- if (!file) return;
337
- if (!isPreviewableFile(file)) return;
338
-
339
- e.preventDefault();
340
- e.stopPropagation();
341
-
342
- setSessionId(currentSessionIdRef.current || '');
343
- setPreviewFile(file);
344
- setPreviewOpen(true);
345
- };
346
-
347
- document.addEventListener('click', handler, { capture: true });
348
- return () => document.removeEventListener('click', handler, { capture: true });
349
- }, []);
350
-
351
- // Cleanup cache when conversations are deleted
352
- useEffect(() => {
353
- const interceptor = apiClient.axios.interceptors.response.use((response) => {
354
- try {
355
- const url = response.config?.url || '';
356
- if (url.includes('aiConversations:destroy')) {
357
- const match = url.match(/filterByTk=([^&]+)/);
358
- if (match) {
359
- SessionBlobCache.clearSession(decodeURIComponent(match[1])).catch(() => {});
360
- }
361
- }
362
- } catch {
363
- // ignore
364
- }
365
- return response;
366
- });
367
-
368
- return () => {
369
- apiClient.axios.interceptors.response.eject(interceptor);
370
- };
371
- }, [apiClient]);
372
-
373
- const handleClose = useCallback(() => {
374
- setPreviewOpen(false);
375
- setPreviewFile(null);
376
- }, []);
377
-
378
- return (
379
- <>
380
- {children}
381
- <PreviewModal open={previewOpen} file={previewFile} sessionId={sessionId} onClose={handleClose} />
382
- </>
383
- );
384
- };
385
-
386
- /**
387
- * Top-level provider that wraps the app.
388
- * Wrapped in try/catch ErrorBoundary so that if plugin-ai isn't loaded,
389
- * the app still works normally without preview functionality.
390
- */
391
- export class ChatFilePreviewErrorBoundary extends React.Component<
392
- { children: React.ReactNode },
393
- { hasError: boolean }
394
- > {
395
- constructor(props: { children: React.ReactNode }) {
396
- super(props);
397
- this.state = { hasError: false };
398
- }
399
-
400
- static getDerivedStateFromError() {
401
- return { hasError: true };
402
- }
403
-
404
- render() {
405
- if (this.state.hasError) {
406
- return this.props.children;
407
- }
408
- return this.props.children;
409
- }
410
- }
411
-
412
- export const ChatFilePreviewProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
413
- return (
414
- <ChatFilePreviewErrorBoundary>
415
- <ChatFilePreviewInner>{children}</ChatFilePreviewInner>
416
- </ChatFilePreviewErrorBoundary>
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
+ };