Studio: render generated images inline for the Images pill

The pill wired the request end of the loop but the response was lost
on the client: the backend emits a `tool_end` _toolEvent carrying the
base64 PNG on `image_b64` / `image_mime`, but the chat-adapter only
read the `result` string and the generic ToolFallback printed the
prompt as JSON args with an empty Result block -- the "I see no
image" symptom in the chat.

- chat-adapter: when the closing `tool_end` is for `image_generation`,
  repackage `image_b64` + `image_mime` (+ size/quality/background)
  into a structured result object instead of dropping them.
- New `ImageGenerationToolUI` reads that result and renders the image
  inline via `<img src="data:image/...;base64,...">` with the prompt
  as a caption. Falls back to a spinner while the request is still
  running.
- Register the component under `image_generation` in thread.tsx's
  tools.by_name map so it preempts ToolFallback for this tool only.
This commit is contained in:
Daniel Han 2026-05-22 14:19:59 +00:00
commit 0f7c297e36
3 changed files with 164 additions and 2 deletions

View file

@ -18,6 +18,7 @@ import {
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@ -1105,6 +1106,7 @@ const AssistantMessage: FC = () => {
python: PythonToolUI,
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
},
Fallback: ToolFallback,
},

View file

@ -0,0 +1,132 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { ImageIcon, LoaderIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
/**
* Renders the synthetic `_toolEvent` chunks emitted by
* `_stream_openai_responses` when OpenAI's Responses-API
* `image_generation` tool fires. The backend stashes the base64
* PNG/WebP/JPEG (the gpt-image backbone output) on an `image_b64`
* field of the tool_end event so the JSON result stays small, and the
* adapter repackages it into a structured `result` shape:
*
* {
* image_b64: string,
* image_mime: string, // e.g. "image/png"
* size?: string, // "1024x1024" etc
* quality?: string,
* background?: string,
* }
*
* The corresponding `tool_start` carries the prompt as
* `args.prompt` (after gpt-image's revision pass) plus `args.kind:
* "image"`. Without this component the generic ToolFallback would
* print the prompt as JSON args text with an empty Result block --
* which is exactly the "no image" symptom users hit before this UI
* landed.
*/
interface ImageGenerationArgs {
prompt?: string;
kind?: string;
}
interface ImageGenerationResult {
image_b64?: string;
image_mime?: string;
size?: string;
quality?: string;
background?: string;
}
const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const parsedArgs = (args as ImageGenerationArgs) ?? {};
const prompt = parsedArgs.prompt ?? "";
const isRunning = status?.type === "running";
const isImageResult =
!!result &&
typeof result === "object" &&
typeof (result as ImageGenerationResult).image_b64 === "string";
const imageResult = isImageResult ? (result as ImageGenerationResult) : null;
const mime = imageResult?.image_mime || "image/png";
const imageSrc = imageResult?.image_b64
? `data:${mime};base64,${imageResult.image_b64}`
: null;
// Collapse the card once the model has resumed streaming prose
// after the image. Mirrors CodeExecutionToolUI so the inline image
// doesn't collapse mid-stream and the user can click to re-expand.
const hasText = useAuiState(({ message }) =>
message.content.some(
(p) =>
p.type === "text" &&
"text" in p &&
(p as { text: string }).text.length > 0,
),
);
const [open, setOpen] = useState(true);
useEffect(() => {
if (isRunning) {
setOpen(true);
} else if (hasText && !imageSrc) {
setOpen(false);
}
}, [isRunning, hasText, imageSrc]);
const runningLabel = "Generating image…";
const completedLabel = prompt
? prompt.length > 80
? `Generated image: ${prompt.slice(0, 80)}`
: `Generated image: ${prompt}`
: "Generated image";
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={isRunning ? runningLabel : completedLabel}
status={status}
icon={ImageIcon}
/>
<ToolFallbackContent>
{isRunning && !imageSrc ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>{runningLabel}</span>
</div>
) : imageSrc ? (
<figure className="m-0 flex flex-col gap-1.5">
<img
src={imageSrc}
alt={prompt || "Generated image"}
className="max-w-full rounded-md border border-border/60"
/>
{prompt ? (
<figcaption className="text-xs leading-snug text-muted-foreground">
{prompt}
</figcaption>
) : null}
</figure>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const ImageGenerationToolUI = memo(
ImageGenerationToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
ImageGenerationToolUI.displayName = "ImageGenerationToolUI";

View file

@ -1610,8 +1610,36 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const imgIdx = rawResult.lastIndexOf(imgMarker);
let parsedResult:
| string
| { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
| { text: string; images: string[]; sessionId: string }
| {
image_b64: string;
image_mime: string;
size?: string;
quality?: string;
background?: string;
};
const imageB64 = toolEvent.image_b64 as string | undefined;
if (
toolCallParts[idx].toolName === "image_generation" &&
typeof imageB64 === "string" &&
imageB64
) {
// OpenAI Responses image_generation_call: the
// backend stashes the base64 PNG/WebP/JPEG on
// separate `image_b64` / `image_mime` fields on
// the synthetic _toolEvent so the JSON result
// string stays small enough to log. Repackage as
// a structured result for the dedicated tool UI.
parsedResult = {
image_b64: imageB64,
image_mime:
(toolEvent.image_mime as string | undefined) ??
"image/png",
size: toolEvent.size as string | undefined,
quality: toolEvent.quality as string | undefined,
background: toolEvent.background as string | undefined,
};
} else if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).