Skip to content

Commit 746d365

Browse files
committed
@ means read file
This is made popular by other prompts Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent 1b8cdb4 commit 746d365

File tree

3 files changed

+202
-404
lines changed

3 files changed

+202
-404
lines changed

cmd/cli/commands/images.go

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ func encodeImageToDataURL(filePath string) (string, error) {
9595
}
9696

9797
contentType := http.DetectContentType(buf)
98-
allowedTypes := []string{"image/jpeg", "image/png", "image/webp"}
99-
if !slices.Contains(allowedTypes, contentType) {
100-
return "", fmt.Errorf("invalid image type: %s", contentType)
98+
// Validate that this is an image file using both content and extension
99+
if !isImageFileByContentAndExtension(buf, filePath) {
100+
return "", fmt.Errorf("invalid image type for file: %s", filePath)
101101
}
102102

103103
info, err := file.Stat()
@@ -158,3 +158,113 @@ func processImagesInPrompt(prompt string) (string, []string, error) {
158158

159159
return strings.TrimSpace(prompt), imageDataURLs, nil
160160
}
161+
162+
// extractFileInclusions finds file paths in the prompt text using the @ symbol
163+
// e.g., @filename.txt, @./path/to/file.txt, @/absolute/path/file.txt
164+
func extractFileInclusions(prompt string) []string {
165+
// Regex to match @ followed by a file path
166+
// Pattern explanation:
167+
// - @ symbol before the path
168+
// - File path can be quoted (at least one char) or unquoted (at least one char)
169+
// - Supports relative (./, ../) and absolute paths
170+
regexPattern := `@(?:"([^"]+)"|'([^']+)'|([^\s"']+))`
171+
re := regexp.MustCompile(regexPattern)
172+
matches := re.FindAllStringSubmatch(prompt, -1)
173+
174+
paths := []string{}
175+
for _, match := range matches {
176+
// match[0] is the full match
177+
// match[1] is double-quoted content
178+
// match[2] is single-quoted content
179+
// match[3] is unquoted content
180+
if len(match) >= 2 && match[1] != "" {
181+
paths = append(paths, match[1])
182+
} else if len(match) >= 3 && match[2] != "" {
183+
paths = append(paths, match[2])
184+
} else if len(match) >= 4 && match[3] != "" {
185+
paths = append(paths, match[3])
186+
}
187+
}
188+
189+
return paths
190+
}
191+
192+
// hasValidImageExtension checks if the file has a valid image extension
193+
func hasValidImageExtension(filePath string) bool {
194+
filePathLower := strings.ToLower(filePath)
195+
return strings.HasSuffix(filePathLower, ".jpg") ||
196+
strings.HasSuffix(filePathLower, ".jpeg") ||
197+
strings.HasSuffix(filePathLower, ".png") ||
198+
strings.HasSuffix(filePathLower, ".webp")
199+
}
200+
201+
// isImageFileByContentAndExtension checks if a file is an image using both file extension and content type detection
202+
func isImageFileByContentAndExtension(content []byte, filePath string) bool {
203+
// First check content type
204+
contentType := http.DetectContentType(content)
205+
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp"}
206+
if slices.Contains(allowedTypes, contentType) {
207+
return true
208+
}
209+
210+
// Fallback to file extension check if content detection fails
211+
return hasValidImageExtension(filePath)
212+
}
213+
214+
// processFileInclusions extracts files mentioned with @ symbol, reads their contents,
215+
// and returns the prompt with file contents embedded
216+
func processFileInclusions(prompt string) (string, error) {
217+
filePaths := extractFileInclusions(prompt)
218+
219+
// Process each file inclusion in order
220+
for _, filePath := range filePaths {
221+
nfp := normalizeFilePath(filePath)
222+
223+
// Read the file content
224+
content, err := os.ReadFile(nfp)
225+
if err != nil {
226+
// Skip non-existent files or files that can't be read
227+
continue
228+
}
229+
230+
// Check if the file is an image to handle it appropriately
231+
// Only content is checked here, not file extension, to maintain original behavior
232+
if isImageFileByContentAndExtension(content, nfp) {
233+
// For image files, we keep the original file reference (@filePath) in the prompt
234+
// so that processImagesInPrompt can handle it properly
235+
continue
236+
}
237+
238+
// For non-image files, replace the @filename with the file content
239+
// Try different variations to match how it appears in the prompt
240+
escapedPath := regexp.QuoteMeta(filePath) // Escape special regex chars
241+
quotedPath := regexp.QuoteMeta(nfp) // Also try normalized path
242+
243+
// Replace all occurrences of the file reference with its content
244+
contentStr := string(content)
245+
246+
// Replace @ symbol usage with the file content - preserve the space or end by using word boundaries
247+
// Use capturing groups to preserve the space
248+
prompt = regexp.MustCompile(`@`+`"`+escapedPath+`"`).ReplaceAllString(prompt, contentStr)
249+
prompt = regexp.MustCompile(`@`+`'`+escapedPath+`'`).ReplaceAllString(prompt, contentStr)
250+
// For the unquoted version, we need to match @ + path and replace with content + the boundary character
251+
// Use a more specific pattern that captures the space or end
252+
prompt = regexp.MustCompile(`(@`+escapedPath+`)(\s|$)`).ReplaceAllString(prompt, contentStr+"$2")
253+
254+
// Also try replacing with normalized path
255+
prompt = regexp.MustCompile(`@`+`"`+quotedPath+`"`).ReplaceAllString(prompt, contentStr)
256+
prompt = regexp.MustCompile(`@`+`'`+quotedPath+`'`).ReplaceAllString(prompt, contentStr)
257+
prompt = regexp.MustCompile(`(@`+quotedPath+`)(\s|$)`).ReplaceAllString(prompt, contentStr+"$2")
258+
}
259+
260+
return prompt, nil
261+
}
262+
263+
// For testing purposes - making the functions public so they can be tested
264+
func ExtractFileInclusions(prompt string) []string {
265+
return extractFileInclusions(prompt)
266+
}
267+
268+
func ProcessFileInclusions(prompt string) (string, error) {
269+
return processFileInclusions(prompt)
270+
}

0 commit comments

Comments
 (0)