Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions console/src/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ import {
appendUser,
settlePending,
appendChunk,
appendImage,
endTurn,
isRenderableImage,
type ChatTurn,
type ChatImage,
} from "./chat";

const PNG_PIXEL: ChatImage = {
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
mimeType: "image/png",
};

function agent(partial: Partial<ChatTurn>): ChatTurn {
return { id: 1, role: "agent", text: "", streaming: false, ...partial };
}
Expand Down Expand Up @@ -93,6 +101,41 @@ describe("transcript reducers", () => {
appendUser(start, 1, "x");
expect(start).toHaveLength(0);
});

it("appendImage opens an agent turn on the first image, appends after", () => {
let turns = appendUser([], 1, "hi");
turns = appendImage(turns, 2, PNG_PIXEL);
expect(turns).toHaveLength(2);
expect(turns[1]).toMatchObject({ role: "agent", streaming: true, id: 2, images: [PNG_PIXEL] });
const second: ChatImage = { ...PNG_PIXEL, mimeType: "image/jpeg" };
turns = appendImage(turns, 99, second);
// still one agent turn, id preserved from the opening image
expect(turns).toHaveLength(2);
expect(turns[1].images).toEqual([PNG_PIXEL, second]);
});

it("appendUser attaches images to the user's own turn", () => {
const turns = appendUser([], 1, "look", false, [PNG_PIXEL]);
expect(turns[0].images).toEqual([PNG_PIXEL]);
});
});

describe("isRenderableImage", () => {
it("accepts an allow-listed mime type with base64-shaped data", () => {
expect(isRenderableImage(PNG_PIXEL)).toBe(true);
});

it("rejects a mime type outside the allow-list", () => {
expect(isRenderableImage({ ...PNG_PIXEL, mimeType: "image/svg+xml" })).toBe(false);
});

it("rejects data that isn't valid base64 shape (defends the src attribute)", () => {
expect(isRenderableImage({ ...PNG_PIXEL, data: '"><script>alert(1)</script>' })).toBe(false);
});

it("rejects empty data", () => {
expect(isRenderableImage({ ...PNG_PIXEL, data: "" })).toBe(false);
});
});

