Skip to content

Commit b229266

Browse files
committed
fix(ui): keep chat attachment payloads out of state
1 parent bb7e862 commit b229266

11 files changed

Lines changed: 311 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
1516
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
1617
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
1718
- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby.

src/commands/channels.list.auth-profiles.test.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ vi.mock("../channels/plugins/status.js", () => ({
4040

4141
import { channelsListCommand } from "./channels/list.js";
4242

43+
function createMockChannelPlugin(accountIds: string[]): ChannelPlugin {
44+
return {
45+
id: "telegram",
46+
meta: {
47+
id: "telegram",
48+
label: "Telegram",
49+
selectionLabel: "Telegram",
50+
docsPath: "/channels/telegram",
51+
blurb: "Telegram",
52+
},
53+
capabilities: { chatTypes: ["direct"] },
54+
config: {
55+
listAccountIds: () => accountIds,
56+
resolveAccount: () => ({}),
57+
},
58+
};
59+
}
60+
4361
describe("channels list auth profiles", () => {
4462
beforeEach(() => {
4563
mocks.readConfigFileSnapshot.mockReset();
@@ -92,21 +110,7 @@ describe("channels list auth profiles", () => {
92110
it("includes configured chat channel accounts in JSON output", async () => {
93111
const runtime = createTestRuntime();
94112
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
95-
{
96-
id: "telegram",
97-
meta: {
98-
id: "telegram",
99-
label: "Telegram",
100-
selectionLabel: "Telegram",
101-
docsPath: "/channels/telegram",
102-
blurb: "Telegram",
103-
},
104-
capabilities: { chatTypes: ["direct"] },
105-
config: {
106-
listAccountIds: () => ["alerts", "default"],
107-
resolveAccount: () => ({}),
108-
},
109-
},
113+
createMockChannelPlugin(["alerts", "default"]),
110114
]);
111115
mocks.readConfigFileSnapshot.mockResolvedValue({
112116
...baseConfigSnapshot,
@@ -141,21 +145,7 @@ describe("channels list auth profiles", () => {
141145
it("prints configured chat channel accounts before auth providers", async () => {
142146
const runtime = createTestRuntime();
143147
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
144-
{
145-
id: "telegram",
146-
meta: {
147-
id: "telegram",
148-
label: "Telegram",
149-
selectionLabel: "Telegram",
150-
docsPath: "/channels/telegram",
151-
blurb: "Telegram",
152-
},
153-
capabilities: { chatTypes: ["direct"] },
154-
config: {
155-
listAccountIds: () => ["default"],
156-
resolveAccount: () => ({}),
157-
},
158-
},
148+
createMockChannelPlugin(["default"]),
159149
]);
160150
mocks.buildChannelAccountSnapshot.mockResolvedValue({
161151
accountId: "default",

ui/src/ui/app-chat.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
44
import type { ChatHost } from "./app-chat.ts";
5+
import {
6+
getChatAttachmentDataUrl,
7+
getChatAttachmentPreviewUrl,
8+
registerChatAttachmentPayload,
9+
resetChatAttachmentPayloadStoreForTest,
10+
} from "./chat/attachment-payload-store.ts";
511
import type { GatewaySessionRow, SessionsListResult } from "./types.ts";
612

713
const { setLastActiveSessionKeyMock } = vi.hoisted(() => ({
@@ -18,6 +24,7 @@ let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHi
1824
let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat;
1925
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
2026
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
27+
let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage;
2128

2229
async function loadChatHelpers(): Promise<void> {
2330
({
@@ -27,6 +34,7 @@ async function loadChatHelpers(): Promise<void> {
2734
handleAbortChat,
2835
refreshChatAvatar,
2936
clearPendingQueueItemsForRun,
37+
removeQueuedMessage,
3038
} = await import("./app-chat.ts"));
3139
}
3240

@@ -117,6 +125,7 @@ describe("refreshChatAvatar", () => {
117125
});
118126

119127
afterEach(() => {
128+
resetChatAttachmentPayloadStoreForTest();
120129
vi.unstubAllGlobals();
121130
});
122131

@@ -729,6 +738,75 @@ describe("handleSendChat", () => {
729738
}),
730739
]);
731740
});
741+
742+
it("drops sent attachment payload bytes while keeping the optimistic preview URL", async () => {
743+
vi.stubGlobal(
744+
"URL",
745+
class extends URL {
746+
static createObjectURL = vi.fn(() => "blob:brief");
747+
static revokeObjectURL = vi.fn();
748+
},
749+
);
750+
const request = vi.fn(async (method: string) => {
751+
if (method === "chat.send") {
752+
return { status: "started", runId: "run-1" };
753+
}
754+
throw new Error(`Unexpected request: ${method}`);
755+
});
756+
const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" });
757+
const attachment = registerChatAttachmentPayload({
758+
attachment: {
759+
id: "att-1",
760+
mimeType: "application/pdf",
761+
fileName: "brief.pdf",
762+
sizeBytes: file.size,
763+
},
764+
dataUrl: "data:application/pdf;base64,JVBERi0xLjQK",
765+
file,
766+
});
767+
const host = makeHost({
768+
client: { request } as unknown as ChatHost["client"],
769+
chatAttachments: [attachment],
770+
chatMessage: "summarize",
771+
});
772+
773+
await handleSendChat(host);
774+
775+
expect(getChatAttachmentDataUrl(attachment)).toBeNull();
776+
expect(getChatAttachmentPreviewUrl(attachment)).toBe("blob:brief");
777+
expect(JSON.stringify(host.chatMessages)).not.toContain("JVBERi0xLjQK");
778+
});
779+
780+
it("releases queued attachment payloads when the queued item is removed", async () => {
781+
const revokeObjectURL = vi.fn();
782+
vi.stubGlobal(
783+
"URL",
784+
class extends URL {
785+
static createObjectURL = vi.fn(() => "blob:queued");
786+
static revokeObjectURL = revokeObjectURL;
787+
},
788+
);
789+
const file = new File(["%PDF-1.4\n"], "brief.pdf", { type: "application/pdf" });
790+
const attachment = registerChatAttachmentPayload({
791+
attachment: {
792+
id: "queued-att",
793+
mimeType: "application/pdf",
794+
fileName: "brief.pdf",
795+
sizeBytes: file.size,
796+
},
797+
dataUrl: "data:application/pdf;base64,JVBERi0xLjQK",
798+
file,
799+
});
800+
const host = makeHost({
801+
chatQueue: [{ id: "queued", text: "later", createdAt: 1, attachments: [attachment] }],
802+
});
803+
804+
removeQueuedMessage(host, "queued");
805+
806+
expect(host.chatQueue).toEqual([]);
807+
expect(getChatAttachmentDataUrl(attachment)).toBeNull();
808+
expect(revokeObjectURL).toHaveBeenCalledWith("blob:queued");
809+
});
732810
});
733811

734812
describe("handleAbortChat", () => {

ui/src/ui/app-chat.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { setLastActiveSessionKey } from "./app-last-active-session.ts";
22
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
33
import { resetToolStream } from "./app-tool-stream.ts";
4+
import {
5+
cloneChatAttachmentsMetadata,
6+
discardChatAttachmentDataUrls,
7+
getChatAttachmentDataUrl,
8+
releaseChatAttachmentPayloads,
9+
} from "./chat/attachment-payload-store.ts";
410
import {
511
handleChatDraftChange,
612
handleChatInputHistoryKey,
@@ -147,7 +153,7 @@ function enqueueChatMessage(
147153
id: generateUUID(),
148154
text: trimmed,
149155
createdAt: Date.now(),
150-
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
156+
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
151157
refreshSessions,
152158
localCommandArgs: localCommand?.args,
153159
localCommandName: localCommand?.name,
@@ -173,7 +179,7 @@ function enqueuePendingRunMessage(
173179
text: trimmed,
174180
createdAt: Date.now(),
175181
kind: "steered",
176-
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
182+
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
177183
pendingRunId,
178184
},
179185
];
@@ -223,16 +229,21 @@ async function sendChatMessageNow(
223229
if (ok && opts?.refreshSessions && runId) {
224230
host.refreshSessionsAfterChat.add(runId);
225231
}
232+
if (ok) {
233+
discardChatAttachmentDataUrls(opts?.attachments);
234+
}
226235
return ok;
227236
}
228237

229238
function attachmentSubmitSignature(attachment: ChatAttachment): string {
239+
const dataUrl = getChatAttachmentDataUrl(attachment);
230240
return JSON.stringify([
231241
attachment.id,
232242
attachment.mimeType,
233243
attachment.fileName ?? "",
234-
attachment.dataUrl.length,
235-
attachment.dataUrl.slice(0, 64),
244+
attachment.sizeBytes ?? 0,
245+
dataUrl?.length ?? 0,
246+
dataUrl?.slice(0, 64) ?? "",
236247
]);
237248
}
238249

@@ -300,6 +311,7 @@ async function sendDetachedBtwMessage(
300311
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
301312
host.sessionKey,
302313
);
314+
releaseChatAttachmentPayloads(opts?.attachments);
303315
}
304316
return ok;
305317
}
@@ -334,6 +346,7 @@ export async function steerQueuedChatMessage(host: ChatHost, id: string) {
334346
host.chatQueue = host.chatQueue.map((entry) => (entry.id === id ? item : entry));
335347
return;
336348
}
349+
releaseChatAttachmentPayloads(attachments);
337350
setLastActiveSessionKey(
338351
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
339352
host.sessionKey,
@@ -374,14 +387,22 @@ async function flushChatQueue(host: ChatHost) {
374387
}
375388

376389
export function removeQueuedMessage(host: ChatHost, id: string) {
390+
const removed = host.chatQueue.filter((item) => item.id === id);
377391
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
392+
for (const item of removed) {
393+
releaseChatAttachmentPayloads(item.attachments);
394+
}
378395
}
379396

380397
export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | undefined) {
381398
if (!runId) {
382399
return;
383400
}
401+
const removed = host.chatQueue.filter((item) => item.pendingRunId === runId);
384402
host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId);
403+
for (const item of removed) {
404+
releaseChatAttachmentPayloads(item.attachments);
405+
}
385406
}
386407

387408
export async function handleSendChat(

ui/src/ui/app-render.helpers.node.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,9 @@ describe("switchChatSession", () => {
499499
const state = {
500500
sessionKey: "main",
501501
chatMessage: "draft",
502-
chatAttachments: [{ mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }],
502+
chatAttachments: [
503+
{ id: "att-1", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" },
504+
],
503505
chatMessages: [{ role: "assistant", content: "old" }],
504506
chatToolMessages: [{ id: "tool-1" }],
505507
chatStreamSegments: [{ text: "segment", ts: 1 }],
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { ChatAttachment } from "../ui-types.ts";
2+
3+
type AttachmentPayload = {
4+
dataUrl?: string;
5+
previewUrl?: string;
6+
};
7+
8+
const payloads = new Map<string, AttachmentPayload>();
9+
10+
function createObjectUrl(file: File): string | undefined {
11+
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
12+
return undefined;
13+
}
14+
return URL.createObjectURL(file);
15+
}
16+
17+
function revokeObjectUrl(url: string | undefined): void {
18+
if (!url || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") {
19+
return;
20+
}
21+
URL.revokeObjectURL(url);
22+
}
23+
24+
export function registerChatAttachmentPayload(params: {
25+
attachment: ChatAttachment;
26+
dataUrl: string;
27+
file: File;
28+
}): ChatAttachment {
29+
const previous = payloads.get(params.attachment.id);
30+
revokeObjectUrl(previous?.previewUrl);
31+
const objectUrl = createObjectUrl(params.file);
32+
const previewUrl = objectUrl ?? params.attachment.previewUrl;
33+
payloads.set(params.attachment.id, {
34+
dataUrl: params.dataUrl,
35+
...(previewUrl ? { previewUrl } : {}),
36+
});
37+
return {
38+
...params.attachment,
39+
...(previewUrl ? { previewUrl } : {}),
40+
};
41+
}
42+
43+
export function getChatAttachmentDataUrl(attachment: ChatAttachment): string | null {
44+
return attachment.dataUrl ?? payloads.get(attachment.id)?.dataUrl ?? null;
45+
}
46+
47+
export function getChatAttachmentPreviewUrl(attachment: ChatAttachment): string | null {
48+
return (
49+
attachment.previewUrl ?? payloads.get(attachment.id)?.previewUrl ?? attachment.dataUrl ?? null
50+
);
51+
}
52+
53+
export function cloneChatAttachmentMetadata(attachment: ChatAttachment): ChatAttachment {
54+
const { dataUrl: _dataUrl, ...metadata } = attachment;
55+
return metadata;
56+
}
57+
58+
export function cloneChatAttachmentsMetadata(
59+
attachments: readonly ChatAttachment[],
60+
): ChatAttachment[] {
61+
return attachments.map(cloneChatAttachmentMetadata);
62+
}
63+
64+
export function releaseChatAttachmentPayload(id: string): void {
65+
const payload = payloads.get(id);
66+
if (!payload) {
67+
return;
68+
}
69+
revokeObjectUrl(payload.previewUrl);
70+
payloads.delete(id);
71+
}
72+
73+
export function releaseChatAttachmentPayloads(attachments: readonly ChatAttachment[] = []): void {
74+
for (const attachment of attachments) {
75+
releaseChatAttachmentPayload(attachment.id);
76+
}
77+
}
78+
79+
export function discardChatAttachmentDataUrl(id: string): void {
80+
const payload = payloads.get(id);
81+
if (!payload) {
82+
return;
83+
}
84+
if (payload.previewUrl) {
85+
payloads.set(id, { previewUrl: payload.previewUrl });
86+
return;
87+
}
88+
payloads.delete(id);
89+
}
90+
91+
export function discardChatAttachmentDataUrls(attachments: readonly ChatAttachment[] = []): void {
92+
for (const attachment of attachments) {
93+
discardChatAttachmentDataUrl(attachment.id);
94+
}
95+
}
96+
97+
export function resetChatAttachmentPayloadStoreForTest(): void {
98+
for (const payload of payloads.values()) {
99+
revokeObjectUrl(payload.previewUrl);
100+
}
101+
payloads.clear();
102+
}

0 commit comments

Comments
 (0)