fix: keep sent Claw messages out of composer
This commit is contained in:
parent
c889bfc381
commit
50f34e7a0f
@ -15,7 +15,6 @@ import {
|
|||||||
} from "@/lib/openclaw-chat/config";
|
} from "@/lib/openclaw-chat/config";
|
||||||
import {
|
import {
|
||||||
buildClawdCancelKey,
|
buildClawdCancelKey,
|
||||||
cancelClawdRequest,
|
|
||||||
clearClawdRequestCancellation,
|
clearClawdRequestCancellation,
|
||||||
isClawdRequestCanceled,
|
isClawdRequestCanceled,
|
||||||
registerActiveClawdRequest,
|
registerActiveClawdRequest,
|
||||||
@ -259,10 +258,6 @@ export async function POST(request: Request) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||||
const handleRequestAbort = () => {
|
|
||||||
cancelClawdRequest(cancelKey);
|
|
||||||
};
|
|
||||||
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -302,7 +297,6 @@ export async function POST(request: Request) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
|
||||||
unregisterActiveClawdRequest(cancelKey);
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -101,6 +101,10 @@ import {
|
|||||||
normalizeCanvasSkillTags,
|
normalizeCanvasSkillTags,
|
||||||
type CanvasSkillTag,
|
type CanvasSkillTag,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
|
import {
|
||||||
|
isClawdTransportInterruption,
|
||||||
|
shouldRestoreClawdComposerAfterFailure,
|
||||||
|
} from "@/lib/openclaw-chat/request-lifecycle";
|
||||||
import {
|
import {
|
||||||
isCanvasImageReadyForAi,
|
isCanvasImageReadyForAi,
|
||||||
isCanvasImageUploadPending,
|
isCanvasImageUploadPending,
|
||||||
@ -11199,8 +11203,7 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
const responseData = await response.json().catch(() => null);
|
const responseData = await response.json().catch(() => null);
|
||||||
if (response.status === 499 || responseData?.canceled === true) {
|
if (response.status === 499 || responseData?.canceled === true) {
|
||||||
canceledClawdMessageIdsRef.current.add(userMessageId);
|
throw new Error("Claw request interrupted");
|
||||||
throw new DOMException("Claw request canceled", "AbortError");
|
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error("[CanvasChat] Claw API returned error:", {
|
console.error("[CanvasChat] Claw API returned error:", {
|
||||||
@ -11251,10 +11254,13 @@ export function CanvasChatPanel({
|
|||||||
void mutateClawdHistory();
|
void mutateClawdHistory();
|
||||||
hasHydratedHistoryRef.current = true;
|
hasHydratedHistoryRef.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wasCanceled =
|
const explicitlyCanceled =
|
||||||
canceledClawdMessageIdsRef.current.has(userMessageId) ||
|
canceledClawdMessageIdsRef.current.has(userMessageId);
|
||||||
(error instanceof Error && error.name === "AbortError");
|
if (
|
||||||
if (wasCanceled) {
|
shouldRestoreClawdComposerAfterFailure({
|
||||||
|
explicitlyCanceled,
|
||||||
|
})
|
||||||
|
) {
|
||||||
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
||||||
pendingClawdRequestRestoreRef.current = null;
|
pendingClawdRequestRestoreRef.current = null;
|
||||||
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
|
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
|
||||||
@ -11263,44 +11269,11 @@ export function CanvasChatPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("[CanvasChat] Claw request failed:", error);
|
console.error("[CanvasChat] Claw request failed:", error);
|
||||||
pendingClawdSubmissionRef.current = null;
|
toast.error(
|
||||||
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
|
isClawdTransportInterruption(error)
|
||||||
recentClawdSubmissionRef.current = null;
|
? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
|
||||||
writeStoredClawdComposerSubmissionSnapshot(null);
|
: CLAW_UPGRADE_RETRY_MESSAGE
|
||||||
}
|
|
||||||
setMessages((previousMessages) =>
|
|
||||||
previousMessages.filter((message) => message.id !== userMessageId)
|
|
||||||
);
|
);
|
||||||
toast.error(CLAW_UPGRADE_RETRY_MESSAGE);
|
|
||||||
if (!isProgrammaticClawdQuickSend) {
|
|
||||||
setClawdTableFiles(tableFilesSnapshot);
|
|
||||||
setAttachments(attachmentsSnapshot);
|
|
||||||
if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) {
|
|
||||||
setSelectedSkillTagId(clawdSkillTagForRequest.id);
|
|
||||||
pendingSelectedClawdSkillTagRef.current = clawdSkillTagForRequest;
|
|
||||||
}
|
|
||||||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
|
||||||
const currentEditorText = (editableDiv?.textContent || "")
|
|
||||||
.replace(/\u200b/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
if (!currentEditorText) {
|
|
||||||
setInput(textContent);
|
|
||||||
if (editableDiv) {
|
|
||||||
editableDiv.textContent = textContent;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
attachmentsSnapshot.forEach((attachment, index) => {
|
|
||||||
if (!attachment.url) return;
|
|
||||||
insertImageTagInEditor(
|
|
||||||
attachment.url,
|
|
||||||
normalizeChatAttachmentName(attachment.name),
|
|
||||||
index
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
pendingClawdSubmissionRef.current = null;
|
pendingClawdSubmissionRef.current = null;
|
||||||
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
||||||
|
|||||||
16
lib/openclaw-chat/request-lifecycle.ts
Normal file
16
lib/openclaw-chat/request-lifecycle.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function shouldRestoreClawdComposerAfterFailure({
|
||||||
|
explicitlyCanceled,
|
||||||
|
}: {
|
||||||
|
explicitlyCanceled: boolean;
|
||||||
|
}) {
|
||||||
|
return explicitlyCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClawdTransportInterruption(error: unknown) {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
error.name === "AbortError" ||
|
||||||
|
/aborted|failed to fetch|fetch failed|network|interrupted/i.test(error.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||||
/routes\/model-classification\.test\.ts/,
|
/routes\/model-classification\.test\.ts/,
|
||||||
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
],
|
],
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
|||||||
51
tests/routes/clawd-request-lifecycle.test.ts
Normal file
51
tests/routes/clawd-request-lifecycle.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
isClawdTransportInterruption,
|
||||||
|
shouldRestoreClawdComposerAfterFailure,
|
||||||
|
} from "@/lib/openclaw-chat/request-lifecycle";
|
||||||
|
|
||||||
|
function readSource(path: string) {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Claw request lifecycle", () => {
|
||||||
|
test("restores the composer only after an explicit user cancellation", () => {
|
||||||
|
expect(
|
||||||
|
shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: true })
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: false })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes browser and network interruptions without treating them as edits", () => {
|
||||||
|
expect(
|
||||||
|
isClawdTransportInterruption(
|
||||||
|
new DOMException("The operation was aborted", "AbortError")
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(isClawdTransportInterruption(new Error("fetch failed"))).toBe(true);
|
||||||
|
expect(isClawdTransportInterruption(new Error("provider unavailable"))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not cancel the upstream request when the browser connection closes", () => {
|
||||||
|
const source = readSource("app/api/canvas/clawd-chat/route.ts");
|
||||||
|
|
||||||
|
expect(source).not.toContain('request.signal.addEventListener("abort"');
|
||||||
|
expect(source).not.toContain("cancelClawdRequest(cancelKey)");
|
||||||
|
expect(source).toContain("registerActiveClawdRequest(cancelKey");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not classify every AbortError as an explicit stop action", () => {
|
||||||
|
const source = readSource("components/canvas/CanvasChatPanel.tsx");
|
||||||
|
|
||||||
|
expect(source).toContain("shouldRestoreClawdComposerAfterFailure");
|
||||||
|
expect(source).not.toContain(
|
||||||
|
'(error instanceof Error && error.name === "AbortError")'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user