mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-29 17:19:00 +08:00
fix: preserve uploaded image filenames in chat history (#159216)
* fix: preserve uploaded image filenames in chat history * test: cover image filenames in queued chat inputs * test(gateway): expect preserved inline image filename
This commit is contained in:
@@ -406,6 +406,7 @@ pasted-text attachments. Older history without origin metadata recognizes
|
||||
`text/plain` attachments named `pasted-text-<digits>.txt` as pasted text.
|
||||
|
||||
Uploaded attachments keep their original filenames on download, including spaces and Unicode characters.
|
||||
Uploaded images also retain their names in accessible transcript image and image-viewer labels after sending and reloading; descriptive alt text takes precedence when provided.
|
||||
Select **Open** on an uploaded text attachment to read it directly in the **Files** side
|
||||
panel. Plain-text attachments, CSV, and JSON preserve line breaks and indentation. Markdown attachments render as documents
|
||||
with interactive code blocks. When an open attachment refreshes with unchanged
|
||||
|
||||
@@ -270,34 +270,28 @@ describe("composer attachment origin", () => {
|
||||
|
||||
describe("persistInboundImagesForTranscript", () => {
|
||||
it("preserves original mixed-media order in claim-only transcript facts", async () => {
|
||||
const fileName = "bands café 雪 🦞.png";
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
id: "video",
|
||||
path: "/media/inbound/video.mp4",
|
||||
size: 100,
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
const parsed = await parseMessageWithAttachments("Compare these", [
|
||||
{ fileName: "video.mp4", mimeType: "video/mp4", content: GENERIC_MP4, durationMs: 2_000 },
|
||||
pngAttachment({ fileName }),
|
||||
]);
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
id: "inline",
|
||||
path: "/media/inbound/inline.jpg",
|
||||
path: "/media/inbound/inline.png",
|
||||
size: 5,
|
||||
contentType: "image/jpeg",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const result = await persistInboundImagesForTranscript({
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
data: "aGVsbG8=",
|
||||
mimeType: "image/jpeg",
|
||||
sourceIndex: 1,
|
||||
},
|
||||
],
|
||||
images: parsed.images,
|
||||
offloadedRefs: [
|
||||
{
|
||||
mediaRef: "https://signed.example/private-video",
|
||||
id: "video",
|
||||
path: "/media/inbound/video.mp4",
|
||||
kind: "video",
|
||||
mimeType: "video/mp4",
|
||||
label: "video.mp4",
|
||||
sizeBytes: 100,
|
||||
durationMs: 2_000,
|
||||
sourceIndex: 0,
|
||||
},
|
||||
{ ...parsed.offloadedRefs[0]!, mediaRef: "https://signed.example/private-video" },
|
||||
],
|
||||
log: { warn: vi.fn() },
|
||||
logContext: "test",
|
||||
@@ -310,17 +304,33 @@ describe("persistInboundImagesForTranscript", () => {
|
||||
contentType: "video/mp4",
|
||||
kind: "video",
|
||||
fileName: "video.mp4",
|
||||
sizeBytes: 100,
|
||||
sizeBytes: Buffer.from(GENERIC_MP4, "base64").length,
|
||||
durationMs: 2_000,
|
||||
hydrationSuppressed: true,
|
||||
},
|
||||
{
|
||||
url: "media://inbound/inline",
|
||||
contentType: "image/jpeg",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
fileName,
|
||||
sizeBytes: 5,
|
||||
},
|
||||
]);
|
||||
expect(saveMediaBufferMock).toHaveBeenLastCalledWith(
|
||||
Buffer.from(PNG_1x1, "base64"),
|
||||
"image/png",
|
||||
"inbound",
|
||||
undefined,
|
||||
fileName,
|
||||
);
|
||||
const persisted = buildPersistedUserTurnMessage({
|
||||
text: parsed.message,
|
||||
media: result.entries.map((entry) => entry.fact),
|
||||
});
|
||||
expect(readPersistedMediaFacts(persisted)?.map((fact) => fact.fileName)).toEqual([
|
||||
"video.mp4",
|
||||
fileName,
|
||||
]);
|
||||
expect(result.omission).toBe("none");
|
||||
const durable = JSON.stringify(result.entries.map((entry) => entry.fact));
|
||||
expect(durable).not.toContain("/media/");
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ChatImageContent = {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
sourceIndex: number;
|
||||
};
|
||||
|
||||
@@ -165,6 +166,8 @@ export async function persistInboundImagesForTranscript(params: {
|
||||
Buffer.from(image.data, "base64"),
|
||||
image.mimeType,
|
||||
"inbound",
|
||||
undefined,
|
||||
image.fileName,
|
||||
);
|
||||
const trusted = assertSavedMedia(saved, `inline image ${image.sourceIndex + 1}`);
|
||||
entries.push({
|
||||
@@ -176,6 +179,7 @@ export async function persistInboundImagesForTranscript(params: {
|
||||
url: trusted.mediaRef,
|
||||
contentType: saved.contentType ?? image.mimeType,
|
||||
kind: "image",
|
||||
...(image.fileName ? { fileName: image.fileName } : {}),
|
||||
sizeBytes: saved.size,
|
||||
},
|
||||
});
|
||||
@@ -408,7 +412,13 @@ export async function parseMessageWithAttachments(
|
||||
(opts?.imageStorage !== "inline" && sizeBytes > ATTACHMENT_OFFLOAD_THRESHOLD_BYTES);
|
||||
|
||||
if (!shouldOffload) {
|
||||
images.push({ type: "image", data: b64, mimeType: finalMime, sourceIndex: idx });
|
||||
images.push({
|
||||
type: "image",
|
||||
data: b64,
|
||||
mimeType: finalMime,
|
||||
...(att.fileName ? { fileName: att.fileName } : {}),
|
||||
sourceIndex: idx,
|
||||
});
|
||||
imageOrder.push("inline");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3535,7 +3535,7 @@ describe("gateway server chat", () => {
|
||||
expect(responses[0]?.ok).toBe(true);
|
||||
await waitForFast(() => expect(captured).toBeDefined(), FAST_WAIT_OPTS);
|
||||
expect(captured?.replyOptions?.images).toEqual([
|
||||
{ type: "image", data: pngB64, mimeType: "image/png", sourceIndex: 0 },
|
||||
{ type: "image", data: pngB64, mimeType: "image/png", sourceIndex: 0, fileName: "dot.png" },
|
||||
]);
|
||||
expect(captured?.ctx?.media).toEqual([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("initial user message handoff", () => {
|
||||
{
|
||||
type: "image",
|
||||
url: imageDataUrl,
|
||||
fileName: "image.png",
|
||||
source: { type: "url", url: imageDataUrl },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -541,6 +541,7 @@ suite.define(() => {
|
||||
await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
|
||||
await expectDecodedThumbnail(userImage, 180);
|
||||
const initialImageSrc = await userImage.getAttribute("src");
|
||||
const initialImageAlt = await userImage.getAttribute("alt");
|
||||
const captureThumbnail = () =>
|
||||
page.video()
|
||||
? takeControlUiElementScreenshot(page, userImage, [userImage])
|
||||
@@ -633,6 +634,17 @@ suite.define(() => {
|
||||
expect(await userImage.getAttribute("data-initial-image-node")).toBe("true");
|
||||
expect((await captureThumbnail()).equals(initialPixels)).toBe(true);
|
||||
await captureUiProof(suite, page, "initial-image-canonical-ready.png");
|
||||
await userRow.locator(".chat-message-image-button").click();
|
||||
const lightbox = page.locator("openclaw-image-lightbox");
|
||||
const dialog = lightbox.getByRole("dialog");
|
||||
await dialog.waitFor({ state: "visible" });
|
||||
await captureUiProof(suite, page, "initial-image-filename-preview.png", {
|
||||
surface: dialog,
|
||||
content: [lightbox.locator("img.image")],
|
||||
});
|
||||
expect(initialImageAlt).toBe("pixel.png");
|
||||
expect(await userImage.getAttribute("alt")).toBe("pixel.png");
|
||||
expect(await dialog.getAttribute("aria-label")).toBe("Image preview: pixel.png");
|
||||
} finally {
|
||||
releaseMedia();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequireRecord } from "../../../../test/helpers/record.js";
|
||||
import type { ChatQueueItem, MessageGroup } from "../../lib/chat/chat-types.ts";
|
||||
import { buildCachedChatItems, resetChatThreadState } from "./chat-thread.ts";
|
||||
|
||||
type ChatItemsProps = Parameters<typeof buildCachedChatItems>[0];
|
||||
const requireRecord = createRequireRecord("record", "expected-non-array-record");
|
||||
|
||||
afterEach(() => resetChatThreadState("queued-inputs"));
|
||||
|
||||
function messageGroups(overrides: Partial<ChatItemsProps>): MessageGroup[] {
|
||||
return buildCachedChatItems({
|
||||
paneId: "queued-inputs",
|
||||
sessionKey: "main",
|
||||
runId: null,
|
||||
messages: [],
|
||||
toolMessages: [],
|
||||
streamSegments: [],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
showToolCalls: true,
|
||||
...overrides,
|
||||
}).filter((item) => item.kind === "group");
|
||||
}
|
||||
|
||||
function queuedSend(
|
||||
id: string,
|
||||
text: string,
|
||||
createdAt: number,
|
||||
sendState: ChatQueueItem["sendState"],
|
||||
overrides: Partial<ChatQueueItem> = {},
|
||||
): ChatQueueItem {
|
||||
return { id, text, createdAt, sendState, ...overrides };
|
||||
}
|
||||
|
||||
function groupAt(groups: readonly MessageGroup[], index: number): MessageGroup {
|
||||
return expectDefined(groups[index], `message group ${index}`);
|
||||
}
|
||||
|
||||
function messageRecord(group: MessageGroup): Record<string, unknown> {
|
||||
return requireRecord(group.messages[0]?.message);
|
||||
}
|
||||
|
||||
describe("queued transcript inputs", () => {
|
||||
it("renders submitted queued sends as user turns before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],
|
||||
queue: [
|
||||
queuedSend("pending-send-1", "first visible send", 2, "sending", {
|
||||
sendSubmittedAtMs: 10,
|
||||
sender: { id: "alice@example.com", name: "Alice Example" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
|
||||
expect(groupAt(groups, 1).sender).toEqual({
|
||||
id: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
});
|
||||
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
|
||||
{ type: "text", text: "first visible send" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders reply metadata on queued user turns before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],
|
||||
queue: [
|
||||
queuedSend("pending-send-1", "follow up", 2, "sending", {
|
||||
replyToId: "transcript-123",
|
||||
sendSubmittedAtMs: 10,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groupAt(groups, 1).messages[0]?.message).toMatchObject({
|
||||
__openclaw: { replyToId: "transcript-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps restored in-flight sends visible without process-local timing", () => {
|
||||
const restored = {
|
||||
id: "restored-send-1",
|
||||
text: "stay visible across reconnect",
|
||||
createdAt: 2,
|
||||
sendAttempts: 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
messageGroups({
|
||||
queue: [
|
||||
{ ...restored, sendAttempts: 0, sendSubmittedAtMs: 10, sendState: "waiting-reconnect" },
|
||||
],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
for (const sendState of ["waiting-reconnect", "sending"] as const) {
|
||||
const groups = messageGroups({ queue: [{ ...restored, sendState }] });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "stay visible across reconnect" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps steerable queued sends out of the thread until sending starts", () => {
|
||||
const queued = {
|
||||
id: "pending-send-1",
|
||||
text: "wait above the composer",
|
||||
createdAt: 2,
|
||||
sendSubmittedAtMs: 10,
|
||||
};
|
||||
|
||||
expect(messageGroups({ queue: [{ ...queued, sendState: "waiting-idle" }] })).toStrictEqual([]);
|
||||
|
||||
const groups = messageGroups({ queue: [{ ...queued, sendState: "sending" }] });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "wait above the composer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders submitted queued attachment sends with attachment blocks before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
queue: [
|
||||
queuedSend("pending-attachment-send-1", "see attached", 2, "sending", {
|
||||
sendSubmittedAtMs: 10,
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
previewUrl: "/media/screenshot.png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "see attached" },
|
||||
{
|
||||
type: "image",
|
||||
url: "/media/screenshot.png",
|
||||
fileName: "screenshot.png",
|
||||
source: { type: "url", url: "/media/screenshot.png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3777,112 +3777,6 @@ describe("buildCachedChatItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders submitted queued sends as user turns before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [assistantMessage("Ready.", 1)],
|
||||
queue: [
|
||||
queuedSend("pending-send-1", "first visible send", 2, "sending", {
|
||||
sendSubmittedAtMs: 10,
|
||||
sender: { id: "alice@example.com", name: "Alice Example" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
|
||||
expect(groupAt(groups, 1).sender).toEqual({
|
||||
id: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
});
|
||||
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
|
||||
{ type: "text", text: "first visible send" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders reply metadata on queued user turns before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [assistantMessage("Ready.", 1)],
|
||||
queue: [
|
||||
queuedSend("pending-send-1", "follow up", 2, "sending", {
|
||||
replyToId: "transcript-123",
|
||||
sendSubmittedAtMs: 10,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groupAt(groups, 1).messages[0]?.message).toMatchObject({
|
||||
__openclaw: { replyToId: "transcript-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps restored in-flight sends visible without process-local timing", () => {
|
||||
const restored = {
|
||||
id: "restored-send-1",
|
||||
text: "stay visible across reconnect",
|
||||
createdAt: 2,
|
||||
sendAttempts: 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
messageGroups({
|
||||
queue: [
|
||||
{ ...restored, sendAttempts: 0, sendSubmittedAtMs: 10, sendState: "waiting-reconnect" },
|
||||
],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
for (const sendState of ["waiting-reconnect", "sending"] as const) {
|
||||
const groups = messageGroups({ queue: [{ ...restored, sendState }] });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "stay visible across reconnect" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps steerable queued sends out of the thread until sending starts", () => {
|
||||
const queued = {
|
||||
id: "pending-send-1",
|
||||
text: "wait above the composer",
|
||||
createdAt: 2,
|
||||
sendSubmittedAtMs: 10,
|
||||
};
|
||||
|
||||
expect(messageGroups({ queue: [{ ...queued, sendState: "waiting-idle" }] })).toStrictEqual([]);
|
||||
|
||||
const groups = messageGroups({ queue: [{ ...queued, sendState: "sending" }] });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "wait above the composer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders submitted queued attachment sends with attachment blocks before chat.send ACK", () => {
|
||||
const groups = messageGroups({
|
||||
queue: [
|
||||
queuedSend("pending-attachment-send-1", "see attached", 2, "sending", {
|
||||
sendSubmittedAtMs: 10,
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
previewUrl: "/media/screenshot.png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
|
||||
{ type: "text", text: "see attached" },
|
||||
{
|
||||
type: "image",
|
||||
url: "/media/screenshot.png",
|
||||
source: { type: "url", url: "/media/screenshot.png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not collapse pending sends with matching history text", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [userMessage("same prompt", 1)],
|
||||
|
||||
@@ -87,10 +87,15 @@ describe("message image gallery loading", () => {
|
||||
).toBe(expectedSrc);
|
||||
const tile = container.querySelector<HTMLButtonElement>(".chat-message-image-button");
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile?.getAttribute("aria-label")).toBe(`Open image ${filename}`);
|
||||
expect(container.querySelector(".chat-message-image")?.getAttribute("alt")).toBe(filename);
|
||||
tile!.click();
|
||||
await opened.promise;
|
||||
expect(onOpenImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ src: new URL(expectedSrc, window.location.href).href }),
|
||||
expect.objectContaining({
|
||||
src: new URL(expectedSrc, window.location.href).href,
|
||||
title: filename,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -257,7 +262,7 @@ describe("message image gallery loading", () => {
|
||||
renderMessageImages(
|
||||
[
|
||||
{ url: "data:image/png;base64,cG5n", alt: "First image" },
|
||||
{ url: source, alt: "Managed neighbor" },
|
||||
{ url: source, alt: "Managed neighbor", fileName: "neighbor.png" },
|
||||
],
|
||||
{ onOpenImage, onRequestUpdate },
|
||||
),
|
||||
@@ -323,7 +328,7 @@ describe("message image gallery loading", () => {
|
||||
renderMessageImages(
|
||||
[
|
||||
{ url: "data:image/png;base64,cG5n", alt: "First image" },
|
||||
{ url: localSource, alt: "Local neighbor" },
|
||||
{ url: localSource, fileName: "Local neighbor.png" },
|
||||
],
|
||||
{ onOpenImage, onRequestUpdate, sessionKey: "main", resourceBasePath: "/openclaw" },
|
||||
),
|
||||
@@ -366,7 +371,7 @@ describe("message image gallery loading", () => {
|
||||
if (removeOwner) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result?.title).toBe("Local neighbor");
|
||||
expect(result?.title).toBe("Local neighbor.png");
|
||||
const url = new URL(result!.src, window.location.href);
|
||||
expect(url.pathname).toBe("/openclaw/__openclaw__/assistant-media");
|
||||
expect(url.searchParams.get("source")).toBe(localSource);
|
||||
|
||||
@@ -49,6 +49,10 @@ function isInlineImageSource(source: string | undefined): source is string {
|
||||
return source?.startsWith("data:image/") === true || source?.startsWith("blob:") === true;
|
||||
}
|
||||
|
||||
function imageTitle(image: ImageBlock): string {
|
||||
return image.alt?.trim() || image.fileName?.trim() || t("chat.imageLightbox.untitled");
|
||||
}
|
||||
|
||||
class MessageImageResourceDirective extends AsyncDirective {
|
||||
private image: ImageBlock | undefined;
|
||||
private options: ImageRenderOptions | undefined;
|
||||
@@ -289,7 +293,7 @@ class MessageImageResourceDirective extends AsyncDirective {
|
||||
previewUrl: string | undefined,
|
||||
opts: ImageRenderOptions | undefined,
|
||||
) {
|
||||
const title = img.alt?.trim() || t("chat.imageLightbox.untitled");
|
||||
const title = imageTitle(img);
|
||||
return this.renderImageFrame(
|
||||
img,
|
||||
html`
|
||||
@@ -440,7 +444,7 @@ function openMessageImage(
|
||||
previewUrl: string,
|
||||
opts: ImageRenderOptions | undefined,
|
||||
) {
|
||||
const title = img.alt?.trim() || t("chat.imageLightbox.untitled");
|
||||
const title = imageTitle(img);
|
||||
const requestVersion = opts?.onRequestOpenImage?.();
|
||||
const images = opts?.galleryImages;
|
||||
const index = images?.indexOf(img) ?? -1;
|
||||
@@ -551,7 +555,7 @@ async function loadGalleryImage(
|
||||
}
|
||||
return {
|
||||
src: safeSrc,
|
||||
title: image.alt?.trim() || t("chat.imageLightbox.untitled"),
|
||||
title: imageTitle(image),
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
release,
|
||||
|
||||
@@ -101,9 +101,10 @@ async function createCanonicalImageTranscript(
|
||||
text: "Cached text",
|
||||
createdAt: 1_000,
|
||||
runId: "canonical-image-send",
|
||||
attachments: inlineUrls.map((dataUrl) => ({
|
||||
attachments: inlineUrls.map((dataUrl, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
mimeType: "image/png",
|
||||
fileName: `image-${index}.png`,
|
||||
dataUrl,
|
||||
})),
|
||||
};
|
||||
@@ -124,10 +125,16 @@ async function createCanonicalImageTranscript(
|
||||
} else if (origin === "submitted") {
|
||||
reduceChatSessionProjection(owner, { type: "sendPending", runId: input.runId, message: local });
|
||||
}
|
||||
const media = Array.from({ length: Math.max(...factIndexes) + 1 }, (_, index) =>
|
||||
factIndexes.includes(index)
|
||||
? { path: `media://inbound/${crypto.randomUUID()}.png`, contentType: "image/png" }
|
||||
: null,
|
||||
const media: Array<{ path: string; contentType: string; fileName?: string } | null> = Array.from(
|
||||
{ length: Math.max(...factIndexes) + 1 },
|
||||
(_, index) =>
|
||||
factIndexes.includes(index)
|
||||
? {
|
||||
path: `media://inbound/${crypto.randomUUID()}.png`,
|
||||
contentType: "image/png",
|
||||
fileName: `image-${factIndexes.indexOf(index)}.png`,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const props = {
|
||||
...threadProps(
|
||||
@@ -241,6 +248,7 @@ describe("canonical image presentation handoff", () => {
|
||||
async (origin) => {
|
||||
const fixture = await createCanonicalImageTranscript(undefined, undefined, origin);
|
||||
const displayed = expectDefined(fixture.displayed[0], "displayed inline image");
|
||||
expect(displayed.alt).toBe("image-0.png");
|
||||
if (origin === "initial receipt") {
|
||||
fixture.publish();
|
||||
expectSameImageNodes(fixture.images(), [displayed]);
|
||||
@@ -258,6 +266,7 @@ describe("canonical image presentation handoff", () => {
|
||||
expect(displayed.getAttribute("src")).toContain(
|
||||
encodeURIComponent(expectDefined(fixture.media[0], "canonical media fact").path),
|
||||
);
|
||||
expect(displayed.alt).toBe("image-0.png");
|
||||
fixture.renderPane();
|
||||
expectSameImageNodes(fixture.images(), [displayed]);
|
||||
displayed.dispatchEvent(new Event("load"));
|
||||
|
||||
@@ -13,6 +13,7 @@ type UserChatMessageContentBlock = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
source?: unknown;
|
||||
attachment?: {
|
||||
url: string;
|
||||
@@ -48,6 +49,7 @@ function buildUserChatMessageContentBlocks(
|
||||
blocks.push({
|
||||
type: "image",
|
||||
url: previewUrl,
|
||||
...(attachment.fileName ? { fileName: attachment.fileName } : {}),
|
||||
source: { type: "url", url: previewUrl },
|
||||
});
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user