describe("turnHtml / transcriptHtml", () => {
Expand Down Expand Up @@ -142,4 +185,30 @@ describe("turnHtml / transcriptHtml", () => {
it("renders an empty-state message for no turns", () => {
expect(transcriptHtml([])).toContain("chat-empty");
});

it("renders a valid image as a data-URI <img>", () => {
const html = turnHtml(agent({ text: "look", streaming: false, images: [PNG_PIXEL] }), mdToHtml);
expect(html).toContain("chat-images");
expect(html).toContain(`data:${PNG_PIXEL.mimeType};base64,${PNG_PIXEL.data}`);
});

it("drops an unrenderable image instead of emitting it", () => {
const bad: ChatImage = { data: "not base64!!", mimeType: "image/png" };
const html = turnHtml(agent({ text: "look", streaming: false, images: [bad] }), mdToHtml);
expect(html).not.toContain("chat-images");
expect(html).not.toContain(bad.data);
});

it("renders images on a streaming turn too", () => {
const html = turnHtml(agent({ text: "", streaming: true, images: [PNG_PIXEL] }), mdToHtml);
expect(html).toContain("chat-images");
});

it("renders the operator's own attached image on a user turn", () => {
const html = turnHtml(
{ id: 1, role: "user", text: "look", streaming: false, images: [PNG_PIXEL] },
mdToHtml,
);
expect(html).toContain("chat-images");
});
});
78 changes: 77 additions & 1 deletion console/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,61 @@ export interface ChatTurn {
// a "queued" state so it isn't invisible until the queue drains. Cleared when
// its turn actually starts.
pending?: boolean;
// Inline images attached to this turn (issue #158) — pasted by the operator on
// a `user` turn, or streamed in from the agent's reply on an `agent` turn.
// Rendered after the turn's text; order relative to interleaved text chunks
// isn't preserved, the same simplification `appendChunk` already makes for
// multiple text chunks accumulating into one string.
images?: ChatImage[];
}

// One inline image — the wire shape is already `{ data, mimeType }` end to end
// (ACP `ImageContent`, `agent-update` events, the `agent_prompt` payload), so the
// transcript keeps it as-is rather than inventing another shape.
export interface ChatImage {
data: string;
mimeType: string;
}

// Only these are rendered — image content is agent- or clipboard-sourced, less
// trusted than markdown text, so it gets an allow-list instead of open season on
// `<img src="data:...">`.
const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);

// Base64's alphabet has no `<`/`>`/`"`/`'` — a `data` value that doesn't match
// this shape isn't real base64 and must not reach a `src` attribute.
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;

// Hard cap on one image's base64 payload: ~8MB decoded (≈10.9M base64 chars).
// Mirrors the ~5MB raw-file cap `chatPanel.ts` enforces before it ever base64s a
// pasted image — this is the render-side half of the same guard, so a malformed
// or oversized image (however it got here) is dropped rather than rendered.
const MAX_IMAGE_BASE64_CHARS = Math.ceil((8 * 1024 * 1024 * 4) / 3);

// Exported so `chatPanel.ts` can apply the same allow-list/shape check to a
// freshly pasted image before it ever gets attached to an outgoing turn.
export function isRenderableImage(img: ChatImage): boolean {
return (
ALLOWED_IMAGE_MIME.has(img.mimeType) &&
img.data.length > 0 &&
img.data.length <= MAX_IMAGE_BASE64_CHARS &&
BASE64_RE.test(img.data)
);
}

// `mimeType` is allow-listed and `data` is base64-shape-checked above, so neither
// can carry a quote/angle-bracket — safe to interpolate directly into the `src`
// attribute without a further escape pass.
function imagesHtml(images: ChatImage[] | undefined): string {
const valid = (images ?? []).filter(isRenderableImage);
if (valid.length === 0) return "";
return (
`<div class="chat-images">` +
valid
.map((img) => `<img class="chat-image" src="data:${img.mimeType};base64,${img.data}" alt="pasted image">`)
.join("") +
`</div>`
);
}

// Markdown → HTML for a finalized agent turn. `html: false` escapes any raw HTML
Expand Down Expand Up @@ -63,6 +118,7 @@ export function turnHtml(turn: ChatTurn, renderMd: RenderMarkdown): string {
return (
`<div class="${cls}">` +
`<div class="chat-body chat-md">${renderMd(turn.text)}</div>` +
imagesHtml(turn.images) +
queued +
`</div>`
);
Expand All @@ -74,6 +130,7 @@ export function turnHtml(turn: ChatTurn, renderMd: RenderMarkdown): string {
`<div class="chat-turn chat-agent" data-id="${turn.id}">` +
`<div class="chat-body chat-stream">${escapeHtml(turn.text)}` +
`<span class="chat-spinner" aria-label="thinking"></span></div>` +
imagesHtml(turn.images) +
`</div>`
);
}
Expand All @@ -84,6 +141,7 @@ export function turnHtml(turn: ChatTurn, renderMd: RenderMarkdown): string {
return (
`<div class="chat-turn chat-agent" data-id="${turn.id}">` +
`<div class="chat-body chat-md">${renderMd(turn.text)}</div>` +
imagesHtml(turn.images) +
`<div class="chat-tools">` +
`<button class="chat-copy" type="button" data-copy="${turn.id}">Copy</button>${reason}` +
`</div>` +
Expand Down Expand Up @@ -113,8 +171,9 @@ export function appendUser(
id: number,
text: string,
pending = false,
images?: ChatImage[],
): ChatTurn[] {
return [...turns, { id, role: "user", text, streaming: false, pending }];
return [...turns, { id, role: "user", text, streaming: false, pending, images }];
}

// Clear the `pending` flag on the user turn `id` — its queued message is now the
Expand All @@ -138,6 +197,23 @@ export function appendChunk(
return [...turns, { id, role: "agent", text, streaming: true }];
}

// Append a streamed inline `image` (issue #158). Mirrors `appendChunk`: opens the
// turn on the first update of a reply (chunk or image), otherwise appends to the
// still-open turn's image list.
export function appendImage(
turns: ChatTurn[],
id: number,
image: ChatImage,
): ChatTurn[] {
const last = turns[turns.length - 1];
if (last && last.role === "agent" && last.streaming) {
const images = [...(last.images ?? []), image];
const updated: ChatTurn = { ...last, images };
return [...turns.slice(0, -1), updated];
}
return [...turns, { id, role: "agent", text: "", streaming: true, images: [image] }];
}

// Close the open agent turn on `turn_end`. If a `turn_end` arrives with no open
// turn (e.g. a cancel before any chunk), synthesize an empty finalized turn so
// the stop reason is still visible. No-op shape otherwise stays pure.
Expand Down
Loading
Loading