Files
hapi/web/src/components/AssistantChat/messages/ToolMessage.tsx
T
wushenghua 587705dedb fix(web): bound loaded generated-media memory with a small LRU
Loaded video/audio/file blobs stayed in memory for the whole session
(object URLs were only revoked on unmount), so opening several tens-of-MB
videos in one chat grew memory without bound. Explicitly loaded media now
goes through a shared cache that keeps at most three entries; the least
recently used object URL is revoked on overflow and its card falls back to
the Load button (a sticky eviction flag prevents a slow load from
re-publishing an already-revoked URL). Auto-loaded images keep their
existing per-card lifetime.
2026-10-01 00:13:38 +08:00

477 lines
21 KiB
TypeScript

import { useEffect, useRef, useState, type CSSProperties } from 'react'
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ChatBlock } from '@/chat/types'
import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types'
import type { ToolGroupBlock } from '@/chat/toolGroups'
import { isObject, safeStringify } from '@hapi/protocol'
import { isSubagentToolName } from '@/chat/subagentTool'
import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard'
import { getEventPresentation } from '@/chat/presentation'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator'
import { ToolCard } from '@/components/ToolCard/ToolCard'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import { CliOutputBlock } from '@/components/CliOutputBlock'
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
import { ImagePreview } from '@/components/ImagePreview'
import { FileIcon } from '@/components/FileIcon'
import { useTranslation } from '@/lib/use-translation'
import { inlineMediaLabelKey, isInlineAudioMimeType, isInlineImageMimeType, isInlineVideoMimeType } from '@/lib/generatedInlineMedia'
import { loadGeneratedMediaObjectUrl, subscribeGeneratedMediaEviction } from '@/lib/generatedMediaCache'
function isToolCallBlock(value: unknown): value is ToolCallBlock {
if (!isObject(value)) return false
if (value.kind !== 'tool-call') return false
if (typeof value.id !== 'string') return false
if (value.localId !== null && typeof value.localId !== 'string') return false
if (typeof value.createdAt !== 'number') return false
if (!Array.isArray(value.children)) return false
if (!isObject(value.tool)) return false
if (typeof value.tool.name !== 'string') return false
if (!('input' in value.tool)) return false
if (value.tool.description !== null && typeof value.tool.description !== 'string') return false
if (value.tool.state !== 'pending' && value.tool.state !== 'running' && value.tool.state !== 'completed' && value.tool.state !== 'error') return false
return true
}
function isToolGroupBlock(value: unknown): value is ToolGroupBlock {
if (!isObject(value)) return false
if (value.kind !== 'tool-group') return false
if (typeof value.id !== 'string') return false
if (!Array.isArray(value.tools)) return false
return true
}
function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock {
if (!isObject(value)) return false
if (value.kind !== 'generated-image') return false
if (typeof value.id !== 'string') return false
if (typeof value.imageId !== 'string') return false
if (typeof value.fileName !== 'string') return false
if (value.mimeType !== null && typeof value.mimeType !== 'string') return false
return true
}
const MIN_INLINE_IMAGE_DIMENSION = 64
/** Scale tiny icons up for readability without exploding skinny/tall images. */
export function computeTinyImageScale(width: number, height: number): number {
const maxDim = Math.max(width, height)
if (width <= 0 || height <= 0 || maxDim >= MIN_INLINE_IMAGE_DIMENSION) {
return 1
}
return Math.min(MIN_INLINE_IMAGE_DIMENSION / maxDim, 16)
}
/** Exported for generated-media fetch and renderer tests. */
export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
const ctx = useHappyChatContext()
const { t } = useTranslation()
const [objectUrl, setObjectUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [imageStyle, setImageStyle] = useState<CSSProperties | undefined>(undefined)
const [loadMedia, setLoadMedia] = useState(false)
const objectUrlRef = useRef<string | null>(null)
const evictedRef = useRef(false)
const isVideo = isInlineVideoMimeType(props.block.mimeType)
const isAudio = isInlineAudioMimeType(props.block.mimeType)
const isImage = isInlineImageMimeType(props.block.mimeType)
const isFile = !isVideo && !isAudio && !isImage
const mediaLabel = t(inlineMediaLabelKey(props.block.mimeType))
const mediaHeader = t('media.displayed.header', { label: mediaLabel, fileName: props.block.fileName })
// Non-image media can be tens of MB; wait for explicit user intent before downloading.
const shouldFetch = isImage || loadMedia
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [])
const mediaKey = `${ctx.sessionId}:${props.block.imageId}`
useEffect(() => {
if (!shouldFetch) {
return
}
let disposed = false
if (!isImage) {
// Explicitly loaded media lives in a bounded shared cache: the cap
// keeps at most a few tens-of-MB blobs alive per session, and the
// listener brings this card back to its Load button when evicted.
setError(null)
evictedRef.current = false
const unsubscribe = subscribeGeneratedMediaEviction(mediaKey, () => {
evictedRef.current = true
setObjectUrl(null)
setLoadMedia(false)
})
void loadGeneratedMediaObjectUrl(mediaKey, () => ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId))
.then((url) => {
if (!disposed && !evictedRef.current) {
setObjectUrl(url)
}
})
.catch((err: unknown) => {
if (!disposed) {
setError(err instanceof Error ? err.message : 'Failed to load inline media')
}
})
return () => {
disposed = true
unsubscribe()
}
}
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
setObjectUrl(null)
setImageStyle(undefined)
setError(null)
void ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId)
.then((blob) => {
if (disposed) return
const nextObjectUrl = URL.createObjectURL(blob)
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
}
objectUrlRef.current = nextObjectUrl
setObjectUrl(nextObjectUrl)
if (isImage) {
setImageStyle(undefined)
const probe = new Image()
probe.onload = () => {
if (disposed) return
const scale = computeTinyImageScale(probe.naturalWidth, probe.naturalHeight)
setImageStyle(scale === 1 ? undefined : { transform: `scale(${scale})` })
}
probe.src = nextObjectUrl
}
})
.catch((err: unknown) => {
if (disposed) return
setError(err instanceof Error ? err.message : 'Failed to load inline media')
})
return () => {
disposed = true
}
}, [ctx.api, ctx.sessionId, props.block.imageId, isImage, shouldFetch, mediaKey])
return (
<div className="max-w-[92%] rounded-2xl border border-[var(--app-border)] bg-[var(--app-tool-card-bg)] p-3">
<div className="mb-2 min-w-0 truncate text-xs font-medium text-[var(--app-hint)]">
{mediaHeader}
</div>
{objectUrl ? (
isVideo ? (
<div className="flex min-h-32 min-w-[12rem] items-center justify-center rounded-xl bg-[var(--app-subtle-bg)]">
<video
src={objectUrl}
controls
playsInline
className="max-h-[min(28rem,60vh)] max-w-full rounded-xl"
/>
</div>
) : isAudio ? (
<audio
src={objectUrl}
controls
preload="metadata"
className="w-full min-w-[12rem]"
/>
) : isFile ? (
<a
href={objectUrl}
download={props.block.fileName}
className="flex items-center gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-4 py-3 text-sm font-medium text-[var(--app-fg)]"
>
<FileIcon fileName={props.block.fileName} size={24} />
<span className="min-w-0 truncate">Download {props.block.fileName}</span>
</a>
) : (
<div className="flex min-h-32 min-w-[12rem] items-center justify-center rounded-xl bg-[var(--app-subtle-bg)]">
<ImagePreview
src={objectUrl}
fileName={props.block.fileName}
label={props.block.fileName}
buttonClassName="block max-h-[min(28rem,60vh)] max-w-full cursor-zoom-in rounded-xl text-left"
imageClassName="max-h-[min(28rem,60vh)] max-w-full rounded-xl object-contain"
imageStyle={imageStyle}
/>
</div>
)
) : error ? (
<div className="text-sm text-[var(--app-hint)]">
{t('media.displayed.unavailable', { label: mediaLabel, error })}
</div>
) : !isImage && !loadMedia ? (
<button
type="button"
onClick={() => setLoadMedia(true)}
className="flex h-48 w-72 max-w-full items-center justify-center rounded-xl border border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-sm font-medium text-[var(--app-fg)]"
>
{isVideo ? 'Load video' : isAudio ? 'Load audio' : 'Prepare download'}
</button>
) : (
<div className="h-48 w-72 max-w-full animate-pulse rounded-xl bg-[var(--app-subtle-bg)]" />
)}
</div>
)
}
function isPendingPermissionBlock(block: ChatBlock): boolean {
return block.kind === 'tool-call' && block.tool.permission?.status === 'pending'
}
function splitTaskChildren(block: ToolCallBlock): { pending: ChatBlock[]; rest: ChatBlock[] } {
const pending: ChatBlock[] = []
const rest: ChatBlock[] = []
for (const child of block.children) {
if (isPendingPermissionBlock(child)) {
pending.push(child)
} else {
rest.push(child)
}
}
return { pending, rest }
}
function HappyNestedBlockList(props: {
blocks: ChatBlock[]
}) {
const ctx = useHappyChatContext()
return (
<div className="flex flex-col gap-3">
{props.blocks.map((block) => {
if (block.kind === 'user-text') {
const status = block.status
const canRetry = status === 'failed' && typeof block.localId === 'string' && Boolean(ctx.onRetryMessage)
const onRetry = canRetry ? () => ctx.onRetryMessage!(block.localId!) : undefined
const showStatus = shouldShowMessageStatus(status)
return (
<div key={`user:${block.id}`} className={getUserBubbleClassName(status)}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<UserBubbleContent text={block.text} />
</div>
{showStatus ? (
<div className="happy-message-actions-first-line shrink-0">
<MessageStatusIndicator status={status} onRetry={onRetry} />
</div>
) : null}
</div>
</div>
)
}
if (block.kind === 'agent-text') {
return (
<div key={`agent:${block.id}`} className="px-1">
<MarkdownRenderer content={block.text} />
</div>
)
}
if (block.kind === 'cli-output') {
const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : ''
return (
<div key={`cli:${block.id}`} className="px-1 min-w-0 max-w-full overflow-x-hidden">
<div className={alignClass}>
<CliOutputBlock text={block.text} />
</div>
</div>
)
}
if (block.kind === 'generated-image') {
return (
<div key={`generated-image:${block.id}`} className="px-1">
<GeneratedImageCard block={block} />
</div>
)
}
if (block.kind === 'agent-event') {
const presentation = getEventPresentation(block.event)
return (
<div key={`event:${block.id}`} className="py-1">
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
<span className="inline-flex items-center gap-1">
{presentation.icon ? <span aria-hidden="true">{presentation.icon}</span> : null}
<span>{presentation.text}</span>
</span>
</div>
</div>
)
}
if (block.kind === 'tool-call') {
const isTask = isSubagentToolName(block.tool.name)
const hideChildren = block.tool.name === 'CodexAgent'
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
<div key={`tool:${block.id}`} data-hapi-share-exclude="true" className="py-1">
<ToolCard
api={ctx.api}
sessionId={ctx.sessionId}
metadata={ctx.metadata}
terminalToolDisplayMode={ctx.terminalToolDisplayMode}
disabled={ctx.disabled}
onDone={ctx.onRefresh}
block={block}
/>
{!hideChildren && block.children.length > 0 ? (
isTask ? (
<>
{taskChildren && taskChildren.pending.length > 0 ? (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={taskChildren.pending} />
</div>
) : null}
{taskChildren && taskChildren.rest.length > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
Task details ({taskChildren.rest.length})
</summary>
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={taskChildren.rest} />
</div>
</details>
) : null}
</>
) : (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={block.children} />
</div>
)
) : null}
</div>
)
}
return null
})}
</div>
)
}
export function HappyToolMessage(props: ToolCallMessagePartProps) {
const ctx = useHappyChatContext()
const artifact = props.artifact
if (isToolGroupBlock(artifact)) {
return (
<div data-hapi-share-exclude="true" className="py-1 min-w-0 max-w-full overflow-x-hidden">
<ToolGroupCard
block={artifact}
metadata={ctx.metadata}
/>
</div>
)
}
if (isGeneratedImageBlock(artifact)) {
return (
<div className="py-1 min-w-0 max-w-full overflow-x-hidden">
<GeneratedImageCard block={artifact} />
</div>
)
}
if (!isToolCallBlock(artifact)) {
const argsText = typeof props.argsText === 'string' ? props.argsText.trim() : ''
const hasArgsText = argsText.length > 0
const hasResult = props.result !== undefined
const resultText = hasResult ? safeStringify(props.result) : ''
return (
<div data-hapi-share-exclude="true" className="py-1 min-w-0 max-w-full overflow-x-hidden">
<div className="overflow-hidden rounded-[20px] bg-[var(--app-tool-card-bg)] p-3 shadow-none">
<div className="flex items-center gap-2 text-xs">
<div className="font-mono text-[var(--app-tool-card-accent)]">
Tool: {props.toolName}
</div>
{props.isError ? (
<span className="text-red-500">Error</span>
) : null}
{props.status.type === 'running' && !hasResult ? (
<span className="text-[var(--app-hint)]">Running…</span>
) : null}
</div>
{hasArgsText ? (
<div className="mt-2">
<CodeBlock code={argsText} language="json" title="Input" />
</div>
) : null}
{hasResult ? (
<div className="mt-2">
<CodeBlock code={resultText} language={typeof props.result === 'string' ? 'text' : 'json'} title="Output" />
</div>
) : null}
</div>
</div>
)
}
const block = artifact
const isTask = isSubagentToolName(block.tool.name)
const hideChildren = block.tool.name === 'CodexAgent'
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
<div data-hapi-share-exclude="true" className="py-1 min-w-0 max-w-full overflow-x-hidden">
<ToolCard
api={ctx.api}
sessionId={ctx.sessionId}
metadata={ctx.metadata}
terminalToolDisplayMode={ctx.terminalToolDisplayMode}
disabled={ctx.disabled}
onDone={ctx.onRefresh}
block={block}
/>
{!hideChildren && block.children.length > 0 ? (
isTask ? (
<>
{taskChildren && taskChildren.pending.length > 0 ? (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={taskChildren.pending} />
</div>
) : null}
{taskChildren && taskChildren.rest.length > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
Task details ({taskChildren.rest.length})
</summary>
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={taskChildren.rest} />
</div>
</details>
) : null}
</>
) : (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={block.children} />
</div>
)
) : null}
</div>
)
}