finished file preview

This commit is contained in:
Ida Dittrich 2025-09-08 18:18:18 +02:00
parent bf2c2f982c
commit 5816a1b752
9 changed files with 362 additions and 150 deletions

View file

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Message, Document } from "./dashboardChatAreaTypes";
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
import { useApiRequest } from '../../../hooks/useApi';
import { FilePreview } from '../../FilePreview';
@ -19,102 +19,61 @@ const formatFileSize = (bytes?: number) => {
const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set());
const { request } = useApiRequest();
const [previewModalOpen, setPreviewModalOpen] = useState<boolean>(false);
const [previewingFile, setPreviewingFile] = useState<Document | null>(null);
const handleDocumentClick = async (doc: Document) => {
console.log('📋 Document object:', doc);
const handlePreview = (doc: Document) => {
console.log('👁️ Opening preview for document:', doc);
// Extract the UUID file ID from the downloadUrl (same as files page)
let actualFileId: string | null = null;
let downloadUrl: string;
if (doc.downloadUrl) {
// Extract UUID from the downloadUrl: /api/workflows/files/{UUID}/download
const match = doc.downloadUrl.match(/\/api\/workflows\/files\/([^\/]+)\/download/);
if (match) {
actualFileId = match[1];
// Use the regular files endpoint with the UUID (same as files page)
downloadUrl = `/api/files/${actualFileId}/download`;
console.log(`🔗 Extracted UUID from downloadUrl: ${actualFileId}`);
console.log(`🌐 Will use files endpoint: ${downloadUrl}`);
} else {
console.error('Could not extract file ID from downloadUrl:', doc.downloadUrl);
alert('Could not extract file ID from download URL');
return;
}
} else if (doc.id) {
// Fallback to using the document id directly
actualFileId = doc.id;
console.log(`🔗 Using document id as fileId: ${actualFileId}`);
} else {
console.error('No downloadUrl available in document');
alert('No download URL available');
console.error('No downloadUrl or id available in document');
alert('No file ID available for preview');
return;
}
if (!actualFileId) {
console.error('Could not determine actual file ID');
alert('Could not determine file ID for download');
alert('Could not determine file ID for preview');
return;
}
// Prevent multiple downloads of the same file
if (downloadingFiles.has(actualFileId)) {
console.log('⏭️ Download already in progress for this file');
return;
}
// Create a modified document with the correct file ID
const documentWithFileId = {
...doc,
id: actualFileId
};
setPreviewingFile(documentWithFileId);
setPreviewModalOpen(true);
};
setDownloadingFiles(prev => new Set(prev).add(actualFileId));
const handleClosePreview = () => {
console.log('❌ Closing preview');
setPreviewModalOpen(false);
setPreviewingFile(null);
};
try {
console.log(`📥 Starting download for file: ${doc.name} (UUID: ${actualFileId})`);
console.log(`🌐 Making request to: ${downloadUrl}`);
const blob = await request({
url: downloadUrl,
method: 'get',
additionalConfig: {
responseType: 'blob'
}
});
console.log(`✅ Download successful for: ${doc.name}`, {
size: blob.size,
type: blob.type
});
// Create filename with extension
const fileName = doc.ext ? `${doc.name}.${doc.ext}` : doc.name;
// Create a download link and trigger the download
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error: any) {
console.error(`❌ Download failed for ${doc.name}:`, error);
console.error('Full error object:', error);
console.error('Error response:', error.response);
let errorMessage = 'Download failed';
if (error.response?.status === 404) {
errorMessage = `File "${doc.name}" not found or has been deleted. (404)`;
} else if (error.response?.status === 403) {
errorMessage = `No permission to download "${doc.name}". (403)`;
} else {
errorMessage = `Download failed: ${error.message || 'Unknown error'}`;
}
alert(errorMessage);
} finally {
setDownloadingFiles(prev => {
const newSet = new Set(prev);
newSet.delete(actualFileId!);
return newSet;
});
}
const handleDocumentClick = (doc: Document) => {
console.log('📋 Document clicked, opening preview:', doc);
handlePreview(doc);
};
@ -138,88 +97,92 @@ const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
const isUser = message.role === 'user';
return (
<div className={`${messageStyles.message_item} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_header}>
{isUser ? 'You' : message.agentName}
{message.timestamp && (
<span className={messageStyles.message_timestamp_inline}>
{formatTimestamp(message.timestamp)}
</span>
)}
</div>
<div className={`${messageStyles.message_bubble} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_content}>
{message.content || <span className={messageStyles.message_no_content}>[No message content]</span>}
<>
<div className={`${messageStyles.message_item} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_header}>
{isUser ? 'You' : message.agentName}
{message.timestamp && (
<span className={messageStyles.message_timestamp_inline}>
{formatTimestamp(message.timestamp)}
</span>
)}
</div>
{hasDocuments && (
<div className={`${messageStyles.message_documents} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={`${messageStyles.message_documents_header} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
{isUser ? 'Uploaded' : 'Attached'} Files ({message.documents?.length || 0})
</div>
<div>
<div className={`${messageStyles.message_bubble} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_content}>
{message.content || <span className={messageStyles.message_no_content}>[No message content]</span>}
</div>
{hasDocuments && (
<div className={`${messageStyles.message_documents} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={`${messageStyles.message_documents_header} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
{isUser ? 'Uploaded' : 'Attached'} Files ({message.documents?.length || 0})
</div>
<div>
{message.documents!.map((doc, i) => {
// Extract UUID from downloadUrl for download state tracking
let fileKey: string | null = null;
if (doc.downloadUrl) {
const match = doc.downloadUrl.match(/\/api\/workflows\/files\/([^\/]+)\/download/);
fileKey = match ? match[1] : null;
}
const isDownloading = fileKey && downloadingFiles.has(fileKey);
return (
<div
key={doc.id || i}
className={messageStyles.message_document_item}
onClick={() => handleDocumentClick(doc)}
title={isDownloading ? 'Downloading...' : `Click to download ${doc.name}`}
title={`Click to preview ${doc.name}`}
style={{
cursor: isDownloading ? 'wait' : 'pointer',
opacity: isDownloading ? 0.7 : 1
cursor: 'pointer'
}}
>
<div className={messageStyles.message_document_info}>
<div className={messageStyles.message_document_name}>
{doc.ext ? `${doc.name}.${doc.ext}` : doc.name}
<div className={messageStyles.message_document_info}>
<div className={messageStyles.message_document_name}>
{doc.ext ? `${doc.name}.${doc.ext}` : doc.name}
</div>
{doc.size && (
<div className={messageStyles.message_document_size}>
{formatFileSize(doc.size)}
</div>
)}
</div>
<div className={messageStyles.message_document_actions}>
{/* Preview and Download buttons disabled for now
<button
onClick={(e) => handlePreview(doc, e)}
className={messageStyles.message_document_action_button}
title="Preview file"
>
<div className={messageStyles.message_document_action_icon}>
<FaEye size={16} />
</div>
</button>
<button
onClick={(e) => handleDownload(doc, e)}
disabled={isDownloading}
className={messageStyles.message_document_action_button}
title="Download file"
>
<div className={messageStyles.message_document_action_icon}>
<FaDownload size={16} />
</div>
</button>
*/}
</div>
{doc.size && (
<div className={messageStyles.message_document_size}>
{formatFileSize(doc.size)}
</div>
)}
</div>
<div className={messageStyles.message_document_actions}>
{/* Preview and Download buttons disabled for now
<button
onClick={(e) => handlePreview(doc, e)}
className={messageStyles.message_document_action_button}
title="Preview file"
>
<div className={messageStyles.message_document_action_icon}>
<FaEye size={16} />
</div>
</button>
<button
onClick={(e) => handleDownload(doc, e)}
disabled={isDownloading}
className={messageStyles.message_document_action_button}
title="Download file"
>
<div className={messageStyles.message_document_action_icon}>
<FaDownload size={16} />
</div>
</button>
*/}
</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
{/* File Preview Modal */}
{previewingFile && (
<FilePreview
isOpen={previewModalOpen}
onClose={handleClosePreview}
fileId={previewingFile.id || ''}
fileName={previewingFile.ext ? `${previewingFile.name}.${previewingFile.ext}` : previewingFile.name}
mimeType={previewingFile.type}
/>
)}
</>
);
};

View file

@ -297,7 +297,7 @@
font-size: 20px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 1000;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;

View file

@ -232,9 +232,9 @@
.valueContainer {
display: flex;
flex-direction: column;
width: 100%;
width: auto;
overflow: visible !important;
min-width: 0;
min-width: 20rem;
}
.jsonValuePreview {
@ -506,8 +506,8 @@
border-radius: 4px;
background: #f8f9fa;
overflow: visible !important;
width: 100%;
min-width: 100%;
width: auto;
min-width: 20rem;
}
@ -563,9 +563,11 @@
box-sizing: border-box;
position: relative;
z-index: 1;
white-space: nowrap !important;
word-break: keep-all !important;
overflow: visible !important;
min-width: 0;
width: 100%;
min-width: 20rem;
width: auto;
}
.nestedValueSummary {
@ -578,8 +580,8 @@
.arrayItems {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
gap: 0px;
margin-left: -0.4rem;
}
/* Array items when no key is shown (should span full width) */

View file

@ -10,6 +10,7 @@ import {
ImageRenderer,
TextRenderer,
PdfRenderer,
HtmlRenderer,
ApplicationRenderer,
UnsupportedRenderer,
LoadingRenderer,
@ -122,8 +123,8 @@ export function FilePreview({
// Create action buttons for the popup header
const actions: PopupAction[] = [
// Copy Content Button - only show for text-based files (exclude PDFs) or corrupted PDFs
...(mimeType !== 'application/pdf' && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
label: copySuccess ? t('files.preview.copied', 'Copied!') : t(''),
icon: copySuccess ? '✓' : <IoIosCopy />,
onClick: handleCopyContent,
@ -207,6 +208,17 @@ export function FilePreview({
);
case 'text':
// Special handling for HTML files
if (mimeType === 'text/html') {
return (
<HtmlRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load HTML preview')}
/>
);
}
return (
<TextRenderer
previewUrl={previewUrl}
@ -234,6 +246,16 @@ export function FilePreview({
);
}
if (mimeType === 'application/html') {
return (
<HtmlRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load HTML preview')}
/>
);
}
return (

View file

@ -0,0 +1,41 @@
import styles from '../FilePreview.module.css';
interface HtmlRendererProps {
previewUrl: string;
fileName: string;
onError: () => void;
}
export function HtmlRenderer({ previewUrl, fileName, onError }: HtmlRendererProps) {
const handleLoad = () => {
console.log('🌐 HTML loaded successfully:', { previewUrl, fileName });
};
const handleError = (event: React.SyntheticEvent<HTMLIFrameElement, Event>) => {
console.error('❌ HTML failed to load:', { previewUrl, fileName, event });
onError();
};
console.log('🔍 HtmlRenderer props:', {
previewUrl,
fileName,
hasPreviewUrl: !!previewUrl
});
return (
<iframe
src={previewUrl}
className={styles.previewIframe}
title={`Preview of ${fileName}`}
data-mime-type="text/html"
onLoad={handleLoad}
onError={handleError}
style={{
background: 'white !important',
border: 'none',
width: '100%',
height: '100%'
}}
/>
);
}

View file

@ -7,12 +7,28 @@ interface ImageRendererProps {
}
export function ImageRenderer({ previewUrl, fileName, onError }: ImageRendererProps) {
const handleLoad = () => {
console.log('🖼️ Image loaded successfully:', { previewUrl, fileName });
};
const handleError = (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
console.error('❌ Image failed to load:', { previewUrl, fileName, event });
onError();
};
console.log('🔍 ImageRenderer props:', {
previewUrl,
fileName,
hasPreviewUrl: !!previewUrl
});
return (
<img
src={previewUrl}
alt={fileName}
className={styles.previewImage}
onError={onError}
onLoad={handleLoad}
onError={handleError}
/>
);
}

View file

@ -247,7 +247,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
return value.length > 8;
}
if (typeof value === 'object' && value !== null && 'keys' in value) {
return value.keys.length > 2;
return value.keys.length > 1;
}
if (typeof value === 'object' && value !== null && 'isArray' in value) {
return value.length > 8;

View file

@ -2,6 +2,7 @@ export { JsonRenderer } from './JsonRenderer';
export { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer';
export { PdfRenderer } from './PdfRenderer';
export { HtmlRenderer } from './HtmlRenderer';
export { ApplicationRenderer } from './ApplicationRenderer';
export { UnsupportedRenderer } from './UnsupportedRenderer';
export { LoadingRenderer } from './LoadingRenderer';

View file

@ -596,6 +596,173 @@ export function useFileOperations() {
}
}
// For image files, try JSON response first (API returns base64-encoded images)
if (mimeType?.startsWith('image/')) {
console.log('🖼️ Image file detected, trying JSON response with base64 content');
try {
const jsonResponse = await request({
url: `/api/files/${fileId}/preview`,
method: 'get',
additionalConfig: {
responseType: 'json',
validateStatus: function (status: number) {
return status >= 200 && status < 300;
}
}
});
console.log('🖼️ Image JSON response received:', {
hasContent: 'content' in jsonResponse,
hasMimeType: 'mimeType' in jsonResponse,
contentLength: jsonResponse.content?.length,
mimeType: jsonResponse.mimeType
});
// Check if response has base64-encoded image content
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
let content = jsonResponse.content;
const responseMimeType = jsonResponse.mimeType || mimeType;
// The content field contains base64-encoded data, decode it first
if (typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
console.log('🖼️ Content appears to be base64-encoded, decoding first...');
try {
const decodedString = atob(content);
console.log('🖼️ Decoded string:', {
length: decodedString.length,
startsWith: decodedString.substring(0, 20),
isJson: decodedString.startsWith('{'),
isImage: decodedString.startsWith('\x89PNG') || decodedString.startsWith('\xFF\xD8\xFF')
});
// Check if it's JSON (nested structure) or direct image data
if (decodedString.startsWith('{')) {
// It's JSON, parse it
const nestedJson = JSON.parse(decodedString);
console.log('🖼️ Parsed nested JSON:', {
hasContent: 'content' in nestedJson,
keys: Object.keys(nestedJson)
});
if (nestedJson && typeof nestedJson === 'object' && 'content' in nestedJson) {
const innerContent = nestedJson.content;
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(innerContent);
console.log('🖼️ Extracted inner content:', {
innerContentLength: innerContent?.length,
innerContentPreview: innerContent?.substring(0, 100) + '...',
isBase64: isBase64
});
if (isBase64) {
// It's base64-encoded image content
content = innerContent;
} else {
throw new Error('Inner content is not base64-encoded');
}
}
} else if (decodedString.startsWith('\x89PNG') || decodedString.startsWith('\xFF\xD8\xFF') || decodedString.startsWith('GIF8') || decodedString.startsWith('RIFF')) {
// It's direct image data, use it as is
console.log('🖼️ Direct image data detected, using as is');
content = btoa(decodedString); // Re-encode as base64 for processing
} else {
throw new Error('Decoded content is neither JSON nor image data');
}
} catch (decodeError) {
console.warn('⚠️ Failed to decode base64 content:', decodeError);
throw decodeError;
}
}
console.log('🖼️ Processing base64 image content:', {
contentLength: content?.length,
mimeType: responseMimeType,
contentPreview: content?.substring(0, 100) + '...',
isBase64: /^[A-Za-z0-9+/=]+$/.test(content)
});
// Decode base64 content
let decodedContent;
try {
decodedContent = atob(content);
console.log('🖼️ Base64 decode successful:', {
originalLength: content.length,
decodedLength: decodedContent.length,
firstBytes: decodedContent.substring(0, 10)
});
// Verify it's actually an image by checking for common image headers
const isJPEG = decodedContent.startsWith('\xFF\xD8\xFF');
const isPNG = decodedContent.startsWith('\x89PNG\r\n\x1a\n');
const isGIF = decodedContent.startsWith('GIF8');
const isWebP = decodedContent.startsWith('RIFF') && decodedContent.includes('WEBP');
console.log('🖼️ Image header verification:', {
isJPEG: isJPEG,
isPNG: isPNG,
isGIF: isGIF,
isWebP: isWebP,
firstBytes: decodedContent.substring(0, 4)
});
if (!isJPEG && !isPNG && !isGIF && !isWebP) {
console.warn('⚠️ Decoded content does not appear to be a valid image');
}
} catch (decodeError) {
console.error('❌ Failed to decode base64 image content:', decodeError);
throw new Error('Failed to decode image content');
}
// Create a blob from the decoded image content
// Convert string to Uint8Array for proper binary handling
const uint8Array = new Uint8Array(decodedContent.length);
for (let i = 0; i < decodedContent.length; i++) {
uint8Array[i] = decodedContent.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: responseMimeType });
const url = window.URL.createObjectURL(blob);
console.log('🔗 Created image blob URL from base64:', url, {
blobSize: blob.size,
blobType: blob.type,
url: url,
uint8ArrayLength: uint8Array.length
});
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
} else {
throw new Error('No content field in image response');
}
} catch (jsonError) {
console.log('🖼️ JSON image response failed, trying blob response...', jsonError);
// Fallback to blob response
const previewData = await request({
url: `/api/files/${fileId}/preview`,
method: 'get',
additionalConfig: {
responseType: 'blob',
validateStatus: function (status: number) {
return status >= 200 && status < 300;
}
}
});
console.log(`✅ Image blob preview successful for: ${fileName}`, {
size: previewData.size,
type: previewData.type,
expectedType: mimeType
});
const url = window.URL.createObjectURL(previewData);
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
}
}
// For other files, first try to get JSON response (for text-based files)
try {
const jsonResponse = await request({