diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss new file mode 100644 index 0000000000..d1f294bbd0 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss @@ -0,0 +1,87 @@ +.view-image-tool-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + + .view-image-tool-card__inline-preview-row { + width: 100%; + padding: 0 2px 2px; + } + + .view-image-tool-card__inline-preview { + position: relative; + width: 100%; + min-height: 120px; + max-height: 180px; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-base); + background: var(--element-bg-soft); + display: flex; + align-items: center; + justify-content: center; + + img { + display: block; + width: 100%; + max-height: 180px; + object-fit: contain; + } + } + + .view-image-tool-card__open-overlay { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-primary) 88%, transparent); + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.2; + cursor: pointer; + backdrop-filter: blur(6px); + box-shadow: 0 2px 8px rgb(0 0 0 / 12%); + + &:hover { + background: var(--color-bg-primary); + border-color: var(--color-accent-500); + color: var(--color-accent-600); + } + } + + .view-image-tool-card__inline-preview--loading, + .view-image-tool-card__inline-preview--placeholder, + .view-image-tool-card__inline-preview--error { + flex-direction: column; + gap: 8px; + color: var(--color-text-secondary); + font-size: 12px; + padding: 16px; + text-align: center; + } + + .view-image-tool-card__inline-preview--error { + color: var(--color-error); + } + + .view-image-tool-card__spinner { + animation: view-image-tool-card-spin 1s linear infinite; + } +} + +@keyframes view-image-tool-card-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx new file mode 100644 index 0000000000..2717223ea9 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import { ViewImageToolCard } from './ViewImageToolCard'; +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const messages: Record = { + 'toolCards.viewImage.loading': 'Loading image...', + 'toolCards.viewImage.loadFailed': 'Failed to load image', + 'toolCards.viewImage.runtimeUriUnsupported': 'Preview is not available for this runtime artifact path', + 'copyOutput.openInEditor': 'Open in editor', +}; + +const { readFileContent } = vi.hoisted(() => ({ + readFileContent: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + workspaceAPI: { + readFileContent, + }, +})); + +vi.mock('./DefaultToolCard', () => ({ + DefaultToolCard: () =>
default-card
, +})); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => messages[key] ?? key, + }), + }; +}); + +vi.mock('../../component-library', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const config: ToolCardConfig = { + toolName: 'view_image', + displayName: 'View Image', + icon: 'IMG', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Attach an image file for model vision', + displayMode: 'standard', + primaryColor: 'var(--color-accent-600)', +}; + +function buildToolItem(overrides: Partial = {}): FlowToolItem { + return { + id: 'tool-1', + type: 'tool', + toolName: 'view_image', + status: 'completed', + timestamp: Date.now(), + toolCall: { + id: 'tool-1', + input: { path: '/workspace/screenshots/pixel.png' }, + }, + toolResult: { + success: true, + result: { + path: '/workspace/screenshots/pixel.png', + mime_type: 'image/png', + width: 1, + height: 1, + size: 128, + summary: 'Attached image: /workspace/screenshots/pixel.png', + }, + }, + ...overrides, + }; +} + +describe('ViewImageToolCard', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + readFileContent.mockReset(); + readFileContent.mockResolvedValue('aGVsbG8='); + + dom = new JSDOM('
', { + pretendToBeVisual: true, + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('CustomEvent', dom.window.CustomEvent); + + container = dom.window.document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + vi.unstubAllGlobals(); + }); + + it('renders the default tool card header and requests image bytes', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('[data-testid="default-tool-card"]')).not.toBeNull(); + expect(readFileContent).toHaveBeenCalledWith('/workspace/screenshots/pixel.png'); + expect(container.querySelector('.view-image-tool-card__inline-preview-row')).not.toBeNull(); + }); + + it('shows an open-in-editor overlay when a handler is provided', () => { + const onOpenInEditor = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + const overlay = container.querySelector('.view-image-tool-card__open-overlay'); + expect(overlay).not.toBeNull(); + expect(overlay?.textContent).toContain('Open in editor'); + + act(() => { + overlay?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(onOpenInEditor).toHaveBeenCalledWith('/workspace/screenshots/pixel.png'); + }); + + it('shows a runtime-uri error without calling readFileContent', async () => { + act(() => { + root.render( + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(readFileContent).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Preview is not available for this runtime artifact path'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx new file mode 100644 index 0000000000..5ab2d2f8eb --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx @@ -0,0 +1,199 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ExternalLink, ImageIcon, Loader } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Tooltip } from '@/component-library'; +import { workspaceAPI } from '@/infrastructure/api'; +import { basenamePath, isBitFunRuntimeUri } from '@/shared/utils/pathUtils'; +import type { ToolCardProps } from '../types/flow-chat'; +import { DefaultToolCard } from './DefaultToolCard'; +import './ViewImageToolCard.scss'; + +function mimeTypeFromPath(path: string): string { + const ext = path.toLowerCase().split('.').pop(); + const mimeTypes: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + bmp: 'image/bmp', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + avif: 'image/avif', + }; + return mimeTypes[ext || ''] || 'image/jpeg'; +} + +function resolveImagePath(toolItem: ToolCardProps['toolItem']): string { + const inputPath = typeof toolItem.toolCall?.input?.path === 'string' + ? toolItem.toolCall.input.path.trim() + : ''; + + const rawResult = toolItem.toolResult?.result; + let result: Record | null = null; + if (typeof rawResult === 'string' && rawResult.trim().length > 0) { + try { + result = JSON.parse(rawResult) as Record; + } catch { + result = null; + } + } else if (rawResult && typeof rawResult === 'object') { + result = rawResult as Record; + } + + if (typeof result?.path === 'string' && result.path.trim().length > 0) { + return result.path.trim(); + } + + return inputPath; +} + +export const ViewImageToolCard: React.FC = ({ + toolItem, + config, + onOpenInEditor, + onExpand, + onOpenInPanel, + sessionId, + displayContext, + interruptionNote, +}) => { + const { t } = useTranslation('flow-chat'); + const { status } = toolItem; + const [imageUrl, setImageUrl] = useState(null); + const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const [loadError, setLoadError] = useState(null); + + const imagePath = useMemo(() => resolveImagePath(toolItem), [toolItem]); + const fileName = useMemo(() => basenamePath(imagePath) || imagePath, [imagePath]); + const mimeType = imagePath ? mimeTypeFromPath(imagePath) : 'image/jpeg'; + const showInlinePreview = status === 'completed' && Boolean(imagePath); + const canOpenInEditor = showInlinePreview && Boolean(onOpenInEditor) && !isBitFunRuntimeUri(imagePath); + + useEffect(() => { + if (status !== 'completed' || !imagePath) { + setImageUrl(null); + setLoadState('idle'); + setLoadError(null); + return; + } + + if (isBitFunRuntimeUri(imagePath)) { + setImageUrl(null); + setLoadState('error'); + setLoadError(t('toolCards.viewImage.runtimeUriUnsupported')); + return; + } + + let cancelled = false; + + const loadImage = async () => { + setLoadState('loading'); + setLoadError(null); + setImageUrl(null); + + try { + const base64Content = await workspaceAPI.readFileContent(imagePath); + if (cancelled) return; + + setImageUrl(`data:${mimeType};base64,${base64Content}`); + setLoadState('loaded'); + } catch (error) { + if (cancelled) return; + setLoadState('error'); + setLoadError( + error instanceof Error + ? error.message + : t('toolCards.viewImage.loadFailed'), + ); + } + }; + + void loadImage(); + + return () => { + cancelled = true; + }; + }, [imagePath, mimeType, status, t]); + + const handleOpenInEditor = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + if (imagePath && onOpenInEditor) { + onOpenInEditor(imagePath); + } + }, [imagePath, onOpenInEditor]); + + const renderInlinePreview = () => { + const openOverlay = canOpenInEditor && loadState !== 'error' ? ( + + + + ) : null; + + if (loadState === 'loading') { + return ( +
+ {openOverlay} + + {t('toolCards.viewImage.loading')} +
+ ); + } + + if (loadState === 'error') { + return ( +
+ {loadError || t('toolCards.viewImage.loadFailed')} +
+ ); + } + + if (imageUrl) { + return ( +
+ {openOverlay} + {fileName} +
+ ); + } + + return ( +
+ {openOverlay} + +
+ ); + }; + + return ( +
+ + + {showInlinePreview && ( +
+ {renderInlinePreview()} +
+ )} +
+ ); +}; + +export default ViewImageToolCard; diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index e17c7301a3..cb9101fc46 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -52,6 +52,7 @@ import { ReviewSessionSummaryCard } from './ReviewSessionSummaryCard'; import { SessionControlToolCard } from './SessionControlToolCard'; import { SessionMessageToolCard } from './SessionMessageToolCard'; import { ComputerUseToolCard } from './ComputerUseToolCard'; +import { ViewImageToolCard } from './ViewImageToolCard'; // Tool card component map - uses backend tool names export const TOOL_CARD_COMPONENTS = { @@ -121,6 +122,9 @@ export const TOOL_CARD_COMPONENTS = { // Computer use (desktop automation) 'ComputerUse': ComputerUseToolCard, + // Multimodal image viewing + 'view_image': ViewImageToolCard, + // BitFun Canvas tools 'CreateCanvas': CanvasToolCard, 'ReadCanvas': CanvasToolCard, diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 23575c477d..71e04910bb 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1642,6 +1642,11 @@ "readingFile": "Reading", "preparingRead": "Preparing to read" }, + "viewImage": { + "loading": "Loading image...", + "loadFailed": "Failed to load image", + "runtimeUriUnsupported": "Preview is not available for this runtime artifact path" + }, "terminalControl": { "terminatingSession": "Terminating terminal", "sessionKilled": "Terminal killed", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index f9991a2b6b..5a4b25c3ed 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1642,6 +1642,11 @@ "readingFile": "正在读取", "preparingRead": "准备读取" }, + "viewImage": { + "loading": "正在加载图片...", + "loadFailed": "图片加载失败", + "runtimeUriUnsupported": "暂不支持预览此运行时产物路径" + }, "terminalControl": { "terminatingSession": "正在终止终端", "sessionKilled": "终端已终止", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 9378bad63b..0dc695fee5 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1642,6 +1642,11 @@ "readingFile": "正在讀取", "preparingRead": "準備讀取" }, + "viewImage": { + "loading": "正在載入圖片...", + "loadFailed": "圖片載入失敗", + "runtimeUriUnsupported": "暫不支援預覽此執行階段產物路徑" + }, "terminalControl": { "terminatingSession": "正在終止終端", "sessionKilled": "終端已終止",