fix(webui): render local CLI image artifacts

This commit is contained in:
Xubin Ren
2026-05-24 19:43:20 +08:00
parent 9efdce276f
commit c9ff64fc0f
13 changed files with 461 additions and 10 deletions
@@ -109,6 +109,46 @@ export default function MarkdownTextRenderer({
</a>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node;
const source = typeof src === "string" ? src : "";
if (!source) return null;
const label = typeof alt === "string" ? alt : "";
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<a
href={source}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={label ? `Open ${label}` : "Open image"}
>
<img
src={source}
alt={label}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
imgClassName,
)}
{...props}
/>
</a>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
);
},
}),
[highlightCode],
);
+35 -5
View File
@@ -193,6 +193,15 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
return prev;
}
function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") return i;
if (m.role === "user") break;
}
return null;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -489,23 +498,41 @@ export function useNanobotStream(
[appendAnswerChunk, ensureActivitySegmentId],
);
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
const flushPendingStreamEvents = useCallback((options?: {
closeAnswerSegment?: boolean;
finalAnswerText?: string;
}) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
const events = pendingStreamEventsRef.current;
if (events.length === 0) {
const finalAnswerText = options?.finalAnswerText;
if (events.length === 0 && finalAnswerText === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
}
pendingStreamEventsRef.current = [];
setMessages((prev) => {
const next = applyPendingStreamEvents(prev, events);
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current)
?? findLatestAssistantAnswerIndex(next);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
});
}
}
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
});
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
@@ -583,7 +610,10 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
flushPendingStreamEvents({ closeAnswerSegment: true });
flushPendingStreamEvents({
closeAnswerSegment: true,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
});
if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
+1
View File
@@ -378,6 +378,7 @@ export type InboundEvent =
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
}
| {
event: "reasoning_delta";
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => {
it("renders markdown images as inline previews", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
const image = screen.getByRole("img", { name: "Diagram" });
expect(image).toHaveAttribute("src", "/api/media/sig/payload");
expect(screen.getByRole("link", { name: "Open Diagram" })).toHaveAttribute(
"href",
"/api/media/sig/payload",
);
expect(screen.getByText("Diagram")).toBeInTheDocument();
});
});
+32
View File
@@ -1266,6 +1266,38 @@ describe("useNanobotStream", () => {
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("replaces streamed content with final stream_end text when provided", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-final", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-stream-final", {
event: "delta",
chat_id: "chat-stream-final",
text: "![Diagram](diagram.png)",
});
});
await flushStreamFrame();
act(() => {
fake.emit("chat-stream-final", {
event: "stream_end",
chat_id: "chat-stream-final",
text: "![Diagram](/api/media/sig/payload)",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "![Diagram](/api/media/sig/payload)",
isStreaming: true,
});
});
it("stamps latency on the last assistant bubble from turn_end", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {