diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 0664e19cc..fbc6b80a4 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1128,6 +1128,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }), success: true, result_for_assistant: None, + image_attachments: None, error: None, duration_ms: Some(outcome.duration_ms), }), @@ -1203,6 +1204,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet result: serde_json::Value::Null, success: false, result_for_assistant: None, + image_attachments: None, error: Some(error.to_string()), duration_ms: None, }), diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 2655154e0..0b17e83d3 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -427,6 +427,7 @@ mod tests { result_for_assistant: Some( "Fetched page with token=ghp_abcdefghijklmnopqrstuvwxyz".to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -499,6 +500,7 @@ mod tests { r#"{"description":"full schema definition","input_schema":{"type":"object"}}"# .to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -558,6 +560,7 @@ mod tests { result_for_assistant: Some( "external page content that should not enter memory extraction".to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -617,6 +620,7 @@ mod tests { result: json!({"content": "r".repeat(TOOL_RESULT_TOKEN_LIMIT * 5)}), success: true, result_for_assistant: None, + image_attachments: None, error: Some("e".repeat(TOOL_ERROR_TOKEN_LIMIT * 5)), duration_ms: Some(1), }), diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index c09b57023..452f2df30 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4984,6 +4984,7 @@ impl SessionManager { tool_id, result, result_for_assistant, + image_attachments, is_error, .. } = &msg.content @@ -4998,6 +4999,7 @@ impl SessionManager { result: result.clone(), success: !is_error, result_for_assistant: assistant_text, + image_attachments: image_attachments.clone(), error: if *is_error { serde_json::to_string(result).ok() } else { @@ -7474,6 +7476,7 @@ mod tests { }), success: true, result_for_assistant: Some(assistant_output.clone()), + image_attachments: None, error: None, duration_ms: Some(1), }), diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index ad1e00e26..4f5f2327b 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -197,6 +197,13 @@ impl ToolStateManager { } => result_for_assistant.clone(), _ => None, }, + image_attachments: match result { + crate::agentic::tools::framework::ToolResult::Result { + image_attachments, + .. + } => image_attachments.clone(), + _ => None, + }, duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, preflight_ms: *preflight_ms, diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index b1fe86981..ba2aeeb6e 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -2321,6 +2321,7 @@ mod tests { }), success: false, result_for_assistant: None, + image_attachments: None, error: Some("operation timed out".to_string()), duration_ms: Some(95_000), }); @@ -2783,6 +2784,7 @@ mod tests { result: serde_json::json!({}), success, result_for_assistant: None, + image_attachments: None, error: (!success).then(|| "tool failed".to_string()), duration_ms: Some(duration_ms), }), diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 788d41119..ed05c6689 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -1,5 +1,6 @@ //! Agentic Events Definition pub use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; +use bitfun_core_types::ToolImageAttachment; use serde::{Deserialize, Serialize}; use std::time::SystemTime; @@ -444,6 +445,8 @@ pub enum ToolEventData { result: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] result_for_assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + image_attachments: Option>, duration_ms: u64, #[serde(default, skip_serializing_if = "Option::is_none")] queue_wait_ms: Option, @@ -727,6 +730,7 @@ mod tests { identity: ToolEventIdentity::direct("tool-1", "write_file"), result: serde_json::json!({ "ok": true }), result_for_assistant: None, + image_attachments: None, duration_ms: 120, queue_wait_ms: Some(10), preflight_ms: Some(20), @@ -764,6 +768,29 @@ mod tests { assert_eq!(decoded.effective_tool_name(), "CreatePlan"); } + #[test] + fn completed_tool_serializes_image_attachments() { + let event = ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-image-1", "view_image"), + result: serde_json::json!({ "path": "preview.png" }), + result_for_assistant: Some("Image attached".to_string()), + image_attachments: Some(vec![bitfun_core_types::ToolImageAttachment { + mime_type: "image/png".to_string(), + data_base64: "AAAA".to_string(), + }]), + duration_ms: 12, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(12), + }; + + let json = serde_json::to_value(&event).expect("serialize tool event"); + + assert_eq!(json["image_attachments"][0]["mime_type"], "image/png"); + assert_eq!(json["image_attachments"][0]["data_base64"], "AAAA"); + } + #[test] fn failed_tool_reports_best_effort_total_duration() { let event = ToolEventData::Failed { diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index a6b9e5507..35c21f99b 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -19,6 +19,7 @@ pub use backend::{ BackgroundCommandLifecycleInfo, ToolExecutionCompletedInfo, ToolExecutionErrorInfo, ToolExecutionProgressInfo, ToolExecutionStartedInfo, ToolTerminalReadyInfo, }; +pub use bitfun_core_types::ToolImageAttachment; pub use emitter::EventEmitter; pub use frontend_projection::{project_agentic_frontend_event, AgenticFrontendEvent}; pub use types::*; diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index e74380fdd..bf255d4fc 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -1,6 +1,6 @@ //! Provider-neutral tool pipeline planning helpers. -use bitfun_events::{ToolEventData, ToolEventIdentity}; +use bitfun_events::{ToolEventData, ToolEventIdentity, ToolImageAttachment}; use dashmap::DashMap; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -111,6 +111,7 @@ pub enum ToolStateEventKind { Completed { result: serde_json::Value, result_for_assistant: Option, + image_attachments: Option>, duration_ms: u64, queue_wait_ms: Option, preflight_ms: Option, @@ -305,6 +306,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { ToolStateEventKind::Completed { result, result_for_assistant, + image_attachments, duration_ms, queue_wait_ms, preflight_ms, @@ -314,6 +316,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { identity, result: sanitize_tool_result_for_event(&result), result_for_assistant, + image_attachments, duration_ms, queue_wait_ms, preflight_ms, @@ -383,6 +386,36 @@ mod tests { use bitfun_events::{ToolEventData, ToolEventIdentity}; use serde_json::json; + #[test] + fn completed_event_preserves_image_attachments() { + let data = tool_state_event_data(ToolStateEventFacts { + identity: ToolEventIdentity::direct("tool-image-1", "view_image"), + state: ToolStateEventKind::Completed { + result: json!({ "path": "preview.png" }), + result_for_assistant: Some("Image attached".to_string()), + image_attachments: Some(vec![bitfun_events::ToolImageAttachment { + mime_type: "image/png".to_string(), + data_base64: "AAAA".to_string(), + }]), + duration_ms: 10, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(10), + }, + }); + + let ToolEventData::Completed { + image_attachments, .. + } = data + else { + panic!("expected completed event"); + }; + let attachments = image_attachments.expect("image attachments"); + assert_eq!(attachments[0].mime_type, "image/png"); + assert_eq!(attachments[0].data_base64, "AAAA"); + } + #[test] fn completed_event_redacts_data_urls_recursively() { let data = tool_state_event_data(ToolStateEventFacts { @@ -393,6 +426,7 @@ mod tests { "nested": [{ "data_url": "data:image/png;base64,BBBB" }] }), result_for_assistant: Some("done".to_string()), + image_attachments: None, duration_ms: 10, queue_wait_ms: Some(1), preflight_ms: None, diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index 054573c08..a1eda1d93 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -261,6 +261,7 @@ fn acp_tool_call_events( Some(tool_call.locations), ), result_for_assistant: None, + image_attachments: None, duration_ms: 0, queue_wait_ms: None, preflight_ms: None, @@ -320,6 +321,7 @@ fn acp_tool_call_update_events( update.fields.locations, ), result_for_assistant: None, + image_attachments: None, duration_ms: 0, queue_wait_ms: None, preflight_ms: None, diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index bab463a2f..35bab0070 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -566,6 +566,7 @@ mod tests { identity: identity("Bash"), result: serde_json::json!({ "stdout": "ok" }), result_for_assistant: Some("done".to_string()), + image_attachments: None, duration_ms: 42, queue_wait_ms: None, preflight_ms: None, @@ -880,6 +881,7 @@ mod tests { "success": true, }), result_for_assistant: None, + image_attachments: None, duration_ms: 15, queue_wait_ms: None, preflight_ms: None, diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 4289b3f7b..1f1157233 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,6 +1,6 @@ //! Types for session persistence -use bitfun_core_types::SessionKind; +use bitfun_core_types::{SessionKind, ToolImageAttachment}; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; @@ -759,6 +759,12 @@ pub struct ToolResultData { alias = "result_for_assistant" )] pub result_for_assistant: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "image_attachments" + )] + pub image_attachments: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none", alias = "duration_ms")] diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 66b2d0c27..5c026e877 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -309,6 +309,10 @@ export type RejectedToolEvent = BaseToolEvent<'Rejected'>; export interface CompletedToolEvent extends BaseToolEvent<'Completed'> { result: unknown; result_for_assistant?: string; + image_attachments?: Array<{ + mime_type: string; + data_base64: string; + }>; duration_ms: number; queue_wait_ms?: number; preflight_ms?: number; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts index aefb44211..e792aa9b5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts @@ -566,6 +566,54 @@ describe('processToolEvent rejected event behavior', () => { }); }); +describe('processToolEvent completed image behavior', () => { + afterEach(() => { + resetStore(); + }); + + it('preserves image attachments on the completed tool item', () => { + const tool: FlowToolItem = { + id: 'tool-image-1', + type: 'tool', + toolName: 'view_image', + timestamp: 1001, + status: 'running', + toolCall: { + id: 'tool-image-1', + input: { path: 'screenshots/preview.png' }, + }, + }; + + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', createSessionWithTool(tool)]]), + activeSessionId: 'session-1', + })); + + processToolEvent( + makeToolContext(), + 'session-1', + 'turn-1', + 'round-1', + { + event_type: 'Completed', + tool_id: 'tool-image-1', + tool_name: 'view_image', + result: { path: 'screenshots/preview.png', width: 80, height: 60 }, + image_attachments: [{ mime_type: 'image/png', data_base64: 'AAAA' }], + duration_ms: 12, + }, + ); + + const updatedTool = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-image-1') as FlowToolItem; + + expect(updatedTool.status).toBe('completed'); + expect(updatedTool.toolResult?.imageAttachments).toEqual([ + { mime_type: 'image/png', data_base64: 'AAAA' }, + ]); + }); +}); + describe('processToolEvent AskUserQuestion retry superseded handling', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 5a0859a73..4d57ce5f5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -539,6 +539,7 @@ function handleCompleted( result: toolEvent.result, success: true, resultForAssistant: toolEvent.result_for_assistant, + imageAttachments: toolEvent.image_attachments, duration_ms: toolEvent.duration_ms }, status: 'completed' as const, 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 000000000..9e57bac26 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss @@ -0,0 +1,72 @@ +.view-image-tool-card__content { + display: flex; + justify-content: flex-start; + min-width: 0; + margin: 8px 0 0 28px; +} + +.view-image-tool-card__preview-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 160px; + max-width: calc(100vw - 76px); + aspect-ratio: 1; + padding: 0; + overflow: hidden; + appearance: none; + background: var(--color-bg-secondary); + border: 1px solid var(--border-base); + border-radius: 8px; + cursor: zoom-in; + + &:focus-visible { + outline: 2px solid var(--color-accent-600); + outline-offset: 2px; + } + + img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.view-image-tool-card__error { + width: 100%; + padding: 12px; + color: var(--color-error); + font-size: var(--flowchat-font-size-base); + text-align: center; +} + +.view-image-tool-card__lightbox { + display: flex; + align-items: center; + justify-content: center; + min-height: 240px; + max-height: 80vh; + overflow: auto; + + img { + display: block; + max-width: 100%; + max-height: 76vh; + object-fit: contain; + } +} + +@media (max-width: 640px) { + .view-image-tool-card__content { + margin-left: 24px; + } + + .view-image-tool-card__preview-button { + width: 144px; + } + + .view-image-tool-card__lightbox { + min-height: 160px; + } +} 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 000000000..dc1e731a4 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ToolCardProps } from '../types/flow-chat'; +import { ViewImageToolCard } from './ViewImageToolCard'; + +vi.mock('@/infrastructure/i18n', async () => { + const { createTestI18nT } = await import('@/test/i18nTestUtils'); + return { + useI18n: () => ({ t: createTestI18nT('flow-chat') }), + }; +}); + +vi.mock('@/component-library', () => ({ + Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => ( + isOpen ?
{children}
: null + ), +})); + +function makeProps(mimeType = 'image/png'): ToolCardProps { + return { + toolItem: { + id: 'tool-image-1', + type: 'tool', + toolName: 'view_image', + timestamp: 1, + status: 'completed', + toolCall: { + id: 'tool-image-1', + input: { path: 'screenshots/preview.png' }, + }, + toolResult: { + success: true, + result: { + path: '/workspace/screenshots/preview.png', + width: 899, + height: 949, + mime_type: 'image/png', + }, + imageAttachments: [{ + mime_type: mimeType, + data_base64: 'AAAA', + }], + }, + }, + config: { + toolName: 'view_image', + displayName: 'View Image', + requiresConfirmation: false, + resultDisplayType: 'detailed', + displayMode: 'compact', + }, + }; +} + +describe('ViewImageToolCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders a completed image attachment inline with stable dimensions', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('data:image/png;base64,AAAA'); + expect(html).toContain('width="899"'); + expect(html).toContain('height="949"'); + expect(html).toContain('Viewed 1 image'); + expect(html).not.toContain('toolCards.viewImage.viewedImages'); + expect(html).toContain('view-image-tool-card__preview-button'); + }); + + it('does not render an unsupported attachment type', () => { + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data:image/svg+xml'); + expect(html).not.toContain('; + return { + path: typeof value.path === 'string' && value.path.trim() ? value.path : null, + width: typeof value.width === 'number' && value.width > 0 ? value.width : null, + height: typeof value.height === 'number' && value.height > 0 ? value.height : null, + }; +} + +function imageSource(toolItem: ToolCardProps['toolItem']): string | null { + const attachment = toolItem.toolResult?.imageAttachments?.[0]; + if (!attachment) return null; + + const mimeType = attachment.mime_type?.toLowerCase(); + if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType) || !attachment.data_base64) return null; + + return `data:${mimeType};base64,${attachment.data_base64}`; +} + +function fileName(path: string | null): string { + if (!path) return 'view_image'; + return path.split(/[\\/]/).filter(Boolean).pop() ?? path; +} + +export const ViewImageToolCard: React.FC = ({ toolItem, onExpand }) => { + const { t } = useI18n('flow-chat'); + const result = useMemo(() => parseResult(toolItem.toolResult?.result), [toolItem.toolResult?.result]); + const source = useMemo(() => imageSource(toolItem), [toolItem]); + const [isExpanded, setIsExpanded] = useState(Boolean(source)); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + const [imageFailed, setImageFailed] = useState(false); + const didAutoExpand = useRef(Boolean(source)); + const toolId = toolItem.id ?? toolItem.toolCall?.id; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId, + toolName: toolItem.toolName, + }); + + useEffect(() => { + if (!source || didAutoExpand.current) return; + didAutoExpand.current = true; + applyExpandedState(isExpanded, true, setIsExpanded, { reason: 'auto' }); + }, [applyExpandedState, isExpanded, source]); + + useEffect(() => { + setImageFailed(false); + }, [source]); + + const handleToggle = () => { + if (!source) return; + applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand }); + }; + + const path = result.path + ?? (typeof toolItem.toolCall?.input?.path === 'string' ? toolItem.toolCall.input.path : null); + const title = fileName(path); + const imageCount = toolItem.toolResult?.imageAttachments?.length ?? 1; + const viewedImagesText = t('toolCards.viewImage.viewedImages', { count: imageCount }); + const viewingText = t('toolCards.viewImage.viewing'); + const statusText = toolItem.status === 'error' + ? toolItem.toolResult?.error ?? t('toolCards.default.failed') + : toolItem.status === 'completed' + ? viewedImagesText === 'toolCards.viewImage.viewedImages' + ? t('toolCards.default.completed') + : viewedImagesText + : viewingText === 'toolCards.viewImage.viewing' + ? t('toolCards.default.executing') + : viewingText; + + return ( +
+ } + /> + )} + action={statusText} + rightStatusIcon={source ? (isExpanded ? : ) : undefined} + /> + )} + /> + + + {source ? ( +
+ {imageFailed ? ( +
+ {t('toolCards.default.failed')} +
+ ) : ( + + )} +
+ ) : null} +
+ + setIsLightboxOpen(false)} + title={title} + size="large" + > +
+ {title} +
+
+
+ ); +}; 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 716a8d2df..71f59a8c6 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 = { @@ -122,6 +123,9 @@ export const TOOL_CARD_COMPONENTS = { // Computer use (desktop automation) 'ComputerUse': ComputerUseToolCard, + // Model vision image preview + 'view_image': ViewImageToolCard, + // BitFun Canvas tools 'CreateCanvas': CanvasToolCard, 'ReadCanvas': CanvasToolCard, diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 7ed800991..0693f0342 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -343,6 +343,17 @@ export const TOOL_CARD_CONFIGS: Record = { primaryColor: 'var(--color-accent-600)' }, + 'view_image': { + toolName: 'view_image', + displayName: 'view_image', + icon: 'IMG', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: '', + displayMode: 'compact', + primaryColor: 'var(--color-accent-600)' + }, + // BitFun Canvas tools 'CreateCanvas': { toolName: 'CreateCanvas', diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index ecb83c9ed..2971ac2c0 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -64,6 +64,10 @@ export interface FlowToolItem extends FlowItem { result: any; success: boolean; resultForAssistant?: string; + imageAttachments?: Array<{ + mime_type: string; + data_base64: string; + }>; error?: string; duration_ms?: number; }; 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 e98a841fd..c9b37d835 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "Open System Settings", "openSettingsFailed": "Could not open System Settings" }, + "viewImage": { + "viewing": "Viewing image...", + "viewedImages": "Viewed {{count}} image" + }, "webFetch": { "parsingUrl": "Parsing URL...", "readLabel": "Read Webpage:", 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 6a88d354f..5b95e9035 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "打开系统设置", "openSettingsFailed": "无法打开系统设置" }, + "viewImage": { + "viewing": "正在查看图像...", + "viewedImages": "已查看 {{count}} 张图像" + }, "webFetch": { "parsingUrl": "解析链接中...", "readLabel": "读取网页:", 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 33ca5a8a1..016c22aab 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "打開系統設定", "openSettingsFailed": "無法打開系統設定" }, + "viewImage": { + "viewing": "正在查看圖像...", + "viewedImages": "已查看 {{count}} 張圖像" + }, "webFetch": { "parsingUrl": "解析連結中...", "readLabel": "讀取網頁:", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 7a9a1eafc..357685696 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -270,6 +270,10 @@ export interface ToolResultData { result: any; success: boolean; resultForAssistant?: string; + imageAttachments?: Array<{ + mime_type: string; + data_base64: string; + }>; error?: string; durationMs?: number; }