Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions web/src/components/AssistantChat/messages/MessageAttachments.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'react'
import type { AttachmentMetadata } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { isImageMimeType } from '@/lib/fileAttachments'
import { ImageLightbox } from '@/components/ImageLightbox'

function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
Expand All @@ -10,19 +12,25 @@ function formatFileSize(bytes: number): string {

function ImageAttachment(props: { attachment: AttachmentMetadata }) {
const { attachment } = props
const [open, setOpen] = useState(false)
return (
<div className="relative overflow-hidden rounded-lg">
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-h-48 max-w-full object-contain"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
<span className="text-xs text-white/90 line-clamp-1">
{attachment.filename}
</span>
<>
<div className="relative cursor-pointer overflow-hidden rounded-lg" onClick={() => setOpen(true)}>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-h-48 max-w-full object-contain transition-opacity hover:opacity-80"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
<span className="text-xs text-white/90 line-clamp-1">
{attachment.filename}
</span>
</div>
</div>
</div>
{attachment.previewUrl && (
<ImageLightbox src={attachment.previewUrl} alt={attachment.filename} open={open} onClose={() => setOpen(false)} />
)}
</>
)
}

Expand Down
100 changes: 100 additions & 0 deletions web/src/components/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useCallback, useState } from 'react'
import { createPortal } from 'react-dom'

interface ImageLightboxProps {
src: string
alt?: string
open: boolean
onClose: () => void
}

export function ImageLightbox({ src, alt, open, onClose }: ImageLightboxProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose]
)

useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])

if (!open) return null

return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
onClick={onClose}
>
{/* Top-right buttons */}
<div className="fixed right-4 top-4 z-[101] flex items-center gap-2">
<button
className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white"
title="在新标签页打开"
onClick={(e) => {
e.stopPropagation()
window.open(src, '_blank')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] src reaches this component from user-upload previews and tool-result data URLs. Opening it with the default window.open behavior keeps window.opener, so a same-origin preview or active data document can retain a handle to the HAPI app and navigate it.

Suggested fix:

window.open(src, '_blank', 'noopener,noreferrer')

}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</button>
<button
className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white"
title="关闭"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>

{/* Image */}
<img
src={src}
alt={alt ?? 'Preview'}
className="max-h-[90vh] max-w-[90vw] rounded object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body
)
}

export function useImageLightbox() {
const [lightbox, setLightbox] = useState<{ src: string; alt?: string } | null>(null)

const openLightbox = useCallback((src: string, alt?: string) => {
setLightbox({ src, alt })
}, [])

const closeLightbox = useCallback(() => {
setLightbox(null)
}, [])

const LightboxPortal = lightbox ? (
<ImageLightbox
src={lightbox.src}
alt={lightbox.alt}
open={true}
onClose={closeLightbox}
/>
) : null

return { openLightbox, LightboxPortal }
}
109 changes: 109 additions & 0 deletions web/src/components/ToolCard/views/_results.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react'
import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all'
import { isObject, safeStringify } from '@hapi/protocol'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist'
import { basename, resolveDisplayPath } from '@/utils/path'
import { getInputStringAny } from '@/lib/toolInputUtils'
import { ImageLightbox } from '@/components/ImageLightbox'

function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } {
const regex = /<tool_use_error>(.*?)<\/tool_use_error>/s
Expand All @@ -28,6 +30,66 @@ function extractTextFromContentBlock(block: unknown): string | null {
return null
}

interface ImageBlock {
mediaType: string
dataUrl: string
}

function extractImageFromContentBlock(block: unknown): ImageBlock | null {
if (!isObject(block)) return null
if (block.type !== 'image') return null
const source = isObject(block.source) ? block.source : null
if (!source) return null
if (source.type === 'base64' && typeof source.media_type === 'string' && typeof source.data === 'string') {
return { mediaType: source.media_type, dataUrl: `data:${source.media_type};base64,${source.data}` }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This trusts source.media_type when constructing a data: URL. A malformed or hostile tool result can claim type: 'image' while using a non-image or active MIME type, and that URL then flows into the lightbox/open-in-new-tab path.

Suggested fix:

const safeImageMediaTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const mediaType = source.media_type.toLowerCase()
if (source.type === 'base64' && safeImageMediaTypes.has(mediaType) && typeof source.data === 'string') {
    return { mediaType, dataUrl: `data:${mediaType};base64,${source.data}` }
}

}
return null
}

function extractImagesFromResult(result: unknown): ImageBlock[] {
if (!result) return []

if (Array.isArray(result)) {
return result.map(extractImageFromContentBlock).filter((img): img is ImageBlock => img !== null)
}

if (isObject(result)) {
const contentArray = Array.isArray(result.content) ? result.content : null
if (contentArray) {
return contentArray.map(extractImageFromContentBlock).filter((img): img is ImageBlock => img !== null)
}
}

return []
}

function InlineImage({ image }: { image: ImageBlock }) {
const [open, setOpen] = useState(false)
return (
<>
<img
src={image.dataUrl}
alt="Tool result image"
className="max-h-48 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
onClick={() => setOpen(true)}
/>
<ImageLightbox src={image.dataUrl} alt="Tool result image" open={open} onClose={() => setOpen(false)} />
</>
)
}

function ResultImages({ result }: { result: unknown }) {
const images = extractImagesFromResult(result)
if (images.length === 0) return null
return (
<div className="mt-2 flex flex-wrap gap-2">
{images.map((img, i) => (
<InlineImage key={i} image={img} />
))}
</div>
)
}

export function extractTextFromResult(result: unknown, depth: number = 0): string | null {
if (depth > 2) return null
if (result === null || result === undefined) return null
Expand Down Expand Up @@ -249,6 +311,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
{stdio.stdout ? <CodeBlock code={stdio.stdout} language="text" /> : null}
{stdio.stderr ? <CodeBlock code={stdio.stderr} language="text" /> : null}
</div>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -259,6 +322,17 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'code', language: 'text' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -284,6 +358,17 @@ const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'auto' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand Down Expand Up @@ -354,6 +439,8 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
}

const images = extractImagesFromResult(result)

const file = extractReadFileContent(result)
if (file) {
const path = file.filePath ? resolveDisplayPath(file.filePath, props.metadata) : null
Expand All @@ -365,6 +452,16 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
</div>
) : null}
<CodeBlock code={file.content} language="text" />
{images.length > 0 ? <ResultImages result={result} /> : null}
<RawJsonDevOnly value={result} />
</>
)
}

if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand Down Expand Up @@ -595,6 +692,7 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
{parsed.wallTime && `Wall time: ${parsed.wallTime}`}
</div>
{renderText(parsed.output.trim(), { mode: 'code' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -606,11 +704,22 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'auto' })}
<ResultImages result={result} />
{typeof result === 'object' ? <RawJsonDevOnly value={result} /> : null}
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

if (typeof result === 'string') {
return renderText(result, { mode: 'auto' })
}
Expand Down
Loading