diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7e79855f7e12..24643fdd4f97 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -138,6 +138,8 @@ export const globalSettingsSchema = z.object({ mcpEnabled: z.boolean().optional(), enableMcpServerCreation: z.boolean().optional(), + mcpMaxImagesPerResponse: z.number().optional(), + mcpMaxImageSizeMB: z.number().optional(), mode: z.string().optional(), modeApiConfigs: z.record(z.string(), z.string()).optional(), diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index fd51b18feda4..64dc392e94fe 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -87,10 +87,16 @@ Otherwise, if you have not completed the task and do not need additional informa images?: string[], ): string | Array => { if (images && images.length > 0) { - const textBlock: Anthropic.TextBlockParam = { type: "text", text } const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images) - // Placing images after text leads to better results - return [textBlock, ...imageBlocks] + + if (text.trim()) { + const textBlock: Anthropic.TextBlockParam = { type: "text", text } + // Placing images after text leads to better results + return [textBlock, ...imageBlocks] + } else { + // For image-only responses, return only image blocks + return imageBlocks + } } else { return text } diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 8738e059e552..a3a7483405c9 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -7,7 +7,12 @@ import { ToolUse } from "../../../shared/tools" // Mock dependencies vi.mock("../../prompts/responses", () => ({ formatResponse: { - toolResult: vi.fn((result: string) => `Tool result: ${result}`), + toolResult: vi.fn((result: string, images?: string[]) => { + if (images && images.length > 0) { + return `Tool result: ${result} (with ${images.length} images)` + } + return `Tool result: ${result}` + }), toolError: vi.fn((error: string) => `Tool error: ${error}`), invalidMcpToolArgumentError: vi.fn((server: string, tool: string) => `Invalid args for ${server}:${tool}`), unknownMcpToolError: vi.fn((server: string, tool: string, availableTools: string[]) => { @@ -57,6 +62,10 @@ describe("useMcpToolTool", () => { getAllServers: vi.fn().mockReturnValue([]), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }), } @@ -209,6 +218,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) await useMcpToolTool( @@ -223,10 +236,729 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockAskApproval).toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Tool executed successfully") }) + it("should handle tool result with text and images", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated image:" }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Generated image:", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated image: (with 1 images)") + }) + + it("should handle tool result with only images (no text)", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: (with 1 images)") + }) + + it("should handle corrupted base64 image data gracefully", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with images:" }, + { + type: "image", + data: "invalid@base64@data", // Invalid base64 characters + mimeType: "image/png", + }, + { + type: "image", + data: "", // Empty base64 data + mimeType: "image/png", + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", // Valid base64 + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + // Spy on console.warn to verify error logging + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should continue processing despite corrupted images + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") + + // Should only include the valid image, not the corrupted ones + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Generated content with images:", [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ]) + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with images: (with 1 images)", + ) + + // Should log warnings for corrupted images + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data contains invalid characters") + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data is not a valid string") + + consoleSpy.mockRestore() + }) + + it("should handle non-string base64 data", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Some text" }, + { + type: "image", + data: 12345, // Non-string data + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should process text content normally + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Some text", []) + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Some text") + + // Should log warning for invalid data type + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: base64 data is not a valid string") + + consoleSpy.mockRestore() + }) + + it("should limit the number of images to prevent performance issues", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create more than 20 images (the current limit) + const imageContent = Array.from({ length: 25 }, (_, i) => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated many images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only process first 20 images + expect(mockTask.say).toHaveBeenCalledWith( + "mcp_server_response", + "Generated many images:", + expect.arrayContaining([expect.stringMatching(/^data:image\/png;base64,/)]), + ) + + // Check that exactly 20 images were processed + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(20) + + // Should log warning about exceeding limit + expect(consoleSpy).toHaveBeenCalledWith( + "MCP response contains more than 20 images. Additional images will be ignored to prevent performance issues.", + ) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated many images: (with 20 images)") + + consoleSpy.mockRestore() + }) + + it("should handle exactly the maximum number of images without warning", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create exactly 20 images (the current limit) + const imageContent = Array.from({ length: 20 }, (_, i) => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated exactly 20 images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should process all 20 images + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(20) + + // Should NOT log warning about exceeding limit + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining("MCP response contains more than 20 images"), + ) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated exactly 20 images: (with 20 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should respect custom maxImagesPerResponse setting", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create 10 images (more than custom limit of 5) + const imageContent = Array.from({ length: 10 }, () => ({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", + })) + + const mockToolResult = { + content: [{ type: "text", text: "Generated many images:" }, ...imageContent], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 5, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only process first 5 images (custom limit) + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(5) + + // Should log warning about exceeding custom limit + expect(consoleSpy).toHaveBeenCalledWith( + "MCP response contains more than 5 images. Additional images will be ignored to prevent performance issues.", + ) + + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result: Generated many images: (with 5 images)") + + consoleSpy.mockRestore() + }) + + it("should reject images that exceed size limit", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + // Create a large base64 string (approximately 2MB when decoded) + const largeBase64 = "A".repeat((2 * 1024 * 1024 * 4) / 3) // Base64 is ~33% larger than original + const smallBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU" + + const mockToolResult = { + content: [ + { type: "text", text: "Generated images with different sizes:" }, + { + type: "image", + data: largeBase64, // This should be rejected (too large) + mimeType: "image/png", + }, + { + type: "image", + data: smallBase64, // This should be accepted + mimeType: "image/png", + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 1, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the small image, not the large one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain(smallBase64) + + // Should log warning about size exceeding limit (either early check or full validation) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/MCP image (likely exceeds size limit|exceeds size limit)/), + ) + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated images with different sizes: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should ignore images with unsupported MIME types", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with different image types:" }, + { + type: "image", + data: "PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNDAiLz48L3N2Zz4=", + mimeType: "image/svg+xml", // Unsupported MIME type + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Supported MIME type + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the supported PNG image, not the SVG + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about unsupported MIME type + expect(consoleSpy).toHaveBeenCalledWith("Unsupported image MIME type: image/svg+xml") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with different image types: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should ignore malformed image content missing data property", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with malformed image:" }, + { + type: "image", + // Missing data property + mimeType: "image/png", + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Valid image + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the valid image, not the malformed one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about missing data property + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: missing data or mimeType") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with malformed image: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + + it("should ignore malformed image content missing mimeType property", async () => { + const block: ToolUse = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test_server", + tool_name: "test_tool", + arguments: '{"param": "value"}', + }, + partial: false, + } + + mockAskApproval.mockResolvedValue(true) + + const mockToolResult = { + content: [ + { type: "text", text: "Generated content with malformed image:" }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + // Missing mimeType property + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + mimeType: "image/png", // Valid image + }, + ], + isError: false, + } + + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + callTool: vi.fn().mockResolvedValue(mockToolResult), + }), + postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), + }) + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await useMcpToolTool( + mockTask as Task, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should only include the valid image, not the malformed one + const sayCall = (mockTask.say as any).mock.calls.find((call: any) => call[0] === "mcp_server_response") + expect(sayCall[2]).toHaveLength(1) + expect(sayCall[2][0]).toContain("data:image/png;base64,") + + // Should log warning about missing mimeType property + expect(consoleSpy).toHaveBeenCalledWith("Invalid MCP ImageContent: missing data or mimeType") + + expect(mockPushToolResult).toHaveBeenCalledWith( + "Tool result: Generated content with malformed image: (with 1 images)", + ) + + consoleSpy.mockRestore() + }) + it("should handle user rejection", async () => { const block: ToolUse = { type: "tool_use", @@ -415,6 +1147,10 @@ describe("useMcpToolTool", () => { callTool: vi.fn().mockResolvedValue(mockToolResult), }), postMessageToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({ + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, + }), }) const block: ToolUse = { @@ -442,7 +1178,7 @@ describe("useMcpToolTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockTask.recordToolError).not.toHaveBeenCalled() expect(mockTask.say).toHaveBeenCalledWith("mcp_server_request_started") - expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully") + expect(mockTask.say).toHaveBeenCalledWith("mcp_server_response", "Tool executed successfully", []) }) it("should reject unknown server names with available servers listed", async () => { diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 41697ab979b5..550145ecf0a9 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -5,6 +5,9 @@ import { ClineAskUseMcpServer } from "../../shared/ExtensionMessage" import { McpExecutionStatus } from "@roo-code/types" import { t } from "../../i18n" +const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/ + interface McpToolParams { server_name?: string tool_name?: string @@ -195,24 +198,107 @@ async function sendExecutionStatus(cline: Task, status: McpExecutionStatus): Pro }) } -function processToolContent(toolResult: any): string { +/** + * Calculate the approximate size of a base64 encoded image in MB + */ +function calculateImageSizeMB(base64Data: string): number { + // Base64 encoding increases size by ~33%, so actual bytes = base64Length * 0.75 + const sizeInBytes = base64Data.length * 0.75 + return sizeInBytes / (1024 * 1024) // Convert to MB +} + +async function validateAndProcessImage(item: any, maxImageSizeMB: number): Promise { + if (!item.mimeType || item.data === undefined || item.data === null) { + console.warn("Invalid MCP ImageContent: missing data or mimeType") + return null + } + + if (!SUPPORTED_IMAGE_TYPES.includes(item.mimeType)) { + console.warn(`Unsupported image MIME type: ${item.mimeType}`) + return null + } + + try { + // Validate base64 data before constructing data URL + if (typeof item.data !== "string" || item.data.trim() === "") { + console.warn("Invalid MCP ImageContent: base64 data is not a valid string") + return null + } + + // Quick size check before full validation to prevent memory spikes + const approximateSizeMB = (item.data.length * 0.75) / (1024 * 1024) + if (approximateSizeMB > maxImageSizeMB * 1.5) { + console.warn( + `MCP image likely exceeds size limit based on string length: ~${approximateSizeMB.toFixed(2)}MB`, + ) + return null + } + + // Basic validation for base64 format + if (!BASE64_REGEX.test(item.data.replace(/\s/g, ""))) { + console.warn("Invalid MCP ImageContent: base64 data contains invalid characters") + return null + } + + // Check image size + const imageSizeMB = calculateImageSizeMB(item.data) + if (imageSizeMB > maxImageSizeMB) { + console.warn( + `MCP image exceeds size limit: ${imageSizeMB.toFixed(2)}MB > ${maxImageSizeMB}MB. Image will be ignored.`, + ) + return null + } + + return `data:${item.mimeType};base64,${item.data}` + } catch (error) { + console.warn("Failed to process MCP image content:", error) + return null + } +} + +async function processToolContent(toolResult: any, cline: Task): Promise<{ text: string; images: string[] }> { if (!toolResult?.content || toolResult.content.length === 0) { - return "" + return { text: "", images: [] } } - return toolResult.content - .map((item: any) => { - if (item.type === "text") { - return item.text - } - if (item.type === "resource") { - const { blob: _, ...rest } = item.resource - return JSON.stringify(rest, null, 2) - } - return "" - }) - .filter(Boolean) - .join("\n\n") + const textParts: string[] = [] + + // Get MCP settings from the extension's global state + const state = await cline.providerRef.deref()?.getState() + const maxImagesPerResponse = Math.max(1, Math.min(100, state?.mcpMaxImagesPerResponse ?? 20)) + const maxImageSizeMB = Math.max(0.1, Math.min(50, state?.mcpMaxImageSizeMB ?? 10)) + + // Separate content by type for efficient processing + const allImageItems = toolResult.content.filter((item: any) => item.type === "image") + const imageItems = allImageItems.slice(0, maxImagesPerResponse) // Limit images before processing + + // Check if we need to warn about exceeding the limit + if (allImageItems.length > maxImagesPerResponse) { + console.warn( + `MCP response contains more than ${maxImagesPerResponse} images. Additional images will be ignored to prevent performance issues.`, + ) + } + + // Process images in parallel + const validatedImages = await Promise.all( + imageItems.map((item: any) => validateAndProcessImage(item, maxImageSizeMB)), + ) + const images = validatedImages.filter(Boolean) as string[] + + // Process other content types + toolResult.content.forEach((item: any) => { + if (item.type === "text") { + textParts.push(item.text) + } else if (item.type === "resource") { + const { blob: _, ...rest } = item.resource + textParts.push(JSON.stringify(rest, null, 2)) + } + }) + + return { + text: textParts.filter(Boolean).join("\n\n"), + images, + } } async function executeToolAndProcessResult( @@ -236,11 +322,13 @@ async function executeToolAndProcessResult( const toolResult = await cline.providerRef.deref()?.getMcpHub()?.callTool(serverName, toolName, parsedArguments) let toolResultPretty = "(No response)" + let images: string[] = [] if (toolResult) { - const outputText = processToolContent(toolResult) + const { text: outputText, images: outputImages } = await processToolContent(toolResult, cline) + images = outputImages - if (outputText) { + if (outputText || images.length > 0) { await sendExecutionStatus(cline, { executionId, status: "output", @@ -266,8 +354,8 @@ async function executeToolAndProcessResult( }) } - await cline.say("mcp_server_response", toolResultPretty) - pushToolResult(formatResponse.toolResult(toolResultPretty)) + await cline.say("mcp_server_response", toolResultPretty, images) + pushToolResult(formatResponse.toolResult(toolResultPretty, images)) } export async function useMcpToolTool( diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f198fad8b2b7..daaa17ca78bc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1738,6 +1738,8 @@ export class ClineProvider fuzzyMatchThreshold, mcpEnabled, enableMcpServerCreation, + mcpMaxImagesPerResponse, + mcpMaxImageSizeMB, alwaysApproveResubmit, requestDelaySeconds, currentApiConfigName, @@ -1853,6 +1855,8 @@ export class ClineProvider fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, mcpEnabled: mcpEnabled ?? true, enableMcpServerCreation: enableMcpServerCreation ?? true, + mcpMaxImagesPerResponse: mcpMaxImagesPerResponse ?? 20, + mcpMaxImageSizeMB: mcpMaxImageSizeMB ?? 10, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 10, currentApiConfigName: currentApiConfigName ?? "default", @@ -2074,6 +2078,8 @@ export class ClineProvider language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true, + mcpMaxImagesPerResponse: stateValues.mcpMaxImagesPerResponse ?? 20, + mcpMaxImageSizeMB: stateValues.mcpMaxImageSizeMB ?? 10, alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? false, requestDelaySeconds: Math.max(5, stateValues.requestDelaySeconds ?? 10), currentApiConfigName: stateValues.currentApiConfigName ?? "default", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bd4608c6eb27..136b31b85856 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -554,6 +554,8 @@ describe("ClineProvider", () => { diagnosticsEnabled: true, openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, + mcpMaxImagesPerResponse: 10, + mcpMaxImageSizeMB: 10, remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 551810625c35..7f2cf9a04c89 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1181,6 +1181,14 @@ export const webviewMessageHandler = async ( await updateGlobalState("enableMcpServerCreation", message.bool ?? true) await provider.postStateToWebview() break + case "mcpMaxImagesPerResponse": + await updateGlobalState("mcpMaxImagesPerResponse", message.value ?? 20) + await provider.postStateToWebview() + break + case "mcpMaxImageSizeMB": + await updateGlobalState("mcpMaxImageSizeMB", message.value ?? 10) + await provider.postStateToWebview() + break case "remoteControlEnabled": try { await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb9f..0a47a5136718 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -309,6 +309,8 @@ export type ExtensionState = Pick< mcpEnabled: boolean enableMcpServerCreation: boolean + mcpMaxImagesPerResponse: number + mcpMaxImageSizeMB: number mode: Mode customModes: ModeConfig[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc4520..e2973aa81015 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -133,6 +133,8 @@ export interface WebviewMessage { | "terminalCompressProgressBar" | "mcpEnabled" | "enableMcpServerCreation" + | "mcpMaxImagesPerResponse" + | "mcpMaxImageSizeMB" | "remoteControlEnabled" | "taskSyncEnabled" | "searchCommits" diff --git a/src/shared/__tests__/combineCommandSequences.spec.ts b/src/shared/__tests__/combineCommandSequences.spec.ts index 86bed15d2056..1f0add08e793 100644 --- a/src/shared/__tests__/combineCommandSequences.spec.ts +++ b/src/shared/__tests__/combineCommandSequences.spec.ts @@ -89,6 +89,48 @@ describe("combineCommandSequences", () => { }) }) + it("should preserve images from mcp_server_response messages", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "use_mcp_server", + text: JSON.stringify({ + serverName: "test-server", + toolName: "test-tool", + arguments: { param: "value" }, + }), + ts: 1625097600000, + }, + { + type: "say", + say: "mcp_server_response", + text: "Generated 1 image", + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ], + ts: 1625097601000, + }, + ] + + const result = combineCommandSequences(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + type: "ask", + ask: "use_mcp_server", + text: JSON.stringify({ + serverName: "test-server", + toolName: "test-tool", + arguments: { param: "value" }, + response: "Generated 1 image", + }), + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU", + ], + ts: 1625097600000, + }) + }) + it("should handle multiple MCP server requests", () => { const messages: ClineMessage[] = [ { diff --git a/src/shared/combineCommandSequences.ts b/src/shared/combineCommandSequences.ts index 56b97a368e5c..cd1493409ece 100644 --- a/src/shared/combineCommandSequences.ts +++ b/src/shared/combineCommandSequences.ts @@ -38,11 +38,16 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[ if (msg.type === "ask" && msg.ask === "use_mcp_server") { // Look ahead for MCP responses let responses: string[] = [] + let allImages: string[] = [] let j = i + 1 while (j < messages.length) { if (messages[j].say === "mcp_server_response") { responses.push(messages[j].text || "") + // Collect images from MCP server responses + if (messages[j].images && Array.isArray(messages[j].images) && messages[j].images!.length > 0) { + allImages.push(...messages[j].images!) + } processedIndices.add(j) j++ } else if (messages[j].type === "ask" && messages[j].ask === "use_mcp_server") { @@ -57,13 +62,22 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[ // Parse the JSON from the message text const jsonObj = safeJsonParse(msg.text || "{}", {}) - // Add the response to the JSON object - jsonObj.response = responses.join("\n") + // Only add non-empty responses + const nonEmptyResponses = responses.filter((response) => response.trim()) + if (nonEmptyResponses.length > 0) { + jsonObj.response = nonEmptyResponses.join("\n") + } // Stringify the updated JSON object const combinedText = JSON.stringify(jsonObj) - combinedMessages.set(msg.ts, { ...msg, text: combinedText }) + // Preserve images in the combined message + const combinedMessage = { ...msg, text: combinedText } + if (allImages.length > 0) { + combinedMessage.images = allImages + } + + combinedMessages.set(msg.ts, combinedMessage) } else { // If there's no response, just keep the original message combinedMessages.set(msg.ts, { ...msg }) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 23ec50af37d5..96940b67c772 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1500,6 +1500,7 @@ export const ChatRowContent = ({ server={server} useMcpServer={useMcpServer} alwaysAllowMcp={alwaysAllowMcp} + images={message.images} /> )} diff --git a/webview-ui/src/components/chat/McpExecution.tsx b/webview-ui/src/components/chat/McpExecution.tsx index a96f368a17ec..71eb10dc042b 100644 --- a/webview-ui/src/components/chat/McpExecution.tsx +++ b/webview-ui/src/components/chat/McpExecution.tsx @@ -10,6 +10,7 @@ import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" import CodeBlock from "../common/CodeBlock" import McpToolRow from "../mcp/McpToolRow" +import Thumbnails from "../common/Thumbnails" import { Markdown } from "./Markdown" interface McpExecutionProps { @@ -28,6 +29,7 @@ interface McpExecutionProps { } useMcpServer?: ClineAskUseMcpServer alwaysAllowMcp?: boolean + images?: string[] } export const McpExecution = ({ @@ -39,6 +41,7 @@ export const McpExecution = ({ server, useMcpServer, alwaysAllowMcp = false, + images, }: McpExecutionProps) => { const { t } = useTranslation("mcp") @@ -212,15 +215,25 @@ export const McpExecution = ({ )} )} - {responseText && responseText.length > 0 && ( - - )} + {(responseText && responseText.length > 0) || (images && images.length > 0) ? ( +
+ {images && images.length > 0 && ( +
+ + {images.length} +
+ )} + +
+ ) : null} @@ -280,6 +293,7 @@ export const McpExecution = ({ isJson={responseIsJson} hasArguments={!!(isArguments || useMcpServer?.arguments || argumentsText)} isPartial={status ? status.status !== "completed" : false} + images={images} /> @@ -294,15 +308,17 @@ const ResponseContainerInternal = ({ isJson, hasArguments, isPartial = false, + images, }: { isExpanded: boolean response: string isJson: boolean hasArguments?: boolean isPartial?: boolean + images?: string[] }) => { // Only render content when expanded to prevent performance issues with large responses - if (!isExpanded || response.length === 0) { + if (!isExpanded || (response.length === 0 && (!images || images.length === 0))) { return (
- {isJson ? ( - - ) : ( - + {images && images.length > 0 && ( +
+ +
)} + {shouldShowText && + (isJson ? ( + + ) : ( + + ))}
) } diff --git a/webview-ui/src/components/common/Thumbnails.tsx b/webview-ui/src/components/common/Thumbnails.tsx index d0db36d5612c..24ace0c599f1 100644 --- a/webview-ui/src/components/common/Thumbnails.tsx +++ b/webview-ui/src/components/common/Thumbnails.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useLayoutEffect, memo } from "react" import { useWindowSize } from "react-use" +import { useTranslation } from "react-i18next" import { vscode } from "@src/utils/vscode" interface ThumbnailsProps { @@ -10,7 +11,9 @@ interface ThumbnailsProps { } const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => { + const { t } = useTranslation("common") const [hoveredIndex, setHoveredIndex] = useState(null) + const [failedImages, setFailedImages] = useState>(new Set()) const containerRef = useRef(null) const { width } = useWindowSize() @@ -24,12 +27,19 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp onHeightChange?.(height) } setHoveredIndex(null) + // Reset failed images when images change + setFailedImages(new Set()) }, [images, width, onHeightChange]) const handleDelete = (index: number) => { setImages?.((prevImages) => prevImages.filter((_, i) => i !== index)) } + const handleImageError = (index: number) => { + setFailedImages((prev) => new Set(prev).add(index)) + console.warn(`Failed to load image at index ${index}`) + } + const isDeletable = setImages !== undefined const handleImageClick = (image: string) => { @@ -53,18 +63,43 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp style={{ position: "relative" }} onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)}> - {`Thumbnail handleImageClick(image)} - /> + {failedImages.has(index) ? ( +
handleImageClick(image)} + title={t("thumbnails.failedToLoad")}> + +
+ ) : ( + {t("thumbnails.altText", handleImageClick(image)} + onError={() => handleImageError(index)} + /> + )} {isDeletable && hoveredIndex === index && (
handleDelete(index)} diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 21ad1c26525e..b35b0585c57d 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -7,6 +7,7 @@ import { VSCodePanels, VSCodePanelTab, VSCodePanelView, + VSCodeTextField, } from "@vscode/webview-ui-toolkit/react" import { McpServer } from "@roo/mcp" @@ -45,9 +46,15 @@ const McpView = ({ onDone }: McpViewProps) => { mcpEnabled, enableMcpServerCreation, setEnableMcpServerCreation, + mcpMaxImagesPerResponse, + setMcpMaxImagesPerResponse, + mcpMaxImageSizeMB, + setMcpMaxImageSizeMB, } = useExtensionState() const { t } = useAppTranslation() + const [maxImagesError, setMaxImagesError] = useState("") + const [maxSizeError, setMaxSizeError] = useState("") return ( @@ -107,6 +114,80 @@ const McpView = ({ onDone }: McpViewProps) => {
+
+
+ { + const value = e.target.value + if (value === "") { + setMaxImagesError("") + return + } + const numValue = parseInt(value, 10) + if (isNaN(numValue) || numValue < 1 || numValue > 100) { + setMaxImagesError(t("mcp:imageSettings.validationError")) + } else { + setMaxImagesError("") + setMcpMaxImagesPerResponse(numValue) + vscode.postMessage({ type: "mcpMaxImagesPerResponse", value: numValue }) + } + }} + style={{ width: "100px" }}> + {t("mcp:imageSettings.maxImagesLabel")} + + {maxImagesError && ( +
+ {maxImagesError} +
+ )} +
+ {t("mcp:imageSettings.maxImagesDescription")} +
+
+ +
+ { + const value = e.target.value + if (value === "") { + setMaxSizeError("") + return + } + const numValue = parseFloat(value) + if (isNaN(numValue) || numValue < 1 || numValue > 100) { + setMaxSizeError(t("mcp:imageSettings.validationError")) + } else { + setMaxSizeError("") + setMcpMaxImageSizeMB(numValue) + vscode.postMessage({ type: "mcpMaxImageSizeMB", value: numValue }) + } + }} + style={{ width: "100px" }}> + {t("mcp:imageSettings.maxSizeLabel")} + + {maxSizeError && ( +
+ {maxSizeError} +
+ )} +
+ {t("mcp:imageSettings.maxSizeDescription")} +
+
+
+ {/* Server List */} {servers.length > 0 && (
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 5534686db660..b096dc6af6f0 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -99,6 +99,10 @@ export interface ExtensionStateContextType extends ExtensionState { setMcpEnabled: (value: boolean) => void enableMcpServerCreation: boolean setEnableMcpServerCreation: (value: boolean) => void + mcpMaxImagesPerResponse: number + setMcpMaxImagesPerResponse: (value: number) => void + mcpMaxImageSizeMB: number + setMcpMaxImageSizeMB: (value: number) => void remoteControlEnabled: boolean setRemoteControlEnabled: (value: boolean) => void taskSyncEnabled: boolean @@ -204,6 +208,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalShellIntegrationTimeout: 4000, mcpEnabled: true, enableMcpServerCreation: false, + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, remoteControlEnabled: false, taskSyncEnabled: false, featureRoomoteControlEnabled: false, @@ -471,10 +477,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setEnableMcpServerCreation: (value) => setState((prevState) => ({ ...prevState, enableMcpServerCreation: value })), + setMcpMaxImagesPerResponse: (value) => + setState((prevState) => ({ ...prevState, mcpMaxImagesPerResponse: value })), + setMcpMaxImageSizeMB: (value) => setState((prevState) => ({ ...prevState, mcpMaxImageSizeMB: value })), setRemoteControlEnabled: (value) => setState((prevState) => ({ ...prevState, remoteControlEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), setFeatureRoomoteControlEnabled: (value) => - setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), + setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 33d7dc0ec7a5..0992af179e14 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -185,6 +185,8 @@ describe("mergeExtensionState", () => { version: "", mcpEnabled: false, enableMcpServerCreation: false, + mcpMaxImagesPerResponse: 20, + mcpMaxImageSizeMB: 10, clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 69f18d94115b..6422f0987346 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -95,5 +95,9 @@ "months_ago": "fa {{count}} mesos", "year_ago": "fa un any", "years_ago": "fa {{count}} anys" + }, + "thumbnails": { + "failedToLoad": "No s'ha pogut carregar la imatge", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ca/mcp.json b/webview-ui/src/i18n/locales/ca/mcp.json index f4f89440fd3c..27f1e8f3a1d2 100644 --- a/webview-ui/src/i18n/locales/ca/mcp.json +++ b/webview-ui/src/i18n/locales/ca/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "En execució", "completed": "Completat", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} imatge(s) en la resposta" + }, + "imageSettings": { + "maxImagesLabel": "Màxim d'imatges per resposta", + "maxSizeLabel": "Mida màxima de la imatge (MB)", + "maxImagesDescription": "El nombre màxim d'imatges que es poden enviar en una sola resposta.", + "maxSizeDescription": "La mida màxima de cada imatge en megabytes.", + "validationError": "Si us plau, introduïu un número entre 1 i 100." } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index b21dba3b347b..48180bdb8aef 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -95,5 +95,9 @@ "months_ago": "vor {{count}} Monaten", "year_ago": "vor einem Jahr", "years_ago": "vor {{count}} Jahren" + }, + "thumbnails": { + "failedToLoad": "Bild konnte nicht geladen werden", + "altText": "Vorschaubild {{index}}" } } diff --git a/webview-ui/src/i18n/locales/de/mcp.json b/webview-ui/src/i18n/locales/de/mcp.json index c44933dd77fa..507cff5f56f0 100644 --- a/webview-ui/src/i18n/locales/de/mcp.json +++ b/webview-ui/src/i18n/locales/de/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Wird ausgeführt", "completed": "Abgeschlossen", - "error": "Fehler" + "error": "Fehler", + "imageCountTooltip": "{{count}} Bild(er) in der Antwort" + }, + "imageSettings": { + "maxImagesLabel": "Maximale Bilder pro Antwort", + "maxSizeLabel": "Maximale Bildgröße (MB)", + "maxImagesDescription": "Die maximale Anzahl von Bildern, die in einer einzigen Antwort gesendet werden können.", + "maxSizeDescription": "Die maximale Größe jedes Bildes in Megabyte.", + "validationError": "Bitte geben Sie eine Zahl zwischen 1 und 100 ein." } } diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 2f7298826507..cfbd41c39f70 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} months ago", "year_ago": "a year ago", "years_ago": "{{count}} years ago" + }, + "thumbnails": { + "failedToLoad": "Failed to load image", + "altText": "Thumbnail {{index}}" } } diff --git a/webview-ui/src/i18n/locales/en/mcp.json b/webview-ui/src/i18n/locales/en/mcp.json index 5bc64a70dca2..ed37233f164a 100644 --- a/webview-ui/src/i18n/locales/en/mcp.json +++ b/webview-ui/src/i18n/locales/en/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Running", "completed": "Completed", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} image(s) in response" + }, + "imageSettings": { + "maxImagesLabel": "Max Images", + "maxImagesDescription": "The maximum number of images that can be included in a single request.", + "maxSizeLabel": "Max Size (MB)", + "maxSizeDescription": "The maximum file size for each image, in megabytes.", + "validationError": "Please enter a valid number between 1 and 100." } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 7e0994e81ca9..cba79ac40331 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -95,5 +95,9 @@ "months_ago": "hace {{count}} meses", "year_ago": "hace un año", "years_ago": "hace {{count}} años" + }, + "thumbnails": { + "failedToLoad": "Error al cargar la imagen", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/es/mcp.json b/webview-ui/src/i18n/locales/es/mcp.json index 5f77b5547e20..6c39ad4b2faa 100644 --- a/webview-ui/src/i18n/locales/es/mcp.json +++ b/webview-ui/src/i18n/locales/es/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Ejecutando", "completed": "Completado", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} imagen(es) en la respuesta" + }, + "imageSettings": { + "maxImagesLabel": "Máximo de imágenes por respuesta", + "maxSizeLabel": "Tamaño máximo de imagen (MB)", + "maxImagesDescription": "El número máximo de imágenes que se pueden enviar en una sola respuesta.", + "maxSizeDescription": "El tamaño máximo de cada imagen en megabytes.", + "validationError": "Por favor, introduce un número entre 1 y 100." } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index 488ec4935a5f..57cdf10ceb0c 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -95,5 +95,9 @@ "months_ago": "il y a {{count}} mois", "year_ago": "il y a un an", "years_ago": "il y a {{count}} ans" + }, + "thumbnails": { + "failedToLoad": "Échec du chargement de l'image", + "altText": "Miniature {{index}}" } } diff --git a/webview-ui/src/i18n/locales/fr/mcp.json b/webview-ui/src/i18n/locales/fr/mcp.json index 7f88c0094eea..2d6d6409e32e 100644 --- a/webview-ui/src/i18n/locales/fr/mcp.json +++ b/webview-ui/src/i18n/locales/fr/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "En cours", "completed": "Terminé", - "error": "Erreur" + "error": "Erreur", + "imageCountTooltip": "{{count}} image(s) dans la réponse" + }, + "imageSettings": { + "maxImagesLabel": "Nombre maximum d'images par réponse", + "maxSizeLabel": "Taille maximale de l'image (Mo)", + "maxImagesDescription": "Le nombre maximum d'images pouvant être envoyées en une seule réponse.", + "maxSizeDescription": "La taille maximale de chaque image en mégaoctets.", + "validationError": "Veuillez saisir un nombre entre 1 et 100." } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 00b46dbb0990..e78ab9afe040 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} महीने पहले", "year_ago": "एक साल पहले", "years_ago": "{{count}} साल पहले" + }, + "thumbnails": { + "failedToLoad": "चित्र लोड नहीं हो सका", + "altText": "थंबनेल {{index}}" } } diff --git a/webview-ui/src/i18n/locales/hi/mcp.json b/webview-ui/src/i18n/locales/hi/mcp.json index 6160c07169fa..8b2ee33e5e38 100644 --- a/webview-ui/src/i18n/locales/hi/mcp.json +++ b/webview-ui/src/i18n/locales/hi/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "चल रहा है", "completed": "पूरा हुआ", - "error": "त्रुटि" + "error": "त्रुटि", + "imageCountTooltip": "प्रतिक्रिया में {{count}} चित्र" + }, + "imageSettings": { + "maxImagesLabel": "अधिकतम छवियाँ", + "maxSizeLabel": "अधिकतम छवि आकार (एमबी)", + "maxImagesDescription": "एक ही प्रतिक्रिया में भेजी जा सकने वाली छवियों की अधिकतम संख्या।", + "maxSizeDescription": "प्रत्येक छवि का अधिकतम आकार मेगाबाइट में।", + "validationError": "कृपया 1 और 100 के बीच एक संख्या दर्ज करें।" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 697765e1c3a9..e399bfce6ead 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} bulan yang lalu", "year_ago": "satu tahun yang lalu", "years_ago": "{{count}} tahun yang lalu" + }, + "thumbnails": { + "failedToLoad": "Gagal memuat gambar", + "altText": "Thumbnail {{index}}" } } diff --git a/webview-ui/src/i18n/locales/id/mcp.json b/webview-ui/src/i18n/locales/id/mcp.json index 0786f3168f93..f0c850b8b7c3 100644 --- a/webview-ui/src/i18n/locales/id/mcp.json +++ b/webview-ui/src/i18n/locales/id/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Berjalan", "completed": "Selesai", - "error": "Error" + "error": "Error", + "imageCountTooltip": "{{count}} gambar dalam respons" + }, + "imageSettings": { + "maxImagesLabel": "Gambar Maks per Respons", + "maxSizeLabel": "Ukuran Gambar Maks (MB)", + "maxImagesDescription": "Jumlah maksimum gambar yang dapat dikirim dalam satu respons.", + "maxSizeDescription": "Ukuran maksimum setiap gambar dalam megabita.", + "validationError": "Harap masukkan angka antara 1 dan 100." } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index e7fbed4d85c7..5652f3885444 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} mesi fa", "year_ago": "un anno fa", "years_ago": "{{count}} anni fa" + }, + "thumbnails": { + "failedToLoad": "Impossibile caricare l'immagine", + "altText": "Anteprima {{index}}" } } diff --git a/webview-ui/src/i18n/locales/it/mcp.json b/webview-ui/src/i18n/locales/it/mcp.json index c35c248d9439..a669d3dad6b8 100644 --- a/webview-ui/src/i18n/locales/it/mcp.json +++ b/webview-ui/src/i18n/locales/it/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "In esecuzione", "completed": "Completato", - "error": "Errore" + "error": "Errore", + "imageCountTooltip": "{{count}} immagine/i nella risposta" + }, + "imageSettings": { + "maxImagesLabel": "Numero massimo di immagini per risposta", + "maxSizeLabel": "Dimensione massima dell'immagine (MB)", + "maxImagesDescription": "Il numero massimo di immagini che possono essere inviate in una singola risposta.", + "maxSizeDescription": "La dimensione massima di ogni immagine in megabyte.", + "validationError": "Inserisci un numero compreso tra 1 e 100." } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index 815da42952bc..6558a48f24e0 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}ヶ月前", "year_ago": "1年前", "years_ago": "{{count}}年前" + }, + "thumbnails": { + "failedToLoad": "画像の読み込みに失敗しました", + "altText": "サムネイル {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ja/mcp.json b/webview-ui/src/i18n/locales/ja/mcp.json index 7c84a184814a..dea55c5d1bd8 100644 --- a/webview-ui/src/i18n/locales/ja/mcp.json +++ b/webview-ui/src/i18n/locales/ja/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "実行中", "completed": "完了", - "error": "エラー" + "error": "エラー", + "imageCountTooltip": "レスポンスに{{count}}個の画像" + }, + "imageSettings": { + "maxImagesLabel": "応答あたりの最大画像数", + "maxSizeLabel": "最大画像サイズ (MB)", + "maxImagesDescription": "1回の応答で送信できる画像の最大数。", + "maxSizeDescription": "各画像の最大サイズ(メガバイト)。", + "validationError": "1から100までの数値を入力してください。" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index da90bf11b92e..58f3f7eb21f0 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}개월 전", "year_ago": "1년 전", "years_ago": "{{count}}년 전" + }, + "thumbnails": { + "failedToLoad": "이미지를 불러오지 못했습니다", + "altText": "썸네일 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ko/mcp.json b/webview-ui/src/i18n/locales/ko/mcp.json index d3f08e795ea9..f927b8c9d8a1 100644 --- a/webview-ui/src/i18n/locales/ko/mcp.json +++ b/webview-ui/src/i18n/locales/ko/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "실행 중", "completed": "완료됨", - "error": "오류" + "error": "오류", + "imageCountTooltip": "응답에 {{count}}개의 이미지" + }, + "imageSettings": { + "maxImagesLabel": "응답당 최대 이미지 수", + "maxSizeLabel": "최대 이미지 크기 (MB)", + "maxImagesDescription": "단일 응답으로 보낼 수 있는 최대 이미지 수입니다.", + "maxSizeDescription": "각 이미지의 최대 크기(메가바이트)입니다.", + "validationError": "1에서 100 사이의 숫자를 입력하세요." } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index 1fb09ee41a04..b0137e2e42cc 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} maanden geleden", "year_ago": "een jaar geleden", "years_ago": "{{count}} jaar geleden" + }, + "thumbnails": { + "failedToLoad": "Afbeelding laden mislukt", + "altText": "Thumbnail {{index}}" } } diff --git a/webview-ui/src/i18n/locales/nl/mcp.json b/webview-ui/src/i18n/locales/nl/mcp.json index 3222b87498dd..39ab6a3692a9 100644 --- a/webview-ui/src/i18n/locales/nl/mcp.json +++ b/webview-ui/src/i18n/locales/nl/mcp.json @@ -61,6 +61,14 @@ "execution": { "running": "Wordt uitgevoerd", "completed": "Voltooid", - "error": "Fout" + "error": "Fout", + "imageCountTooltip": "{{count}} afbeelding(en) in reactie" + }, + "imageSettings": { + "maxImagesLabel": "Max afbeeldingen per antwoord", + "maxSizeLabel": "Max afbeeldingsgrootte (MB)", + "maxImagesDescription": "Het maximale aantal afbeeldingen dat in één antwoord kan worden verzonden.", + "maxSizeDescription": "De maximale grootte van elke afbeelding in megabytes.", + "validationError": "Voer een getal in tussen 1 en 100." } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index ea6ada357de7..f906192941d1 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} miesięcy temu", "year_ago": "rok temu", "years_ago": "{{count}} lat temu" + }, + "thumbnails": { + "failedToLoad": "Nie udało się załadować obrazu", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/pl/mcp.json b/webview-ui/src/i18n/locales/pl/mcp.json index 02eccfe69f37..74ca70683b73 100644 --- a/webview-ui/src/i18n/locales/pl/mcp.json +++ b/webview-ui/src/i18n/locales/pl/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Uruchomione", "completed": "Zakończone", - "error": "Błąd" + "error": "Błąd", + "imageCountTooltip": "{{count}} obraz(ów) w odpowiedzi" + }, + "imageSettings": { + "maxImagesLabel": "Maksymalna liczba obrazów na odpowiedź", + "maxSizeLabel": "Maksymalny rozmiar obrazu (MB)", + "maxImagesDescription": "Maksymalna liczba obrazów, które można wysłać w jednej odpowiedzi.", + "maxSizeDescription": "Maksymalny rozmiar każdego obrazu w megabajtach.", + "validationError": "Wprowadź liczbę od 1 do 100." } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 1528567c9a06..d08d29be3a59 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -95,5 +95,9 @@ "months_ago": "há {{count}} meses", "year_ago": "há um ano", "years_ago": "há {{count}} anos" + }, + "thumbnails": { + "failedToLoad": "Falha ao carregar imagem", + "altText": "Miniatura {{index}}" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/mcp.json b/webview-ui/src/i18n/locales/pt-BR/mcp.json index e5018762a729..7844bf95a07e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/mcp.json +++ b/webview-ui/src/i18n/locales/pt-BR/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Em execução", "completed": "Concluído", - "error": "Erro" + "error": "Erro", + "imageCountTooltip": "{{count}} imagem(ns) na resposta" + }, + "imageSettings": { + "maxImagesLabel": "Máximo de imagens por resposta", + "maxSizeLabel": "Tamanho máximo da imagem (MB)", + "maxImagesDescription": "O número máximo de imagens que podem ser enviadas em uma única resposta.", + "maxSizeDescription": "O tamanho máximo de cada imagem em megabytes.", + "validationError": "Por favor, insira um número entre 1 e 100." } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index cd5ba42c0146..1088e4163aa2 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} месяцев назад", "year_ago": "год назад", "years_ago": "{{count}} лет назад" + }, + "thumbnails": { + "failedToLoad": "Не удалось загрузить изображение", + "altText": "Миниатюра {{index}}" } } diff --git a/webview-ui/src/i18n/locales/ru/mcp.json b/webview-ui/src/i18n/locales/ru/mcp.json index 3e7ef5f3ae85..fb38af76045d 100644 --- a/webview-ui/src/i18n/locales/ru/mcp.json +++ b/webview-ui/src/i18n/locales/ru/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Выполняется", "completed": "Завершено", - "error": "Ошибка" + "error": "Ошибка", + "imageCountTooltip": "{{count}} изображение(й) в ответе" + }, + "imageSettings": { + "maxImagesLabel": "Максимум изображений в ответе", + "maxSizeLabel": "Максимальный размер изображения (МБ)", + "maxImagesDescription": "Максимальное количество изображений, которое можно отправить в одном ответе.", + "maxSizeDescription": "Максимальный размер каждого изображения в мегабайтах.", + "validationError": "Пожалуйста, введите число от 1 до 100." } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index aa049fc35d1e..df274f636ed1 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} ay önce", "year_ago": "bir yıl önce", "years_ago": "{{count}} yıl önce" + }, + "thumbnails": { + "failedToLoad": "Resim yüklenemedi", + "altText": "Küçük resim {{index}}" } } diff --git a/webview-ui/src/i18n/locales/tr/mcp.json b/webview-ui/src/i18n/locales/tr/mcp.json index fa4717224400..401cff5a2184 100644 --- a/webview-ui/src/i18n/locales/tr/mcp.json +++ b/webview-ui/src/i18n/locales/tr/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Çalışıyor", "completed": "Tamamlandı", - "error": "Hata" + "error": "Hata", + "imageCountTooltip": "Yanıtta {{count}} resim" + }, + "imageSettings": { + "maxImagesLabel": "Yanıt Başına Maksimum Görüntü", + "maxSizeLabel": "Maksimum Görüntü Boyutu (MB)", + "maxImagesDescription": "Tek bir yanıtta gönderilebilecek maksimum görüntü sayısı.", + "maxSizeDescription": "Her görüntünün megabayt cinsinden maksimum boyutu.", + "validationError": "Lütfen 1 ile 100 arasında bir sayı girin." } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index f9fad7dbc336..c161b99a3c2d 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} tháng trước", "year_ago": "một năm trước", "years_ago": "{{count}} năm trước" + }, + "thumbnails": { + "failedToLoad": "Không thể tải hình ảnh", + "altText": "Hình thu nhỏ {{index}}" } } diff --git a/webview-ui/src/i18n/locales/vi/mcp.json b/webview-ui/src/i18n/locales/vi/mcp.json index 05009902d5c4..2797e3acd889 100644 --- a/webview-ui/src/i18n/locales/vi/mcp.json +++ b/webview-ui/src/i18n/locales/vi/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "Đang chạy", "completed": "Hoàn thành", - "error": "Lỗi" + "error": "Lỗi", + "imageCountTooltip": "{{count}} hình ảnh trong phản hồi" + }, + "imageSettings": { + "maxImagesLabel": "Số lượng hình ảnh tối đa mỗi phản hồi", + "maxSizeLabel": "Kích thước hình ảnh tối đa (MB)", + "maxImagesDescription": "Số lượng hình ảnh tối đa có thể được gửi trong một phản hồi duy nhất.", + "maxSizeDescription": "Kích thước tối đa của mỗi hình ảnh tính bằng megabyte.", + "validationError": "Vui lòng nhập một số từ 1 đến 100." } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 8b422be06068..d1b96db7b3f9 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}}个月前", "year_ago": "1年前", "years_ago": "{{count}}年前" + }, + "thumbnails": { + "failedToLoad": "加载图片失败", + "altText": "缩略图 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/mcp.json b/webview-ui/src/i18n/locales/zh-CN/mcp.json index 775250b19f04..0cb16e7a964a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/mcp.json +++ b/webview-ui/src/i18n/locales/zh-CN/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "运行中", "completed": "已完成", - "error": "错误" + "error": "错误", + "imageCountTooltip": "响应中有 {{count}} 张图片" + }, + "imageSettings": { + "maxImagesLabel": "每个响应的最大图像数", + "maxSizeLabel": "最大图像大小 (MB)", + "maxImagesDescription": "单个响应中可以发送的最大图像数。", + "maxSizeDescription": "每个图像的最大大小(以兆字节为单位)。", + "validationError": "请输入一个 1 到 100 之间的数字。" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 85e4ce53cc17..e3c0d1336cff 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -95,5 +95,9 @@ "months_ago": "{{count}} 個月前", "year_ago": "1 年前", "years_ago": "{{count}} 年前" + }, + "thumbnails": { + "failedToLoad": "載入圖片失敗", + "altText": "縮圖 {{index}}" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/mcp.json b/webview-ui/src/i18n/locales/zh-TW/mcp.json index e5fc6f66e5aa..6d0087eb6951 100644 --- a/webview-ui/src/i18n/locales/zh-TW/mcp.json +++ b/webview-ui/src/i18n/locales/zh-TW/mcp.json @@ -60,6 +60,14 @@ "execution": { "running": "執行中", "completed": "已完成", - "error": "錯誤" + "error": "錯誤", + "imageCountTooltip": "回應中有 {{count}} 張圖片" + }, + "imageSettings": { + "maxImagesLabel": "每個回應的最大圖片數", + "maxSizeLabel": "最大圖片大小 (MB)", + "maxImagesDescription": "單一回應中可傳送的圖片數量上限。", + "maxSizeDescription": "每張圖片的大小上限(以 MB 為單位)。", + "validationError": "請輸入一個 1 到 100 之間的數字。" } }