mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-08 22:21:23 +00:00
fix: address PR #6150 review feedback
- Consolidate duplicate getMimeType functions into shared utilities - Remove duplicate MediaThumbnails component, enhance Thumbnails to support video - Add JSDoc comments to VideoContentBlock interface - Convert inline styles to Tailwind classes in ChatRow - Add robust error handling for video processing - Create centralized media configuration for accepted file types - Ensure consistent test naming conventions - Fix ESLint warnings
This commit is contained in:
parent
ec674d2673
commit
2ac9984e28
12 changed files with 378 additions and 276 deletions
|
|
@ -1,7 +1,17 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Content, Part } from "@google/genai"
|
||||
|
||||
// Extended type to support video content blocks that aren't in the standard Anthropic SDK
|
||||
/**
|
||||
* Extended content block type to support video content that isn't in the standard Anthropic SDK.
|
||||
* This interface extends the standard Anthropic content blocks to include video support for Gemini models.
|
||||
*
|
||||
* @interface VideoContentBlock
|
||||
* @property {string} type - Must be "video" to identify this as a video content block
|
||||
* @property {Object} source - The video source information
|
||||
* @property {string} source.type - Must be "base64" for base64-encoded video data
|
||||
* @property {string} source.data - The base64-encoded video data
|
||||
* @property {string} source.media_type - The MIME type of the video (e.g., "video/mp4", "video/webm")
|
||||
*/
|
||||
interface VideoContentBlock {
|
||||
type: "video"
|
||||
source: {
|
||||
|
|
@ -11,6 +21,10 @@ interface VideoContentBlock {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended content block parameter type that includes both standard Anthropic content blocks
|
||||
* and our custom video content block for Gemini model support.
|
||||
*/
|
||||
type ExtendedContentBlockParam = Anthropic.ContentBlockParam | VideoContentBlock
|
||||
|
||||
export function convertAnthropicContentToGemini(content: string | ExtendedContentBlockParam[]): Part[] {
|
||||
|
|
@ -28,11 +42,39 @@ export function convertAnthropicContentToGemini(content: string | ExtendedConten
|
|||
}
|
||||
|
||||
return { inlineData: { data: block.source.data, mimeType: block.source.media_type } }
|
||||
case "video":
|
||||
case "video": {
|
||||
if (block.source.type !== "base64") {
|
||||
throw new Error("Unsupported video source type")
|
||||
throw new Error("Unsupported video source type. Only base64 encoded videos are supported.")
|
||||
}
|
||||
|
||||
// Validate video MIME type
|
||||
const supportedVideoTypes = ["video/mp4", "video/webm", "video/ogg", "video/quicktime"]
|
||||
if (!supportedVideoTypes.includes(block.source.media_type)) {
|
||||
throw new Error(
|
||||
`Unsupported video format: ${block.source.media_type}. Supported formats: ${supportedVideoTypes.join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if video data exists
|
||||
if (!block.source.data || block.source.data.trim() === "") {
|
||||
throw new Error("Video data is empty or missing")
|
||||
}
|
||||
|
||||
// Validate base64 format
|
||||
try {
|
||||
// Basic validation - check if it's valid base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/
|
||||
if (!base64Regex.test(block.source.data.replace(/\s/g, ""))) {
|
||||
throw new Error("Invalid base64 format for video data")
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to validate video data: ${e instanceof Error ? e.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { inlineData: { data: block.source.data, mimeType: block.source.media_type } }
|
||||
}
|
||||
case "tool_use":
|
||||
return {
|
||||
functionCall: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { getMimeType } from "../../shared/utils/media"
|
||||
|
||||
export async function selectImages(): Promise<string[]> {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
|
|
@ -23,23 +23,11 @@ export async function selectImages(): Promise<string[]> {
|
|||
const buffer = await fs.readFile(imagePath)
|
||||
const base64 = buffer.toString("base64")
|
||||
const mimeType = getMimeType(imagePath)
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported file type: ${imagePath}`)
|
||||
}
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`
|
||||
return dataUrl
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpeg":
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
throw new Error(`Unsupported file type: ${ext}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
src/shared/utils/media.ts
Normal file
54
src/shared/utils/media.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Get MIME type from either a file path or a data URI
|
||||
* @param input - Either a file path or a data URI
|
||||
* @returns The MIME type or null if not found
|
||||
*/
|
||||
export function getMimeType(input: string): string | null {
|
||||
// Check if it's a data URI
|
||||
if (input.startsWith("data:")) {
|
||||
const match = input.match(/^data:(.*?);/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// Otherwise, treat it as a file path
|
||||
const ext = path.extname(input).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpeg":
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".ogg":
|
||||
return "video/ogg"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a video
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if it's a video MIME type
|
||||
*/
|
||||
export function isVideoMimeType(mimeType: string | null): boolean {
|
||||
return mimeType?.startsWith("video/") ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an image
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if it's an image MIME type
|
||||
*/
|
||||
export function isImageMimeType(mimeType: string | null): boolean {
|
||||
return mimeType?.startsWith("image/") ?? false
|
||||
}
|
||||
|
|
@ -201,37 +201,26 @@ export const ChatRowContent = ({
|
|||
|
||||
const type = message.type === "ask" ? message.ask : message.say
|
||||
|
||||
const normalColor = "var(--vscode-foreground)"
|
||||
const errorColor = "var(--vscode-errorForeground)"
|
||||
const successColor = "var(--vscode-charts-green)"
|
||||
const cancelledColor = "var(--vscode-descriptionForeground)"
|
||||
|
||||
const [icon, title] = useMemo(() => {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:error")}</span>,
|
||||
<span className="codicon codicon-error text-vscode-errorForeground -mb-[1.5px]"></span>,
|
||||
<span className="text-vscode-errorForeground font-bold">{t("chat:error")}</span>,
|
||||
]
|
||||
case "mistake_limit_reached":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:troubleMessage")}</span>,
|
||||
<span className="codicon codicon-error text-vscode-errorForeground -mb-[1.5px]"></span>,
|
||||
<span className="text-vscode-errorForeground font-bold">{t("chat:troubleMessage")}</span>,
|
||||
]
|
||||
case "command":
|
||||
return [
|
||||
isCommandExecuting ? (
|
||||
<ProgressIndicator />
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-terminal"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
|
||||
<span className="codicon codicon-terminal text-vscode-foreground -mb-[1.5px]"></span>
|
||||
),
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:runCommand.title")}:</span>,
|
||||
<span className="text-vscode-foreground font-bold">{t("chat:runCommand.title")}:</span>,
|
||||
]
|
||||
case "use_mcp_server":
|
||||
const mcpServerUse = safeJsonParse<ClineAskUseMcpServer>(message.text)
|
||||
|
|
@ -242,11 +231,9 @@ export const ChatRowContent = ({
|
|||
isMcpServerResponding ? (
|
||||
<ProgressIndicator />
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-server"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
|
||||
<span className="codicon codicon-server text-vscode-foreground -mb-[1.5px]"></span>
|
||||
),
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>
|
||||
<span className="text-vscode-foreground font-bold">
|
||||
{mcpServerUse.type === "use_mcp_tool"
|
||||
? t("chat:mcp.wantsToUseTool", { serverName: mcpServerUse.serverName })
|
||||
: t("chat:mcp.wantsToAccessResource", { serverName: mcpServerUse.serverName })}
|
||||
|
|
@ -254,88 +241,60 @@ export const ChatRowContent = ({
|
|||
]
|
||||
case "completion_result":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-check"
|
||||
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
|
||||
<span className="codicon codicon-check text-vscode-charts-green -mb-[1.5px]"></span>,
|
||||
<span className="text-vscode-charts-green font-bold">{t("chat:taskCompleted")}</span>,
|
||||
]
|
||||
case "api_req_retry_delayed":
|
||||
return []
|
||||
case "api_req_started":
|
||||
const getIconSpan = (iconName: string, color: string) => (
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-${iconName}`}
|
||||
style={{ color, fontSize: 16, marginBottom: "-1.5px" }}
|
||||
/>
|
||||
const getIconSpan = (iconName: string, colorClass: string) => (
|
||||
<div className="w-4 h-4 flex items-center justify-center">
|
||||
<span className={`codicon codicon-${iconName} ${colorClass} text-base -mb-[1.5px]`} />
|
||||
</div>
|
||||
)
|
||||
return [
|
||||
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
|
||||
apiReqCancelReason === "user_cancelled" ? (
|
||||
getIconSpan("error", cancelledColor)
|
||||
getIconSpan("error", "text-vscode-descriptionForeground")
|
||||
) : (
|
||||
getIconSpan("error", errorColor)
|
||||
getIconSpan("error", "text-vscode-errorForeground")
|
||||
)
|
||||
) : cost !== null && cost !== undefined ? (
|
||||
getIconSpan("check", successColor)
|
||||
getIconSpan("check", "text-vscode-charts-green")
|
||||
) : apiRequestFailedMessage ? (
|
||||
getIconSpan("error", errorColor)
|
||||
getIconSpan("error", "text-vscode-errorForeground")
|
||||
) : (
|
||||
<ProgressIndicator />
|
||||
),
|
||||
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
|
||||
apiReqCancelReason === "user_cancelled" ? (
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>
|
||||
{t("chat:apiRequest.cancelled")}
|
||||
</span>
|
||||
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.cancelled")}</span>
|
||||
) : (
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>
|
||||
<span className="text-vscode-errorForeground font-bold">
|
||||
{t("chat:apiRequest.streamingFailed")}
|
||||
</span>
|
||||
)
|
||||
) : cost !== null && cost !== undefined ? (
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.title")}</span>
|
||||
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.title")}</span>
|
||||
) : apiRequestFailedMessage ? (
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:apiRequest.failed")}</span>
|
||||
<span className="text-vscode-errorForeground font-bold">{t("chat:apiRequest.failed")}</span>
|
||||
) : (
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.streaming")}</span>
|
||||
<span className="text-vscode-foreground font-bold">{t("chat:apiRequest.streaming")}</span>
|
||||
),
|
||||
]
|
||||
case "followup":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-question"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}
|
||||
/>,
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:questions.hasQuestion")}</span>,
|
||||
<span className="codicon codicon-question text-vscode-foreground -mb-[1.5px]" />,
|
||||
<span className="text-vscode-foreground font-bold">{t("chat:questions.hasQuestion")}</span>,
|
||||
]
|
||||
default:
|
||||
return [null, null]
|
||||
}
|
||||
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "10px",
|
||||
wordBreak: "break-word",
|
||||
}
|
||||
const headerClassName = "flex items-center gap-2.5 mb-2.5 break-words"
|
||||
|
||||
const pStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}
|
||||
const pClassName = "m-0 whitespace-pre-wrap break-words overflow-wrap-anywhere"
|
||||
|
||||
const tool = useMemo(
|
||||
() => (message.ask === "tool" ? safeJsonParse<ClineSayTool>(message.text) : null),
|
||||
|
|
@ -351,9 +310,7 @@ export const ChatRowContent = ({
|
|||
|
||||
if (tool) {
|
||||
const toolIcon = (name: string) => (
|
||||
<span
|
||||
className={`codicon codicon-${name}`}
|
||||
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
|
||||
<span className={`codicon codicon-${name} text-vscode-foreground -mb-[1.5px]`}></span>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
|
|
@ -363,11 +320,9 @@ export const ChatRowContent = ({
|
|||
if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("diff")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{t("chat:fileOperations.wantsToApplyBatchChanges")}
|
||||
</span>
|
||||
<span className="font-bold">{t("chat:fileOperations.wantsToApplyBatchChanges")}</span>
|
||||
</div>
|
||||
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
|
||||
</>
|
||||
|
|
@ -377,16 +332,13 @@ export const ChatRowContent = ({
|
|||
// Regular single file diff
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{tool.isProtected ? (
|
||||
<span
|
||||
className="codicon codicon-lock"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
|
||||
) : (
|
||||
toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")
|
||||
)}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{tool.isProtected
|
||||
? t("chat:fileOperations.wantsToEditProtected")
|
||||
: tool.isOutsideWorkspace
|
||||
|
|
@ -408,16 +360,13 @@ export const ChatRowContent = ({
|
|||
case "insertContent":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{tool.isProtected ? (
|
||||
<span
|
||||
className="codicon codicon-lock"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
|
||||
) : (
|
||||
toolIcon("insert")
|
||||
)}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{tool.isProtected
|
||||
? t("chat:fileOperations.wantsToEditProtected")
|
||||
: tool.isOutsideWorkspace
|
||||
|
|
@ -443,16 +392,13 @@ export const ChatRowContent = ({
|
|||
case "searchAndReplace":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{tool.isProtected ? (
|
||||
<span
|
||||
className="codicon codicon-lock"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
|
||||
) : (
|
||||
toolIcon("replace")
|
||||
)}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{tool.isProtected && message.type === "ask"
|
||||
? t("chat:fileOperations.wantsToEditProtected")
|
||||
: message.type === "ask"
|
||||
|
|
@ -473,9 +419,9 @@ export const ChatRowContent = ({
|
|||
)
|
||||
case "codebaseSearch": {
|
||||
return (
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("search")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{tool.path ? (
|
||||
<Trans
|
||||
i18nKey="chat:codebaseSearch.wantsToSearchWithPath"
|
||||
|
|
@ -511,16 +457,13 @@ export const ChatRowContent = ({
|
|||
case "newFileCreated":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{tool.isProtected ? (
|
||||
<span
|
||||
className="codicon codicon-lock"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
<span className="codicon codicon-lock text-vscode-editorWarning-foreground -mb-[1.5px]" />
|
||||
) : (
|
||||
toolIcon("new-file")
|
||||
)}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{tool.isProtected
|
||||
? t("chat:fileOperations.wantsToEditProtected")
|
||||
: t("chat:fileOperations.wantsToCreate")}
|
||||
|
|
@ -544,11 +487,9 @@ export const ChatRowContent = ({
|
|||
if (isBatchRequest) {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("files")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{t("chat:fileOperations.wantsToReadMultiple")}
|
||||
</span>
|
||||
<span className="font-bold">{t("chat:fileOperations.wantsToReadMultiple")}</span>
|
||||
</div>
|
||||
<BatchFilePermission
|
||||
files={tool.batchFiles || []}
|
||||
|
|
@ -564,9 +505,9 @@ export const ChatRowContent = ({
|
|||
// Regular single file read request
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask"
|
||||
? tool.isOutsideWorkspace
|
||||
? t("chat:fileOperations.wantsToReadOutsideWorkspace")
|
||||
|
|
@ -598,9 +539,9 @@ export const ChatRowContent = ({
|
|||
case "fetchInstructions":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>{t("chat:instructions.wantsToFetch")}</span>
|
||||
<span className="font-bold">{t("chat:instructions.wantsToFetch")}</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content}
|
||||
|
|
@ -614,9 +555,9 @@ export const ChatRowContent = ({
|
|||
case "listFilesTopLevel":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask"
|
||||
? tool.isOutsideWorkspace
|
||||
? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace")
|
||||
|
|
@ -638,9 +579,9 @@ export const ChatRowContent = ({
|
|||
case "listFilesRecursive":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask"
|
||||
? tool.isOutsideWorkspace
|
||||
? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace")
|
||||
|
|
@ -662,9 +603,9 @@ export const ChatRowContent = ({
|
|||
case "listCodeDefinitionNames":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask"
|
||||
? tool.isOutsideWorkspace
|
||||
? t("chat:directoryOperations.wantsToViewDefinitionsOutsideWorkspace")
|
||||
|
|
@ -686,9 +627,9 @@ export const ChatRowContent = ({
|
|||
case "searchFiles":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("search")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask" ? (
|
||||
<Trans
|
||||
i18nKey={
|
||||
|
|
@ -724,9 +665,9 @@ export const ChatRowContent = ({
|
|||
case "switchMode":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("symbol-enum")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
{message.type === "ask" ? (
|
||||
<>
|
||||
{tool.reason ? (
|
||||
|
|
@ -767,9 +708,9 @@ export const ChatRowContent = ({
|
|||
case "newTask":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("tasklist")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
<span className="font-bold">
|
||||
<Trans
|
||||
i18nKey="chat:subtasks.wantsToCreate"
|
||||
components={{ code: <code>{tool.mode}</code> }}
|
||||
|
|
@ -810,9 +751,9 @@ export const ChatRowContent = ({
|
|||
case "finishTask":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{toolIcon("check-all")}
|
||||
<span style={{ fontWeight: "bold" }}>{t("chat:subtasks.wantsToFinish")}</span>
|
||||
<span className="font-bold">{t("chat:subtasks.wantsToFinish")}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -890,7 +831,7 @@ export const ChatRowContent = ({
|
|||
fontSize: 16,
|
||||
marginBottom: "-1.5px",
|
||||
}}></span>
|
||||
<span style={{ fontWeight: "bold" }}>{t("chat:diffError.title")}</span>
|
||||
<span className="font-bold">{t("chat:diffError.title")}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<VSCodeButton
|
||||
|
|
@ -991,19 +932,13 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${headerClassName} justify-between cursor-pointer select-none`}
|
||||
style={{
|
||||
...headerStyle,
|
||||
marginBottom:
|
||||
((cost === null || cost === undefined) && apiRequestFailedMessage) ||
|
||||
apiReqStreamingFailedMessage
|
||||
? 10
|
||||
: 0,
|
||||
justifyContent: "space-between",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
onClick={handleToggleExpand}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
|
||||
|
|
@ -1019,7 +954,7 @@ export const ChatRowContent = ({
|
|||
{(((cost === null || cost === undefined) && apiRequestFailedMessage) ||
|
||||
apiReqStreamingFailedMessage) && (
|
||||
<>
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
|
||||
<p className={`${pClassName} text-vscode-errorForeground`}>
|
||||
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
|
||||
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
|
||||
<>
|
||||
|
|
@ -1135,18 +1070,18 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
|
||||
<p className={`${pClassName} text-vscode-errorForeground`}>{message.text}</p>
|
||||
</>
|
||||
)
|
||||
case "completion_result":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -1209,7 +1144,7 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -1225,11 +1160,11 @@ export const ChatRowContent = ({
|
|||
case "mistake_limit_reached":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
|
||||
<p className={`${pClassName} text-vscode-errorForeground`}>{message.text}</p>
|
||||
</>
|
||||
)
|
||||
case "command":
|
||||
|
|
@ -1262,7 +1197,7 @@ export const ChatRowContent = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -1304,7 +1239,7 @@ export const ChatRowContent = ({
|
|||
if (message.text) {
|
||||
return (
|
||||
<div>
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -1320,7 +1255,7 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
<div className={headerClassName}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { SelectDropdown, DropdownOptionType, Button, StandardTooltip } from "@/c
|
|||
|
||||
import ModeSelector from "./ModeSelector"
|
||||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||
import MediaThumbnails from "../common/MediaThumbnails"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import ContextMenu from "./ContextMenu"
|
||||
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
|
||||
import { IndexingStatusBadge } from "./IndexingStatusBadge"
|
||||
|
|
@ -1268,9 +1268,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
</div>
|
||||
|
||||
{selectedMedia.length > 0 && (
|
||||
<MediaThumbnails
|
||||
mediaItems={selectedMedia}
|
||||
setMediaItems={setSelectedMedia}
|
||||
<Thumbnails
|
||||
images={selectedMedia}
|
||||
setImages={setSelectedMedia}
|
||||
style={{
|
||||
left: "16px",
|
||||
zIndex: 2,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import RooTips from "@src/components/welcome/RooTips"
|
|||
import { StandardTooltip } from "@src/components/ui"
|
||||
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
|
||||
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
|
||||
import { getAcceptedFileTypes } from "@src/utils/media-config"
|
||||
|
||||
import TelemetryBanner from "../common/TelemetryBanner"
|
||||
import VersionIndicator from "../common/VersionIndicator"
|
||||
|
|
@ -702,27 +703,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
|
||||
|
||||
const acceptedFileTypes = useMemo(() => {
|
||||
const modelId = apiConfiguration?.apiModelId
|
||||
const isGeminiPro = modelId?.includes("gemini-2.5-pro")
|
||||
const isGeminiFlash =
|
||||
modelId?.includes("gemini-1.5-flash") ||
|
||||
modelId?.includes("gemini-2.0-flash-001") ||
|
||||
modelId?.includes("gemini-2.5-flash-preview-05-20") ||
|
||||
modelId?.includes("gemini-2.5-flash") ||
|
||||
modelId?.includes("gemini-2.0-flash-lite-preview-02-05") ||
|
||||
modelId?.includes("gemini-2.0-flash-thinking-exp-01-21") ||
|
||||
modelId?.includes("gemini-2.0-flash-thinking-exp-1219") ||
|
||||
modelId?.includes("gemini-2.0-flash-exp") ||
|
||||
modelId?.includes("gemini-2.5-flash-lite-preview-06-17")
|
||||
|
||||
if ((isGeminiPro || isGeminiFlash) && model?.supportsImages) {
|
||||
return ["png", "jpeg", "webp", "heic", "heif", "mp4", "mov", "avi", "wmv", "flv", "webm"]
|
||||
}
|
||||
if (model?.supportsImages) {
|
||||
return ["png", "jpeg", "webp", "heic", "heif"]
|
||||
}
|
||||
return []
|
||||
}, [apiConfiguration, model])
|
||||
return getAcceptedFileTypes(apiConfiguration?.apiModelId, model?.supportsImages)
|
||||
}, [apiConfiguration?.apiModelId, model?.supportsImages])
|
||||
|
||||
const shouldDisableImages =
|
||||
!model?.supportsImages || sendingDisabled || selectedMedia.length >= MAX_IMAGES_PER_MESSAGE
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defaultModeSlug } from "@roo/modes"
|
|||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import * as pathMentions from "@src/utils/path-mentions"
|
||||
import { BASE_IMAGE_FORMATS, VIDEO_FORMATS } from "@src/utils/media-config"
|
||||
|
||||
import ChatTextArea from "../ChatTextArea"
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ describe("ChatTextArea", () => {
|
|||
mode: defaultModeSlug,
|
||||
setMode: vi.fn(),
|
||||
modeShortcutText: "(⌘. for next mode)",
|
||||
acceptedFileTypes: ["png", "jpeg", "gif", "mp4"],
|
||||
acceptedFileTypes: [...BASE_IMAGE_FORMATS, ...VIDEO_FORMATS],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import React from "react"
|
||||
import { FileVideo, X } from "lucide-react"
|
||||
import { getMimeType } from "../../utils/getMimeType"
|
||||
|
||||
interface MediaThumbnailsProps {
|
||||
mediaItems: string[]
|
||||
setMediaItems: React.Dispatch<React.SetStateAction<string[]>>
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const MediaThumbnails: React.FC<MediaThumbnailsProps> = ({ mediaItems, setMediaItems, style }) => {
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setMediaItems((prevImages) => prevImages.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 p-2 bg-vscode-input-background" style={style}>
|
||||
{mediaItems.map((item, index) => {
|
||||
const mimeType = getMimeType(item)
|
||||
const isVideo = mimeType?.startsWith("video/")
|
||||
|
||||
return (
|
||||
<div key={index} className="relative w-16 h-16">
|
||||
{isVideo ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-vscode-input-background rounded">
|
||||
<FileVideo className="w-8 h-8 text-vscode-descriptionForeground" />
|
||||
</div>
|
||||
) : (
|
||||
<img src={item} alt={`thumbnail ${index}`} className="w-full h-full object-cover rounded" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="absolute top-0 right-0 bg-red-500 text-white rounded-full p-1">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaThumbnails
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useRef, useLayoutEffect, memo } from "react"
|
||||
import { useWindowSize } from "react-use"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { getMimeType, isVideoMimeType } from "../../utils/media"
|
||||
import { FileVideo } from "lucide-react"
|
||||
|
||||
interface ThumbnailsProps {
|
||||
images: string[]
|
||||
|
|
@ -46,51 +48,85 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
|
|||
rowGap: 3,
|
||||
...style,
|
||||
}}>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ position: "relative" }}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`Thumbnail ${index + 1}`}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleImageClick(image)}
|
||||
/>
|
||||
{isDeletable && hoveredIndex === index && (
|
||||
<div
|
||||
onClick={() => handleDelete(index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-close"
|
||||
{images.map((image, index) => {
|
||||
const mimeType = getMimeType(image)
|
||||
const isVideo = isVideoMimeType(mimeType)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{ position: "relative" }}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}>
|
||||
{isVideo ? (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
}}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
width: 34,
|
||||
height: 34,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleImageClick(image)}
|
||||
title={`Video: ${mimeType || "Unknown format"}`}>
|
||||
<FileVideo size={20} style={{ color: "var(--vscode-descriptionForeground)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={image}
|
||||
alt={`Thumbnail ${index + 1}`}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleImageClick(image)}
|
||||
onError={(e) => {
|
||||
// Handle image load errors
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const errorDiv = document.createElement("div")
|
||||
errorDiv.style.cssText =
|
||||
"width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background-color: var(--vscode-input-background); border-radius: 4px; font-size: 10px; color: var(--vscode-errorForeground);"
|
||||
errorDiv.textContent = "!"
|
||||
errorDiv.title = "Failed to load image"
|
||||
target.parentNode?.appendChild(errorDiv)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isDeletable && hoveredIndex === index && (
|
||||
<div
|
||||
onClick={() => handleDelete(index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-close"
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
}}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export function getMimeType(dataUri: string): string | null {
|
||||
const match = dataUri.match(/^data:(.*?);/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
84
webview-ui/src/utils/media-config.ts
Normal file
84
webview-ui/src/utils/media-config.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Configuration for accepted media file types by model
|
||||
*/
|
||||
|
||||
export interface MediaConfig {
|
||||
images: string[]
|
||||
videos: string[]
|
||||
}
|
||||
|
||||
// Base image formats supported by most models
|
||||
export const BASE_IMAGE_FORMATS = ["png", "jpeg", "webp", "heic", "heif"]
|
||||
|
||||
// Video formats supported by Gemini models
|
||||
export const VIDEO_FORMATS = ["mp4", "mov", "avi", "wmv", "flv", "webm"]
|
||||
|
||||
// Configuration for different model types
|
||||
export const MEDIA_CONFIG: Record<string, MediaConfig> = {
|
||||
// Gemini Pro and Flash models support both images and videos
|
||||
gemini_full: {
|
||||
images: BASE_IMAGE_FORMATS,
|
||||
videos: VIDEO_FORMATS,
|
||||
},
|
||||
// Default configuration for models that only support images
|
||||
default: {
|
||||
images: BASE_IMAGE_FORMATS,
|
||||
videos: [],
|
||||
},
|
||||
// Configuration for models that don't support any media
|
||||
none: {
|
||||
images: [],
|
||||
videos: [],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accepted file types for a given model
|
||||
* @param modelId - The model ID
|
||||
* @param supportsImages - Whether the model supports images
|
||||
* @returns Array of accepted file extensions
|
||||
*/
|
||||
export function getAcceptedFileTypes(modelId: string | undefined, supportsImages: boolean | undefined): string[] {
|
||||
if (!supportsImages) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Check if it's a Gemini model that supports video
|
||||
const isGeminiWithVideo =
|
||||
modelId?.includes("gemini-2.5-pro") ||
|
||||
modelId?.includes("gemini-1.5-flash") ||
|
||||
modelId?.includes("gemini-2.0-flash-001") ||
|
||||
modelId?.includes("gemini-2.5-flash-preview-05-20") ||
|
||||
modelId?.includes("gemini-2.5-flash") ||
|
||||
modelId?.includes("gemini-2.0-flash-lite-preview-02-05") ||
|
||||
modelId?.includes("gemini-2.0-flash-thinking-exp-01-21") ||
|
||||
modelId?.includes("gemini-2.0-flash-thinking-exp-1219") ||
|
||||
modelId?.includes("gemini-2.0-flash-exp") ||
|
||||
modelId?.includes("gemini-2.5-flash-lite-preview-06-17")
|
||||
|
||||
if (isGeminiWithVideo) {
|
||||
const config = MEDIA_CONFIG.gemini_full
|
||||
return [...config.images, ...config.videos]
|
||||
}
|
||||
|
||||
// Default to image-only support
|
||||
return MEDIA_CONFIG.default.images
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file type is an image
|
||||
* @param fileType - The file extension (without dot)
|
||||
* @returns true if the file type is an image
|
||||
*/
|
||||
export function isImageFileType(fileType: string): boolean {
|
||||
return BASE_IMAGE_FORMATS.includes(fileType.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file type is a video
|
||||
* @param fileType - The file extension (without dot)
|
||||
* @returns true if the file type is a video
|
||||
*/
|
||||
export function isVideoFileType(fileType: string): boolean {
|
||||
return VIDEO_FORMATS.includes(fileType.toLowerCase())
|
||||
}
|
||||
27
webview-ui/src/utils/media.ts
Normal file
27
webview-ui/src/utils/media.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Get MIME type from a data URI
|
||||
* @param dataUri - A data URI string
|
||||
* @returns The MIME type or null if not found
|
||||
*/
|
||||
export function getMimeType(dataUri: string): string | null {
|
||||
const match = dataUri.match(/^data:(.*?);/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents a video
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if it's a video MIME type
|
||||
*/
|
||||
export function isVideoMimeType(mimeType: string | null): boolean {
|
||||
return mimeType?.startsWith("video/") ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an image
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if it's an image MIME type
|
||||
*/
|
||||
export function isImageMimeType(mimeType: string | null): boolean {
|
||||
return mimeType?.startsWith("image/") ?? false
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue