fix(webui): render local CLI image artifacts
This commit is contained in:
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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></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();
|
||||
});
|
||||
});
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-final", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-final",
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps latency on the last assistant bubble from turn_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user