Compare commits
2 Commits
main
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a78a289a | ||
|
|
50f34e7a0f |
@ -14,6 +14,9 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
@ -25,6 +28,11 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
]);
|
||||
|
||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
const normalized = contentType.trim().toLowerCase();
|
||||
@ -40,8 +48,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
|
||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
if (
|
||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "avif" ||
|
||||
extension === "tif" ||
|
||||
extension === "tiff"
|
||||
) {
|
||||
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
|
||||
}
|
||||
|
||||
return "File type should be JPEG, PNG, WebP or SVG";
|
||||
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
|
||||
}
|
||||
|
||||
function isHeicUpload(contentType: string, filename: string) {
|
||||
@ -53,10 +69,20 @@ function isHeicUpload(contentType: string, filename: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function isSharpCompatibilityUpload(contentType: string, filename: string) {
|
||||
const extension = filename.toLowerCase().split(".").pop() || "";
|
||||
return (
|
||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
||||
extension === "avif" ||
|
||||
extension === "tif" ||
|
||||
extension === "tiff"
|
||||
);
|
||||
}
|
||||
|
||||
function toJpegFilename(filename: string) {
|
||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||
if (/\.(heic|heif)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
||||
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
||||
}
|
||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||
@ -76,6 +102,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
}
|
||||
return Buffer.from(output);
|
||||
}
|
||||
|
||||
async function convertSharpCompatibilityImageToJpeg(
|
||||
buffer: Buffer
|
||||
): Promise<Buffer> {
|
||||
const sharp = (await import("sharp")).default;
|
||||
return sharp(buffer, { animated: false, failOn: "none" })
|
||||
.rotate()
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({
|
||||
quality: 92,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
/**
|
||||
* 使用Sharp智能优化图片(保持原格式)
|
||||
* 策略:避免双重压缩,只在需要时优化
|
||||
@ -288,6 +329,38 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (isSharpCompatibilityUpload(contentType, filename)) {
|
||||
try {
|
||||
const convertedBuffer =
|
||||
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
|
||||
logger.info("[Upload API] Compatibility image converted to JPEG", {
|
||||
clientUploadId,
|
||||
originalContentType: contentType,
|
||||
originalSize: uploadBuffer.length,
|
||||
convertedSize: convertedBuffer.length,
|
||||
});
|
||||
uploadBuffer = convertedBuffer;
|
||||
filename = toJpegFilename(filename);
|
||||
convertedFrom = contentType;
|
||||
contentType = "image/jpeg";
|
||||
} catch (conversionError) {
|
||||
logger.warn("[Upload API] Compatibility image conversion failed", {
|
||||
clientUploadId,
|
||||
contentType,
|
||||
size: uploadBuffer.length,
|
||||
error:
|
||||
conversionError instanceof Error
|
||||
? conversionError.message
|
||||
: String(conversionError),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: createUnsupportedImageTypeMessage(contentType, filename),
|
||||
clientUploadId,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
cancelClawdRequest,
|
||||
clearClawdRequestCancellation,
|
||||
isClawdRequestCanceled,
|
||||
registerActiveClawdRequest,
|
||||
@ -259,10 +258,6 @@ export async function POST(request: Request) {
|
||||
const controller = new AbortController();
|
||||
registerActiveClawdRequest(cancelKey, controller, 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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@ -302,7 +297,6 @@ export async function POST(request: Request) {
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
});
|
||||
|
||||
|
||||
@ -101,6 +101,10 @@ import {
|
||||
normalizeCanvasSkillTags,
|
||||
type CanvasSkillTag,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
import {
|
||||
isClawdTransportInterruption,
|
||||
shouldRestoreClawdComposerAfterFailure,
|
||||
} from "@/lib/openclaw-chat/request-lifecycle";
|
||||
import {
|
||||
isCanvasImageReadyForAi,
|
||||
isCanvasImageUploadPending,
|
||||
@ -411,6 +415,19 @@ function isClawdChatMessage(message: unknown) {
|
||||
const CLAWD_TABLE_ACCEPT =
|
||||
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
|
||||
const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
|
||||
const CLAWD_IMAGE_ACCEPT =
|
||||
".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff";
|
||||
const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif",
|
||||
"tif",
|
||||
"tiff",
|
||||
]);
|
||||
const CLAWD_TABLE_MAX_FILES = 3;
|
||||
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const CLAWD_PROCESSING_HINT =
|
||||
@ -440,6 +457,13 @@ function isClawdTableFile(file: File) {
|
||||
);
|
||||
}
|
||||
|
||||
function isClawdImageFile(file: File) {
|
||||
return (
|
||||
file.type.startsWith("image/") ||
|
||||
CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeClawdComposerText(value: string) {
|
||||
return String(value || "")
|
||||
.replace(/\u200b/g, " ")
|
||||
@ -11199,8 +11223,7 @@ export function CanvasChatPanel({
|
||||
});
|
||||
const responseData = await response.json().catch(() => null);
|
||||
if (response.status === 499 || responseData?.canceled === true) {
|
||||
canceledClawdMessageIdsRef.current.add(userMessageId);
|
||||
throw new DOMException("Claw request canceled", "AbortError");
|
||||
throw new Error("Claw request interrupted");
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error("[CanvasChat] Claw API returned error:", {
|
||||
@ -11251,10 +11274,13 @@ export function CanvasChatPanel({
|
||||
void mutateClawdHistory();
|
||||
hasHydratedHistoryRef.current = true;
|
||||
} catch (error) {
|
||||
const wasCanceled =
|
||||
canceledClawdMessageIdsRef.current.has(userMessageId) ||
|
||||
(error instanceof Error && error.name === "AbortError");
|
||||
if (wasCanceled) {
|
||||
const explicitlyCanceled =
|
||||
canceledClawdMessageIdsRef.current.has(userMessageId);
|
||||
if (
|
||||
shouldRestoreClawdComposerAfterFailure({
|
||||
explicitlyCanceled,
|
||||
})
|
||||
) {
|
||||
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
||||
pendingClawdRequestRestoreRef.current = null;
|
||||
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
|
||||
@ -11263,44 +11289,11 @@ export function CanvasChatPanel({
|
||||
return;
|
||||
}
|
||||
console.error("[CanvasChat] Claw request failed:", error);
|
||||
pendingClawdSubmissionRef.current = null;
|
||||
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
|
||||
recentClawdSubmissionRef.current = null;
|
||||
writeStoredClawdComposerSubmissionSnapshot(null);
|
||||
}
|
||||
setMessages((previousMessages) =>
|
||||
previousMessages.filter((message) => message.id !== userMessageId)
|
||||
toast.error(
|
||||
isClawdTransportInterruption(error)
|
||||
? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
|
||||
: CLAW_UPGRADE_RETRY_MESSAGE
|
||||
);
|
||||
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 {
|
||||
pendingClawdSubmissionRef.current = null;
|
||||
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
|
||||
@ -11933,7 +11926,7 @@ export function CanvasChatPanel({
|
||||
width?: number;
|
||||
height?: number;
|
||||
} | null> => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
if (!isClawdImageFile(file)) {
|
||||
toast.error("请选择图片文件");
|
||||
return null;
|
||||
}
|
||||
@ -11963,7 +11956,15 @@ export function CanvasChatPanel({
|
||||
}
|
||||
: undefined);
|
||||
if (!response.ok) {
|
||||
throw new Error("上传失败");
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
details?: string;
|
||||
} | null;
|
||||
throw new Error(
|
||||
payload?.error?.trim() ||
|
||||
payload?.details?.trim() ||
|
||||
"图片上传失败"
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@ -11981,7 +11982,11 @@ export function CanvasChatPanel({
|
||||
assetId,
|
||||
};
|
||||
} catch (_error) {
|
||||
toast.error("图片上传失败");
|
||||
toast.error(
|
||||
_error instanceof Error && _error.message
|
||||
? _error.message
|
||||
: "图片上传失败"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||
@ -12316,11 +12321,9 @@ export function CanvasChatPanel({
|
||||
|
||||
if (isClawdMode) {
|
||||
const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file));
|
||||
const imageFiles = selectedFiles.filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
const imageFiles = selectedFiles.filter((file) => isClawdImageFile(file));
|
||||
const unsupportedFiles = selectedFiles.filter(
|
||||
(file) => !isClawdTableFile(file) && !file.type.startsWith("image/")
|
||||
(file) => !isClawdTableFile(file) && !isClawdImageFile(file)
|
||||
);
|
||||
|
||||
unsupportedFiles.forEach((file) => {
|
||||
@ -16122,7 +16125,7 @@ export function CanvasChatPanel({
|
||||
type="file"
|
||||
accept={
|
||||
isClawdMode
|
||||
? `image/*,${CLAWD_TABLE_ACCEPT}`
|
||||
? `${CLAWD_IMAGE_ACCEPT},${CLAWD_TABLE_ACCEPT}`
|
||||
: supportsAtlasVideoReference
|
||||
? "image/*,video/mp4,video/quicktime,video/webm"
|
||||
: "image/*"
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
@ -109,6 +109,11 @@ export function getContentType(filename: string): string {
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
svg: "image/svg+xml",
|
||||
psd: "image/vnd.adobe.photoshop",
|
||||
psb: "application/octet-stream",
|
||||
|
||||
@ -13,6 +13,7 @@ export default defineConfig({
|
||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||
],
|
||||
fullyParallel: true,
|
||||
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