mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 13:10:24 +08:00
Enables dragging files from preview directory listings directly into the
WaveAI panel for analysis.
## Changes
**Modified `frontend/app/aipanel/aipanel.tsx`:**
- Added `useDrop` hook to accept `FILE_ITEM` drag type from preview
directory
- Implemented `handleFileItemDrop` to:
- Read file content via `RpcApi.FileReadCommand` using the remote URI
- Convert base64 data to browser `File` object with proper MIME type
- Validate and add to panel using existing `model.addFile()` flow
- Integrated with existing drag overlay for visual feedback
- Rejects directories with appropriate error messaging
## Implementation
```typescript
const handleFileItemDrop = useCallback(
async (draggedFile: DraggedFile) => {
if (draggedFile.isDir) {
model.setError("Cannot add directories to Wave AI. Please select a file.");
return;
}
const fileData = await RpcApi.FileReadCommand(TabRpcClient, {
info: { path: draggedFile.uri }
}, null);
const bytes = new Uint8Array(atob(fileData.data64).split('').map(c => c.charCodeAt(0)));
const file = new File([bytes], draggedFile.relName, {
type: fileData.info?.mimetype || "application/octet-stream"
});
// Existing validation and addFile flow
await model.addFile(file);
},
[model]
);
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: "FILE_ITEM",
drop: handleFileItemDrop,
collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop() })
}), [handleFileItemDrop]);
```
No changes required to preview directory—it already exports `FILE_ITEM`
drag items. Works independently from native file system drag-and-drop.
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
531 lines
14 KiB
TypeScript
531 lines
14 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
const TextFileLimit = 200 * 1024; // 200KB
|
|
const PdfLimit = 5 * 1024 * 1024; // 5MB
|
|
const ImageLimit = 10 * 1024 * 1024; // 10MB
|
|
const ImagePreviewSize = 128;
|
|
const ImagePreviewWebPQuality = 0.8;
|
|
const ImageMaxEdge = 4096;
|
|
|
|
export const isAcceptableFile = (file: File): boolean => {
|
|
const acceptableTypes = [
|
|
// Images
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/svg+xml",
|
|
// PDFs
|
|
"application/pdf",
|
|
// Text files
|
|
"text/plain",
|
|
"text/markdown",
|
|
"text/html",
|
|
"text/css",
|
|
"text/javascript",
|
|
"text/typescript",
|
|
// Application types for code files
|
|
"application/javascript",
|
|
"application/typescript",
|
|
"application/json",
|
|
"application/xml",
|
|
];
|
|
|
|
if (acceptableTypes.includes(file.type)) {
|
|
return true;
|
|
}
|
|
|
|
// Check file extensions for files without proper MIME types
|
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
|
const acceptableExtensions = [
|
|
"txt",
|
|
"log",
|
|
"md",
|
|
"js",
|
|
"mjs",
|
|
"cjs",
|
|
"jsx",
|
|
"ts",
|
|
"mts",
|
|
"cts",
|
|
"tsx",
|
|
"go",
|
|
"py",
|
|
"java",
|
|
"c",
|
|
"cpp",
|
|
"h",
|
|
"hpp",
|
|
"html",
|
|
"htm",
|
|
"css",
|
|
"scss",
|
|
"sass",
|
|
"json",
|
|
"jsonc",
|
|
"json5",
|
|
"jsonl",
|
|
"ndjson",
|
|
"xml",
|
|
"yaml",
|
|
"yml",
|
|
"sh",
|
|
"bat",
|
|
"sql",
|
|
"php",
|
|
"rb",
|
|
"rs",
|
|
"swift",
|
|
"kt",
|
|
"cs",
|
|
"vb",
|
|
"r",
|
|
"scala",
|
|
"clj",
|
|
"ex",
|
|
"exs",
|
|
"ini",
|
|
"toml",
|
|
"conf",
|
|
"cfg",
|
|
"env",
|
|
"zsh",
|
|
"fish",
|
|
"ps1",
|
|
"psm1",
|
|
"bazel",
|
|
"bzl",
|
|
"csv",
|
|
"tsv",
|
|
"properties",
|
|
"ipynb",
|
|
"rmd",
|
|
"gradle",
|
|
"groovy",
|
|
"cmake",
|
|
];
|
|
|
|
if (extension && acceptableExtensions.includes(extension)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for specific filenames (case-insensitive)
|
|
const fileName = file.name.toLowerCase();
|
|
const acceptableFilenames = [
|
|
"makefile",
|
|
"dockerfile",
|
|
"containerfile",
|
|
"go.mod",
|
|
"go.sum",
|
|
"go.work",
|
|
"go.work.sum",
|
|
"package.json",
|
|
"package-lock.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"composer.json",
|
|
"composer.lock",
|
|
"gemfile",
|
|
"gemfile.lock",
|
|
"podfile",
|
|
"podfile.lock",
|
|
"cargo.toml",
|
|
"cargo.lock",
|
|
"pipfile",
|
|
"pipfile.lock",
|
|
"requirements.txt",
|
|
"setup.py",
|
|
"pyproject.toml",
|
|
"poetry.lock",
|
|
"build.gradle",
|
|
"settings.gradle",
|
|
"pom.xml",
|
|
"build.xml",
|
|
"readme",
|
|
"readme.md",
|
|
"license",
|
|
"license.md",
|
|
"changelog",
|
|
"changelog.md",
|
|
"contributing",
|
|
"contributing.md",
|
|
"authors",
|
|
"codeowners",
|
|
"procfile",
|
|
"jenkinsfile",
|
|
"vagrantfile",
|
|
"rakefile",
|
|
"gruntfile.js",
|
|
"gulpfile.js",
|
|
"webpack.config.js",
|
|
"rollup.config.js",
|
|
"vite.config.js",
|
|
"jest.config.js",
|
|
"vitest.config.js",
|
|
".dockerignore",
|
|
".gitignore",
|
|
".gitattributes",
|
|
".gitmodules",
|
|
".editorconfig",
|
|
".eslintrc",
|
|
".prettierrc",
|
|
".pylintrc",
|
|
".bashrc",
|
|
".bash_profile",
|
|
".bash_login",
|
|
".bash_logout",
|
|
".profile",
|
|
".zshrc",
|
|
".zprofile",
|
|
".zshenv",
|
|
".zlogin",
|
|
".zlogout",
|
|
".kshrc",
|
|
".cshrc",
|
|
".tcshrc",
|
|
".xonshrc",
|
|
".shrc",
|
|
".aliases",
|
|
".functions",
|
|
".exports",
|
|
".direnvrc",
|
|
".vimrc",
|
|
".gvimrc",
|
|
];
|
|
|
|
return acceptableFilenames.includes(fileName);
|
|
};
|
|
|
|
export const getFileIcon = (fileName: string, fileType: string): string => {
|
|
if (fileType === "directory") {
|
|
return "fa-folder";
|
|
}
|
|
|
|
if (fileType.startsWith("image/")) {
|
|
return "fa-image";
|
|
}
|
|
|
|
if (fileType === "application/pdf") {
|
|
return "fa-file-pdf";
|
|
}
|
|
|
|
// Check file extensions for code files
|
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
|
switch (ext) {
|
|
case "js":
|
|
case "jsx":
|
|
case "ts":
|
|
case "tsx":
|
|
return "fa-file-code";
|
|
case "go":
|
|
return "fa-file-code";
|
|
case "py":
|
|
return "fa-file-code";
|
|
case "java":
|
|
case "c":
|
|
case "cpp":
|
|
case "h":
|
|
case "hpp":
|
|
return "fa-file-code";
|
|
case "html":
|
|
case "css":
|
|
case "scss":
|
|
case "sass":
|
|
return "fa-file-code";
|
|
case "json":
|
|
case "xml":
|
|
case "yaml":
|
|
case "yml":
|
|
return "fa-file-code";
|
|
case "md":
|
|
case "txt":
|
|
return "fa-file-text";
|
|
default:
|
|
return "fa-file";
|
|
}
|
|
};
|
|
|
|
export const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
};
|
|
|
|
// Normalize MIME type for AI processing
|
|
export const normalizeMimeType = (file: File): string => {
|
|
const fileType = file.type;
|
|
|
|
// Images keep their real mimetype
|
|
if (fileType.startsWith("image/")) {
|
|
return fileType;
|
|
}
|
|
|
|
// PDFs keep their mimetype
|
|
if (fileType === "application/pdf") {
|
|
return fileType;
|
|
}
|
|
|
|
// Everything else (code files, markdown, text, etc.) becomes text/plain
|
|
return "text/plain";
|
|
};
|
|
|
|
// Helper function to read file as base64 for AIMessage
|
|
export const readFileAsBase64 = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result as string;
|
|
// Remove data URL prefix to get just base64
|
|
const base64 = result.split(",")[1];
|
|
resolve(base64);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
// Helper function to create data URL for UIMessage
|
|
export const createDataUrl = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
export interface FileSizeError {
|
|
fileName: string;
|
|
fileSize: number;
|
|
maxSize: number;
|
|
fileType: "text" | "pdf" | "image";
|
|
}
|
|
|
|
export const validateFileSize = (file: File): FileSizeError | null => {
|
|
if (file.type.startsWith("image/")) {
|
|
if (file.size > ImageLimit) {
|
|
return {
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
maxSize: ImageLimit,
|
|
fileType: "image",
|
|
};
|
|
}
|
|
} else if (file.type === "application/pdf") {
|
|
if (file.size > PdfLimit) {
|
|
return {
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
maxSize: PdfLimit,
|
|
fileType: "pdf",
|
|
};
|
|
}
|
|
} else {
|
|
if (file.size > TextFileLimit) {
|
|
return {
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
maxSize: TextFileLimit,
|
|
fileType: "text",
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const validateFileSizeFromInfo = (
|
|
fileName: string,
|
|
fileSize: number,
|
|
mimeType: string
|
|
): FileSizeError | null => {
|
|
let maxSize: number;
|
|
let fileType: "text" | "pdf" | "image";
|
|
|
|
if (mimeType.startsWith("image/")) {
|
|
maxSize = ImageLimit;
|
|
fileType = "image";
|
|
} else if (mimeType === "application/pdf") {
|
|
maxSize = PdfLimit;
|
|
fileType = "pdf";
|
|
} else {
|
|
maxSize = TextFileLimit;
|
|
fileType = "text";
|
|
}
|
|
|
|
if (fileSize > maxSize) {
|
|
return {
|
|
fileName,
|
|
fileSize,
|
|
maxSize,
|
|
fileType,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const formatFileSizeError = (error: FileSizeError): string => {
|
|
const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file";
|
|
return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`;
|
|
};
|
|
|
|
/**
|
|
* Resize an image to have a maximum edge of 4096px and convert to WebP format
|
|
* Returns the optimized image if it's smaller than the original, otherwise returns the original
|
|
*/
|
|
export const resizeImage = async (file: File): Promise<File> => {
|
|
// Only process actual image files (not SVG)
|
|
if (!file.type.startsWith("image/") || file.type === "image/svg+xml") {
|
|
return file;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
|
|
img.onload = async () => {
|
|
URL.revokeObjectURL(url);
|
|
|
|
let { width, height } = img;
|
|
|
|
// Check if resizing is needed
|
|
if (width <= ImageMaxEdge && height <= ImageMaxEdge) {
|
|
// Image is already small enough, just try WebP conversion
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(img, 0, 0);
|
|
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob && blob.size < file.size) {
|
|
const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), {
|
|
type: "image/webp",
|
|
});
|
|
console.log(
|
|
`Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`
|
|
);
|
|
resolve(webpFile);
|
|
} else {
|
|
console.log(
|
|
`Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}`
|
|
);
|
|
resolve(file);
|
|
}
|
|
},
|
|
"image/webp",
|
|
ImagePreviewWebPQuality
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Calculate new dimensions while maintaining aspect ratio
|
|
if (width > height) {
|
|
height = Math.round((height * ImageMaxEdge) / width);
|
|
width = ImageMaxEdge;
|
|
} else {
|
|
width = Math.round((width * ImageMaxEdge) / height);
|
|
height = ImageMaxEdge;
|
|
}
|
|
|
|
// Create canvas and resize
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(img, 0, 0, width, height);
|
|
|
|
// Convert to WebP
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob && blob.size < file.size) {
|
|
const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), {
|
|
type: "image/webp",
|
|
});
|
|
console.log(
|
|
`Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`
|
|
);
|
|
resolve(webpFile);
|
|
} else {
|
|
console.log(
|
|
`Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}`
|
|
);
|
|
resolve(file);
|
|
}
|
|
},
|
|
"image/webp",
|
|
ImagePreviewWebPQuality
|
|
);
|
|
};
|
|
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(file);
|
|
};
|
|
|
|
img.src = url;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a 128x128 preview data URL for an image file
|
|
*/
|
|
export const createImagePreview = async (file: File): Promise<string | null> => {
|
|
if (!file.type.startsWith("image/") || file.type === "image/svg+xml") {
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
|
|
let { width, height } = img;
|
|
|
|
if (width > height) {
|
|
height = Math.round((height * ImagePreviewSize) / width);
|
|
width = ImagePreviewSize;
|
|
} else {
|
|
width = Math.round((width * ImagePreviewSize) / height);
|
|
height = ImagePreviewSize;
|
|
}
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(img, 0, 0, width, height);
|
|
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
resolve(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
},
|
|
"image/webp",
|
|
ImagePreviewWebPQuality
|
|
);
|
|
};
|
|
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(null);
|
|
};
|
|
|
|
img.src = url;
|
|
});
|
|
};
|