/** Inline renderer for image-generation tool-result attachments. */ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { useEffect, useMemo, useState } from 'react' import { AGENT_IMAGE_API } from '../protocol.ts' import { CHAT_IMAGE_EVENT } from './conversation-sync.ts' import css from './image-toolview.module.css' /** Owner props supplied by the host's keyed tool-call slot. */ export interface ImageToolViewOwnerProps { callId: string toolName: string block: ToolCallBlock cwd?: string home?: string openFile: (path: string) => void inspect?: () => void } interface LoadedImage { ref: ImageAttachmentRef src: string } interface ImageToolViewProps extends ImageToolViewOwnerProps { sessionId: SessionId } function isSettled(block: ToolCallBlock): block is Extract { return 'kind' in block } /** Read persisted UI metadata on modern hosts and content blocks on older hosts. */ export function imageRefsOf(block: ToolCallBlock): ImageAttachmentRef[] { if (!isSettled(block) || block.isError) return [] const refs = new Map() const append = (value: unknown): void => { if (typeof value !== 'object' || value === null || Array.isArray(value)) return const raw = value as Record const attachmentId = raw.attachmentId ?? raw.attachment_id const mediaType = raw.mediaType ?? raw.media_type if (typeof attachmentId !== 'string' || attachmentId === '' || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(String(mediaType)) || typeof raw.bytes !== 'number' || !Number.isSafeInteger(raw.bytes) || raw.bytes <= 0 || typeof raw.width !== 'number' || !Number.isSafeInteger(raw.width) || raw.width <= 0 || typeof raw.height !== 'number' || !Number.isSafeInteger(raw.height) || raw.height <= 0) return if (!refs.has(attachmentId)) refs.set(attachmentId, { attachmentId: attachmentId as ImageAttachmentRef['attachmentId'], mediaType: mediaType as ImageAttachmentRef['mediaType'], bytes: raw.bytes, width: raw.width, height: raw.height, ...typeof raw.name === 'string' ? { name: raw.name } : {}, }) } const fromImages = (value: unknown): void => { if (typeof value !== 'object' || value === null || Array.isArray(value)) return const images = (value as { images?: unknown }).images if (Array.isArray(images)) images.forEach(append) } block.content.forEach(content => { if (content.type === 'image') append(content.attachment) }) // DSH's Web client consumes persisted result.meta, not host presentResult(). fromImages((block as { meta?: unknown }).meta) // Existing sessions also carry the exact references in the textual tool result. if (refs.size === 0) { try { fromImages(JSON.parse(textOf(block))) } catch { /* Not a JSON image-tool result. */ } } return [...refs.values()] } function textOf(block: ToolCallBlock): string { if (!isSettled(block)) return '' return block.content .filter(content => content.type === 'text') .map(content => content.text) .join('\n') } function resultInfo(block: ToolCallBlock): { status: string; message: string } { if (!isSettled(block)) return { status: 'running', message: '正在生成图片…' } const text = textOf(block) try { const parsed = JSON.parse(text) as { status?: unknown; message?: unknown } return { status: typeof parsed.status === 'string' ? parsed.status : block.isError ? 'failed' : 'completed', message: typeof parsed.message === 'string' ? parsed.message : '', } } catch { return { status: block.isError ? 'failed' : 'completed', message: text } } } function statusLabel(status: string): string { if (status === 'running' || status === 'queued') return '生成中' if (status === 'failed') return '生成失败' if (status === 'cancelled') return '已取消' return '图片结果' } function useAttachmentImages( sessionId: SessionId, refs: ImageAttachmentRef[], load: (sessionId: SessionId, ref: ImageAttachmentRef) => Promise, ): { images: LoadedImage[]; error: string | null } { const key = useMemo(() => refs.map(ref => String(ref.attachmentId)).join('|'), [refs]) const [images, setImages] = useState([]) const [error, setError] = useState(null) useEffect(() => { let disposed = false let failed = false const urls: string[] = [] const revoke = (): void => { for (const url of urls) URL.revokeObjectURL(url) urls.length = 0 } setImages([]) setError(null) if (refs.length === 0) return () => { /* no attachments to clean up */ } void Promise.all(refs.map(async ref => { const src = await load(sessionId, ref) if (disposed || failed) URL.revokeObjectURL(src) else urls.push(src) return { ref, src } })) .then(next => { if (!disposed) setImages(next) }) .catch(errorValue => { failed = true revoke() if (!disposed) setError(errorValue instanceof Error ? errorValue.message : String(errorValue)) }) return () => { disposed = true revoke() } }, [key, load, refs, sessionId]) return { images, error } } /** Register the inline image result view for all image-generation result tools. */ export function registerImageToolviews(ctx: ClientContext): void { const load = async (_sessionId: SessionId, ref: ImageAttachmentRef): Promise => { // Tool-result images intentionally do not occur in model-visible session // content, so session.readAttachment() rejects them. The plugin route // reads the same durable attachment after validating the complete ref. const query = new URLSearchParams({ attachment_id: String(ref.attachmentId), media_type: ref.mediaType, bytes: String(ref.bytes), width: String(ref.width), height: String(ref.height), }) const response = await fetch(`${AGENT_IMAGE_API}?${query.toString()}`) if (!response.ok) throw new Error(`无法读取图片附件(HTTP ${response.status})。`) const blob = await response.blob() return URL.createObjectURL(blob) } const ImageToolView = (props: ImageToolViewProps): React.JSX.Element => { const refs = useMemo(() => imageRefsOf(props.block), [props.block]) const { status, message } = resultInfo(props.block) const { images, error } = useAttachmentImages(props.sessionId, refs, load) useEffect(() => { if (images.length === 0) return document.dispatchEvent(new CustomEvent(CHAT_IMAGE_EVENT, { detail: { sessionId: props.sessionId, refs: images.map(image => image.ref), }, })) }, [images, props.sessionId]) return
{props.toolName} {statusLabel(status)}
{message !== '' &&

{message}

} {images.length > 0 &&
{images.map(image => {image.ref.name )}
} {refs.length > 0 && images.length === 0 && error === null &&

正在加载图片…

} {error !== null &&

{error}

}
} ctx.slots.inject('tool.call.toolview', function* () { for (const key of ['generate_image', 'edit_image', 'get_image_generation_task']) { yield ctx.slots.register({ name: 'tool.call.toolview', key, inject: (sessionId: string) => ({ sessionId: sessionId as SessionId }), }, ImageToolView) } }) }