diff --git a/cmd/wsh/cmd/wshcmd-ai.go b/cmd/wsh/cmd/wshcmd-ai.go index b1f4ae3af0..643c80ee7a 100644 --- a/cmd/wsh/cmd/wshcmd-ai.go +++ b/cmd/wsh/cmd/wshcmd-ai.go @@ -5,6 +5,7 @@ package cmd import ( "encoding/base64" + "encoding/json" "fmt" "io" "net/http" @@ -13,6 +14,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" @@ -107,22 +109,38 @@ func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { if err != nil { return fmt.Errorf("accessing file %s: %w", filePath, err) } - if fileInfo.IsDir() { - return fmt.Errorf("%s is a directory, not a file", filePath) + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("getting absolute path for %s: %w", filePath, err) } - data, err = os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("reading file %s: %w", filePath, err) + if fileInfo.IsDir() { + result, err := fileutil.ReadDir(filePath, 500) + if err != nil { + return fmt.Errorf("reading directory %s: %w", filePath, err) + } + jsonData, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("marshaling directory listing for %s: %w", filePath, err) + } + data = jsonData + fileName = absPath + mimeType = "directory" + } else { + data, err = os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading file %s: %w", filePath, err) + } + fileName = absPath + mimeType = detectMimeType(data) } - fileName = filepath.Base(filePath) - mimeType = detectMimeType(data) } isPDF := mimeType == "application/pdf" isImage := strings.HasPrefix(mimeType, "image/") - - if !isPDF && !isImage { + isDirectory := mimeType == "directory" + + if !isPDF && !isImage && !isDirectory { mimeType = "text/plain" if utilfn.ContainsBinaryData(data) { return fmt.Errorf("file %s contains binary data and cannot be uploaded as text", fileName) diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts index cdaaef9507..0e80886b2f 100644 --- a/frontend/app/aipanel/ai-utils.ts +++ b/frontend/app/aipanel/ai-utils.ts @@ -4,26 +4,26 @@ export const isAcceptableFile = (file: File): boolean => { const acceptableTypes = [ // Images - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/svg+xml', + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", // PDFs - 'application/pdf', + "application/pdf", // Text files - 'text/plain', - 'text/markdown', - 'text/html', - 'text/css', - 'text/javascript', - 'text/typescript', + "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', + "application/javascript", + "application/typescript", + "application/json", + "application/xml", ]; if (acceptableTypes.includes(file.type)) { @@ -31,85 +31,122 @@ export const isAcceptableFile = (file: File): boolean => { } // Check file extensions for files without proper MIME types - const extension = file.name.split('.').pop()?.toLowerCase(); + const extension = file.name.split(".").pop()?.toLowerCase(); const acceptableExtensions = [ - 'txt', 'md', 'js', 'jsx', 'ts', 'tsx', 'go', 'py', 'java', 'c', 'cpp', 'h', 'hpp', - 'html', 'css', 'scss', 'sass', 'json', 'xml', 'yaml', 'yml', 'sh', 'bat', 'sql', - 'php', 'rb', 'rs', 'swift', 'kt', 'cs', 'vb', 'r', 'scala', 'clj', 'ex', 'exs' + "txt", + "md", + "js", + "jsx", + "ts", + "tsx", + "go", + "py", + "java", + "c", + "cpp", + "h", + "hpp", + "html", + "css", + "scss", + "sass", + "json", + "xml", + "yaml", + "yml", + "sh", + "bat", + "sql", + "php", + "rb", + "rs", + "swift", + "kt", + "cs", + "vb", + "r", + "scala", + "clj", + "ex", + "exs", ]; return extension ? acceptableExtensions.includes(extension) : false; }; export const getFileIcon = (fileName: string, fileType: string): string => { - if (fileType.startsWith('image/')) { - return 'fa-image'; + if (fileType === "directory") { + return "fa-folder"; } - - if (fileType === 'application/pdf') { - return 'fa-file-pdf'; + + 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(); + 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'; + 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'; + return "fa-file"; } }; export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 B'; + if (bytes === 0) return "0 B"; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; + 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]; + 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/')) { + if (fileType.startsWith("image/")) { return fileType; } - + // PDFs keep their mimetype - if (fileType === 'application/pdf') { + if (fileType === "application/pdf") { return fileType; } - + // Everything else (code files, markdown, text, etc.) becomes text/plain - return 'text/plain'; + return "text/plain"; }; // Helper function to read file as base64 for AIMessage @@ -119,7 +156,7 @@ export const readFileAsBase64 = (file: File): Promise => { reader.onload = () => { const result = reader.result as string; // Remove data URL prefix to get just base64 - const base64 = result.split(',')[1]; + const base64 = result.split(",")[1]; resolve(base64); }; reader.onerror = reject; @@ -141,7 +178,7 @@ export interface FileSizeError { fileName: string; fileSize: number; maxSize: number; - fileType: 'text' | 'pdf' | 'image'; + fileType: "text" | "pdf" | "image"; } export const validateFileSize = (file: File): FileSizeError | null => { @@ -149,22 +186,22 @@ export const validateFileSize = (file: File): FileSizeError | null => { const PDF_LIMIT = 5 * 1024 * 1024; // 5MB const IMAGE_LIMIT = 10 * 1024 * 1024; // 10MB - if (file.type.startsWith('image/')) { + if (file.type.startsWith("image/")) { if (file.size > IMAGE_LIMIT) { return { fileName: file.name, fileSize: file.size, maxSize: IMAGE_LIMIT, - fileType: 'image' + fileType: "image", }; } - } else if (file.type === 'application/pdf') { + } else if (file.type === "application/pdf") { if (file.size > PDF_LIMIT) { return { fileName: file.name, fileSize: file.size, maxSize: PDF_LIMIT, - fileType: 'pdf' + fileType: "pdf", }; } } else { @@ -173,7 +210,7 @@ export const validateFileSize = (file: File): FileSizeError | null => { fileName: file.name, fileSize: file.size, maxSize: TEXT_FILE_LIMIT, - fileType: 'text' + fileType: "text", }; } } @@ -182,7 +219,7 @@ export const validateFileSize = (file: File): FileSizeError | null => { }; export const formatFileSizeError = (error: FileSizeError): string => { - const typeLabel = error.fileType === 'image' ? 'Image' : error.fileType === 'pdf' ? 'PDF' : 'Text file'; + 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)}.`; }; @@ -192,7 +229,7 @@ export const formatFileSizeError = (error: FileSizeError): string => { */ export const resizeImage = async (file: File): Promise => { // Only process actual image files (not SVG) - if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') { + if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { return file; } @@ -207,30 +244,34 @@ export const resizeImage = async (file: File): Promise => { URL.revokeObjectURL(url); let { width, height } = img; - + // Check if resizing is needed if (width <= MAX_EDGE && height <= MAX_EDGE) { // Image is already small enough, just try WebP conversion - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; - const ctx = canvas.getContext('2d'); + 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', + 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)}`); + 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)}`); + console.log( + `Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}` + ); resolve(file); } }, - 'image/webp', + "image/webp", WEBP_QUALITY ); return; @@ -246,27 +287,31 @@ export const resizeImage = async (file: File): Promise => { } // Create canvas and resize - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; - const ctx = canvas.getContext('2d'); + 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', + 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)}`); + 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)}`); + 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', + "image/webp", WEBP_QUALITY ); }; @@ -284,7 +329,7 @@ export const resizeImage = async (file: File): Promise => { * Create a 128x128 preview data URL for an image file */ export const createImagePreview = async (file: File): Promise => { - if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') { + if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { return null; } @@ -308,10 +353,10 @@ export const createImagePreview = async (file: File): Promise => height = PREVIEW_SIZE; } - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0, width, height); canvas.toBlob( @@ -326,7 +371,7 @@ export const createImagePreview = async (file: File): Promise => resolve(null); } }, - 'image/webp', + "image/webp", WEBP_QUALITY ); }; @@ -338,4 +383,4 @@ export const createImagePreview = async (file: File): Promise => img.src = url; }); -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index cd1c128bad..24a7815f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.1-beta.0", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.1-beta.0", + "version": "0.12.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index 79ae202182..ee61cf71d8 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -363,13 +363,37 @@ func convertFileAIMessagePart(part uctypes.AIMessagePart) (*OpenAIMessageContent randomSuffix := uuid.New().String()[0:8] formattedText := fmt.Sprintf("\n%s\n", randomSuffix, quotedFileName, textContent, randomSuffix) + return &OpenAIMessageContent{ + Type: "input_text", + Text: formattedText, + }, nil + case part.MimeType == "directory": + var jsonContent string + + if len(part.Data) > 0 { + jsonContent = string(part.Data) + } else { + return nil, fmt.Errorf("directory listing part missing data") + } + + directoryName := part.FileName + if directoryName == "" { + directoryName = "unnamed-directory" + } + + encodedDirName := strings.ReplaceAll(directoryName, `"`, """) + quotedDirName := strconv.Quote(encodedDirName) + + randomSuffix := uuid.New().String()[0:8] + formattedText := fmt.Sprintf("\n%s\n", randomSuffix, quotedDirName, jsonContent, randomSuffix) + return &OpenAIMessageContent{ Type: "input_text", Text: formattedText, }, nil default: - return nil, fmt.Errorf("dropping file with unsupported mimetype '%s' (OpenAI supports images, PDFs, and text/plain)", part.MimeType) + return nil, fmt.Errorf("dropping file with unsupported mimetype '%s' (OpenAI supports images, PDFs, text/plain, and directories)", part.MimeType) } } @@ -506,6 +530,25 @@ func (m *OpenAIChatMessage) ConvertToUIMessage() *uctypes.UIMessage { MimeType: "text/plain", }, }) + } else if strings.HasPrefix(block.Text, " maxEntries { - entries = entries[:maxEntries] - truncated = true - } - - var entryList []DirEntryOut - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - continue - } - - isDir := isDirMap[entry.Name()] - isSymlink := entry.Type()&fs.ModeSymlink != 0 - - entryData := DirEntryOut{ - Name: entry.Name(), - Dir: isDir, - Symlink: isSymlink, - Mode: info.Mode().String(), - Modified: utilfn.FormatRelativeTime(info.ModTime()), - ModifiedTime: info.ModTime().UTC().Format(time.RFC3339), - } - - if !isDir { - entryData.Size = info.Size() - } - - entryList = append(entryList, entryData) + return nil, err } - result := map[string]any{ - "path": params.Path, - "absolute_path": expandedPath, - "entry_count": len(entryList), - "total_entries": totalEntries, - "entries": entryList, + resultMap := map[string]any{ + "path": result.Path, + "absolute_path": result.AbsolutePath, + "entry_count": result.EntryCount, + "total_entries": result.TotalEntries, + "entries": result.Entries, } - if truncated { - result["truncated"] = true - result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d total). Increase max_entries to see more.", len(entryList), totalEntries) + if result.Truncated { + resultMap["truncated"] = true + resultMap["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d total). Increase max_entries to see more.", result.EntryCount, result.TotalEntries) } - parentDir := filepath.Dir(expandedPath) - if parentDir != expandedPath { - result["parent_dir"] = parentDir + if result.ParentDir != "" { + resultMap["parent_dir"] = result.ParentDir } - return result, nil + return resultMap, nil } func GetReadDirToolDefinition() uctypes.ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 7e35bb2ba4..f3c45b8ae7 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -68,6 +68,7 @@ var SystemPromptText_OpenAI = strings.Join([]string{ // Attached text files `User-attached text files may appear inline as \ncontent\n.`, + `User-attached directories use the tag JSON DirInfo.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`, diff --git a/pkg/util/fileutil/readdir.go b/pkg/util/fileutil/readdir.go new file mode 100644 index 0000000000..0f419b919c --- /dev/null +++ b/pkg/util/fileutil/readdir.go @@ -0,0 +1,137 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package fileutil + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +type DirEntryOut struct { + Name string `json:"name"` + Dir bool `json:"dir,omitempty"` + Symlink bool `json:"symlink,omitempty"` + Size int64 `json:"size,omitempty"` + Mode string `json:"mode"` + Modified string `json:"modified"` + ModifiedTime string `json:"modified_time"` +} + +type ReadDirResult struct { + Path string `json:"path"` + AbsolutePath string `json:"absolute_path"` + ParentDir string `json:"parent_dir,omitempty"` + Entries []DirEntryOut `json:"entries"` + EntryCount int `json:"entry_count"` + TotalEntries int `json:"total_entries"` + Truncated bool `json:"truncated,omitempty"` +} + +func ReadDir(path string, maxEntries int) (*ReadDirResult, error) { + expandedPath, err := wavebase.ExpandHomeDir(path) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + fileInfo, err := os.Stat(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to stat path: %w", err) + } + + if !fileInfo.IsDir() { + return nil, fmt.Errorf("path is not a directory") + } + + entries, err := os.ReadDir(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + totalEntries := len(entries) + + isDirMap := make(map[string]bool) + symlinkCount := 0 + for _, entry := range entries { + name := entry.Name() + if entry.Type()&fs.ModeSymlink != 0 { + if symlinkCount < 1000 { + symlinkCount++ + fullPath := filepath.Join(expandedPath, name) + if info, err := os.Stat(fullPath); err == nil { + isDirMap[name] = info.IsDir() + } else { + isDirMap[name] = entry.IsDir() + } + } else { + isDirMap[name] = entry.IsDir() + } + } else { + isDirMap[name] = entry.IsDir() + } + } + + sort.Slice(entries, func(i, j int) bool { + iIsDir := isDirMap[entries[i].Name()] + jIsDir := isDirMap[entries[j].Name()] + if iIsDir != jIsDir { + return iIsDir + } + return entries[i].Name() < entries[j].Name() + }) + + var truncated bool + if len(entries) > maxEntries { + entries = entries[:maxEntries] + truncated = true + } + + var entryList []DirEntryOut + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + + isDir := isDirMap[entry.Name()] + isSymlink := entry.Type()&fs.ModeSymlink != 0 + + entryData := DirEntryOut{ + Name: entry.Name(), + Dir: isDir, + Symlink: isSymlink, + Mode: info.Mode().String(), + Modified: utilfn.FormatRelativeTime(info.ModTime()), + ModifiedTime: info.ModTime().UTC().Format(time.RFC3339), + } + + if !isDir { + entryData.Size = info.Size() + } + + entryList = append(entryList, entryData) + } + + result := &ReadDirResult{ + Path: path, + AbsolutePath: expandedPath, + Entries: entryList, + EntryCount: len(entryList), + TotalEntries: totalEntries, + Truncated: truncated, + } + + parentDir := filepath.Dir(expandedPath) + if parentDir != expandedPath { + result.ParentDir = parentDir + } + + return result, nil +} \ No newline at end of file