feat: Replace image-only attachment with general file attachment support

- Add comprehensive file processing system with MIME type detection
- Support multiple file categories (images, documents, code, data, archives, config)
- Implement 10MB file size limit and intelligent text/binary handling
- Replace camera icon with paperclip icon in chat interface
- Add selectFiles message types and handlers
- Update translations for attachFiles across all languages
- Maintain backward compatibility with existing image functionality

Resolves #5532
This commit is contained in:
Roo Code 2025-07-18 17:36:14 +00:00
parent a6e16e80d9
commit 53422d3856
24 changed files with 286 additions and 5 deletions

View file

@ -30,6 +30,7 @@ import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { selectFiles } from "../../integrations/misc/process-files"
import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
@ -381,6 +382,15 @@ export const webviewMessageHandler = async (
messageTs: message.messageTs,
})
break
case "selectFiles":
const files = await selectFiles()
await provider.postMessageToWebview({
type: "selectedFiles",
files,
context: message.context,
messageTs: message.messageTs,
})
break
case "exportCurrentTask":
const currentTaskId = provider.getCurrentCline()?.taskId
if (currentTaskId) {

View file

@ -0,0 +1,246 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
// File size limit: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024
// Supported file types with their MIME types
const FILE_TYPE_CATEGORIES = {
images: ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"],
documents: ["pdf", "doc", "docx", "txt", "rtf", "odt", "md"],
code: ["js", "ts", "py", "java", "cpp", "c", "h", "hpp", "html", "css", "json", "xml", "yaml", "yml", "php", "rb", "go", "rs", "swift", "kt", "scala", "sh", "bat", "ps1"],
data: ["csv", "xls", "xlsx", "sql", "db", "sqlite"],
archives: ["zip", "rar", "tar", "gz", "7z"],
config: ["ini", "conf", "config", "env", "properties"],
}
// Text file extensions that should be read as content
const TEXT_FILE_EXTENSIONS = [
...FILE_TYPE_CATEGORIES.code,
...FILE_TYPE_CATEGORIES.documents.filter(ext => ["txt", "md"].includes(ext)),
...FILE_TYPE_CATEGORIES.config,
...FILE_TYPE_CATEGORIES.data.filter(ext => ["csv", "sql"].includes(ext)),
]
export interface ProcessedFile {
name: string
path: string
size: number
type: string
category: string
content?: string // For text files
dataUrl?: string // For images and binary files
error?: string
}
export async function selectFiles(): Promise<ProcessedFile[]> {
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
openLabel: "Select",
filters: {
"All Files": ["*"],
"Images": FILE_TYPE_CATEGORIES.images,
"Documents": FILE_TYPE_CATEGORIES.documents,
"Code Files": FILE_TYPE_CATEGORIES.code,
"Data Files": FILE_TYPE_CATEGORIES.data,
"Archives": FILE_TYPE_CATEGORIES.archives,
"Config Files": FILE_TYPE_CATEGORIES.config,
},
}
const fileUris = await vscode.window.showOpenDialog(options)
if (!fileUris || fileUris.length === 0) {
return []
}
return await Promise.all(
fileUris.map(async (uri: vscode.Uri) => {
try {
return await processFile(uri.fsPath)
} catch (error) {
const fileName = path.basename(uri.fsPath)
return {
name: fileName,
path: uri.fsPath,
size: 0,
type: "unknown",
category: "unknown",
error: error instanceof Error ? error.message : String(error),
}
}
}),
)
}
export async function processFile(filePath: string): Promise<ProcessedFile> {
const fileName = path.basename(filePath)
const fileExt = path.extname(filePath).toLowerCase().slice(1)
// Check file size
const stats = await fs.stat(filePath)
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`File size (${formatFileSize(stats.size)}) exceeds the 10MB limit`)
}
const mimeType = getMimeType(filePath)
const category = getFileCategory(fileExt)
const processedFile: ProcessedFile = {
name: fileName,
path: filePath,
size: stats.size,
type: mimeType,
category,
}
// Handle different file types
if (category === "images") {
// Process images as data URLs (existing behavior)
const buffer = await fs.readFile(filePath)
const base64 = buffer.toString("base64")
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
} else if (isTextFile(fileExt)) {
// Read text files as content
try {
processedFile.content = await fs.readFile(filePath, "utf-8")
} catch (error) {
// If UTF-8 reading fails, treat as binary
const buffer = await fs.readFile(filePath)
const base64 = buffer.toString("base64")
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
}
} else {
// Handle binary files as base64
const buffer = await fs.readFile(filePath)
const base64 = buffer.toString("base64")
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
}
return processedFile
}
// Backward compatibility function for existing image functionality
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
openLabel: "Select",
filters: {
Images: FILE_TYPE_CATEGORIES.images,
},
}
const fileUris = await vscode.window.showOpenDialog(options)
if (!fileUris || fileUris.length === 0) {
return []
}
return await Promise.all(
fileUris.map(async (uri: vscode.Uri) => {
const filePath = uri.fsPath
const buffer = await fs.readFile(filePath)
const base64 = buffer.toString("base64")
const mimeType = getMimeType(filePath)
return `data:${mimeType};base64,${base64}`
}),
)
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
// Image types
switch (ext) {
case ".png": return "image/png"
case ".jpeg":
case ".jpg": return "image/jpeg"
case ".gif": return "image/gif"
case ".bmp": return "image/bmp"
case ".webp": return "image/webp"
case ".svg": return "image/svg+xml"
// Document types
case ".pdf": return "application/pdf"
case ".doc": return "application/msword"
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".txt": return "text/plain"
case ".rtf": return "application/rtf"
case ".odt": return "application/vnd.oasis.opendocument.text"
case ".md": return "text/markdown"
// Code types
case ".js": return "text/javascript"
case ".ts": return "text/typescript"
case ".py": return "text/x-python"
case ".java": return "text/x-java-source"
case ".cpp":
case ".c": return "text/x-c"
case ".h":
case ".hpp": return "text/x-c"
case ".html": return "text/html"
case ".css": return "text/css"
case ".json": return "application/json"
case ".xml": return "application/xml"
case ".yaml":
case ".yml": return "application/x-yaml"
case ".php": return "text/x-php"
case ".rb": return "text/x-ruby"
case ".go": return "text/x-go"
case ".rs": return "text/x-rust"
case ".swift": return "text/x-swift"
case ".kt": return "text/x-kotlin"
case ".scala": return "text/x-scala"
case ".sh": return "text/x-shellscript"
case ".bat": return "text/x-msdos-batch"
case ".ps1": return "text/x-powershell"
// Data types
case ".csv": return "text/csv"
case ".xls": return "application/vnd.ms-excel"
case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".sql": return "text/x-sql"
case ".db":
case ".sqlite": return "application/x-sqlite3"
// Archive types
case ".zip": return "application/zip"
case ".rar": return "application/x-rar-compressed"
case ".tar": return "application/x-tar"
case ".gz": return "application/gzip"
case ".7z": return "application/x-7z-compressed"
// Config types
case ".ini": return "text/plain"
case ".conf":
case ".config": return "text/plain"
case ".env": return "text/plain"
case ".properties": return "text/plain"
default: return "application/octet-stream"
}
}
function getFileCategory(extension: string): string {
for (const [category, extensions] of Object.entries(FILE_TYPE_CATEGORIES)) {
if (extensions.includes(extension)) {
return category
}
}
return "other"
}
function isTextFile(extension: string): boolean {
return TEXT_FILE_EXTENSIONS.includes(extension)
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}

View file

@ -54,6 +54,7 @@ export interface ExtensionMessage {
| "action"
| "state"
| "selectedImages"
| "selectedFiles"
| "theme"
| "workspaceUpdated"
| "invoke"
@ -123,6 +124,7 @@ export interface ExtensionMessage {
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
state?: ExtensionState
images?: string[]
files?: any[] // ProcessedFile array from process-files.ts
filePaths?: string[]
openedTabs?: Array<{
label: string

View file

@ -53,6 +53,7 @@ export interface WebviewMessage {
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "selectFiles"
| "exportCurrentTask"
| "shareCurrentTask"
| "showTaskWithId"

View file

@ -25,7 +25,7 @@ import Thumbnails from "../common/Thumbnails"
import ModeSelector from "./ModeSelector"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal, Paperclip } from "lucide-react"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { cn } from "@/lib/utils"
import { usePromptHistory } from "./hooks/usePromptHistory"
@ -41,6 +41,7 @@ interface ChatTextAreaProps {
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
onSend: () => void
onSelectImages: () => void
onSelectFiles?: () => void
shouldDisableImages: boolean
onHeightChange?: (height: number) => void
mode: Mode
@ -63,6 +64,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setSelectedImages,
onSend,
onSelectImages,
onSelectFiles,
shouldDisableImages,
onHeightChange,
mode,
@ -984,11 +986,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</StandardTooltip>
)}
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<StandardTooltip content={t("chat:attachFiles")}>
<button
aria-label={t("chat:addImages")}
aria-label={t("chat:attachFiles")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
onClick={!shouldDisableImages ? (onSelectFiles || onSelectImages) : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
@ -1003,7 +1005,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
<Paperclip className="w-4 h-4" />
</button>
</StandardTooltip>
</div>

View file

@ -701,6 +701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const { info: model } = useSelectedModel(apiConfiguration)
const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
const selectFiles = useCallback(() => vscode.postMessage({ type: "selectFiles" }), [])
const shouldDisableImages =
!model?.supportsImages || sendingDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
@ -1858,6 +1859,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSelectedImages={setSelectedImages}
onSend={() => handleSendMessage(inputValue, selectedImages)}
onSelectImages={selectImages}
onSelectFiles={selectFiles}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
if (isAtBottom) {

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Seleccioneu la configuració de l'API",
"enhancePrompt": "Millora la sol·licitud amb context addicional",
"addImages": "Afegeix imatges al missatge",
"attachFiles": "Attach files to message",
"sendMessage": "Envia el missatge",
"stopTts": "Atura la síntesi de veu",
"typeMessage": "Escriu un missatge...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "API-Konfiguration auswählen",
"enhancePrompt": "Prompt mit zusätzlichem Kontext verbessern",
"addImages": "Bilder zur Nachricht hinzufügen",
"attachFiles": "Attach files to message",
"sendMessage": "Nachricht senden",
"stopTts": "Text-in-Sprache beenden",
"typeMessage": "Nachricht eingeben...",

View file

@ -122,6 +122,7 @@
},
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"addImages": "Add images to message",
"attachFiles": "Attach files to message",
"sendMessage": "Send message",
"stopTts": "Stop text-to-speech",
"typeMessage": "Type a message...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Seleccionar configuración de API",
"enhancePrompt": "Mejorar el mensaje con contexto adicional",
"addImages": "Agregar imágenes al mensaje",
"attachFiles": "Attach files to message",
"sendMessage": "Enviar mensaje",
"stopTts": "Detener texto a voz",
"typeMessage": "Escribe un mensaje...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Sélectionner la configuration de l'API",
"enhancePrompt": "Améliorer la requête avec un contexte supplémentaire",
"addImages": "Ajouter des images au message",
"attachFiles": "Attach files to message",
"sendMessage": "Envoyer le message",
"stopTts": "Arrêter la synthèse vocale",
"typeMessage": "Écrivez un message...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "एपीआई कॉन्फ़िगरेशन का चयन करें",
"enhancePrompt": "अतिरिक्त संदर्भ के साथ प्रॉम्प्ट बढ़ाएँ",
"addImages": "संदेश में चित्र जोड़ें",
"attachFiles": "Attach files to message",
"sendMessage": "संदेश भेजें",
"stopTts": "टेक्स्ट-टू-स्पीच बंद करें",
"typeMessage": "एक संदेश लिखें...",

View file

@ -125,6 +125,7 @@
"description": "Persona khusus yang menyesuaikan perilaku Roo."
},
"addImages": "Tambahkan gambar ke pesan",
"attachFiles": "Attach files to message",
"sendMessage": "Kirim pesan",
"stopTts": "Hentikan text-to-speech",
"typeMessage": "Ketik pesan...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Seleziona la configurazione API",
"enhancePrompt": "Migliora prompt con contesto aggiuntivo",
"addImages": "Aggiungi immagini al messaggio",
"attachFiles": "Attach files to message",
"sendMessage": "Invia messaggio",
"stopTts": "Interrompi sintesi vocale",
"typeMessage": "Scrivi un messaggio...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "API構成を選択",
"enhancePrompt": "追加コンテキストでプロンプトを強化",
"addImages": "メッセージに画像を追加",
"attachFiles": "Attach files to message",
"sendMessage": "メッセージを送信",
"stopTts": "テキスト読み上げを停止",
"typeMessage": "メッセージを入力...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "API 구성 선택",
"enhancePrompt": "추가 컨텍스트로 프롬프트 향상",
"addImages": "메시지에 이미지 추가",
"attachFiles": "Attach files to message",
"sendMessage": "메시지 보내기",
"stopTts": "텍스트 음성 변환 중지",
"typeMessage": "메시지 입력...",

View file

@ -111,6 +111,7 @@
"description": "Gespecialiseerde persona's die het gedrag van Roo aanpassen."
},
"addImages": "Afbeeldingen toevoegen aan bericht",
"attachFiles": "Attach files to message",
"sendMessage": "Bericht verzenden",
"stopTts": "Stop tekst-naar-spraak",
"typeMessage": "Typ een bericht...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Wybierz konfigurację API",
"enhancePrompt": "Ulepsz podpowiedź dodatkowym kontekstem",
"addImages": "Dodaj obrazy do wiadomości",
"attachFiles": "Attach files to message",
"sendMessage": "Wyślij wiadomość",
"stopTts": "Zatrzymaj syntezę mowy",
"typeMessage": "Wpisz wiadomość...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Selecionar configuração da API",
"enhancePrompt": "Aprimorar prompt com contexto adicional",
"addImages": "Adicionar imagens à mensagem",
"attachFiles": "Attach files to message",
"sendMessage": "Enviar mensagem",
"stopTts": "Parar conversão de texto em fala",
"typeMessage": "Digite uma mensagem...",

View file

@ -111,6 +111,7 @@
"description": "Специализированные персоны, которые настраивают поведение Roo."
},
"addImages": "Добавить изображения к сообщению",
"attachFiles": "Attach files to message",
"sendMessage": "Отправить сообщение",
"stopTts": "Остановить синтез речи",
"typeMessage": "Введите сообщение...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "API yapılandırmasını seçin",
"enhancePrompt": "Ek bağlamla istemi geliştir",
"addImages": "Mesaja resim ekle",
"attachFiles": "Attach files to message",
"sendMessage": "Mesaj gönder",
"stopTts": "Metin okumayı durdur",
"typeMessage": "Bir mesaj yazın...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "Chọn cấu hình API",
"enhancePrompt": "Nâng cao yêu cầu với ngữ cảnh bổ sung",
"addImages": "Thêm hình ảnh vào tin nhắn",
"attachFiles": "Attach files to message",
"sendMessage": "Gửi tin nhắn",
"stopTts": "Dừng chuyển văn bản thành giọng nói",
"typeMessage": "Nhập tin nhắn...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "选择 API 配置",
"enhancePrompt": "增强提示词",
"addImages": "添加图片到消息",
"attachFiles": "Attach files to message",
"sendMessage": "发送消息",
"stopTts": "停止文本转语音",
"typeMessage": "输入消息...",

View file

@ -104,6 +104,7 @@
"selectApiConfig": "選取 API 設定",
"enhancePrompt": "使用額外內容增強提示",
"addImages": "新增圖片到訊息中",
"attachFiles": "Attach files to message",
"sendMessage": "傳送訊息",
"stopTts": "停止文字轉語音",
"typeMessage": "輸入訊息...",