Compare commits

..

5 Commits

46 changed files with 1016 additions and 1877 deletions

View File

@ -75,8 +75,6 @@ export function LocalAiImagePipelineSections({
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
@ -105,7 +103,6 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
@ -1466,112 +1463,6 @@ export function LocalAiImagePipelineSections({
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>

View File

@ -12,7 +12,6 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
rmbg: false,
enhance: false,
"enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false,
extend: false,
"watermark-remove": false,

View File

@ -1,7 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config";
import type {
EnhanceProKreaConfig,
@ -334,8 +333,6 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
@ -378,7 +375,6 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;

View File

@ -200,11 +200,6 @@ import {
MIN_CANVAS_IMAGE_TASK_CONCURRENCY,
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
defaultCanvasLocalEditConfig,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
type CanvasSkillCategory,
type CanvasSkillTag,
@ -1061,6 +1056,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imagePsdTemplateReplace: ImageIcon,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,
@ -1906,13 +1902,6 @@ export function AdminSettingsClient() {
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
setting?: { value?: unknown };
}>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher);
const {
data: canvasLocalEditConfigData,
mutate: mutateCanvasLocalEditConfig,
} = useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`,
fetcher
);
const {
data: canvasImageTaskQueueConfigData,
mutate: mutateCanvasImageTaskQueueConfig,
@ -2625,9 +2614,6 @@ export function AdminSettingsClient() {
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
useState("");
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
defaultCanvasLocalEditConfig()
);
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
@ -2872,13 +2858,6 @@ export function AdminSettingsClient() {
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
}, [aiWashConfigData]);
useEffect(() => {
if (!canvasLocalEditConfigData) return;
setCanvasLocalEditConfig(
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
);
}, [canvasLocalEditConfigData]);
const enhanceProApiKeysDraftCount = useMemo(
() =>
normalizeEnhanceProKreaConfig({
@ -5324,34 +5303,6 @@ export function AdminSettingsClient() {
}
};
const handleSaveCanvasLocalEditConfig = async () => {
try {
const normalizedConfig =
normalizeCanvasLocalEditConfig(canvasLocalEditConfig);
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: CANVAS_LOCAL_EDIT_CONFIG_KEY,
value: normalizedConfig,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
String(payload?.message || payload?.error || "保存失败")
);
}
mutateCanvasLocalEditConfig();
toast.success("局部编辑模型配置保存成功");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存局部编辑模型配置失败"
);
}
};
const handleDeleteEnhanceProPoolKey = (index: number) => {
setEnhanceProConfig((prev) => ({
...prev,
@ -18777,7 +18728,6 @@ export function AdminSettingsClient() {
angleEditConfig={angleEditConfig}
aiWashConfig={aiWashConfig}
aiWashModeIconUploading={aiWashModeIconUploading}
canvasLocalEditConfig={canvasLocalEditConfig}
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
enhanceConfig={enhanceConfig}
@ -18798,7 +18748,6 @@ export function AdminSettingsClient() {
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
handleSaveAiWashConfig={handleSaveAiWashConfig}
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
handleSaveExtendConfig={handleSaveExtendConfig}
@ -18820,7 +18769,6 @@ export function AdminSettingsClient() {
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
setAngleEditConfig={setAngleEditConfig}
setAiWashConfig={setAiWashConfig}
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
setEnhanceConfig={setEnhanceConfig}
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
setEnhanceProConfig={setEnhanceProConfig}

View File

@ -3,7 +3,6 @@ import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
HeadObjectCommand,
ListPartsCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
@ -22,22 +21,13 @@ import {
maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget,
} from "@/lib/storage/service";
import {
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
OPENCLAW_CHAT_FILE_MAX_BYTES,
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
} from "@/lib/openclaw-chat/media-attachments";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) {
@ -86,18 +76,6 @@ const CompleteSchema = z.object({
clientUploadId: z.string().max(128).optional(),
});
const ResumeSchema = z.object({
action: z.literal("resume"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
purpose: z.string().max(64).optional(),
clientUploadId: z.string().max(128).optional(),
});
const AbortSchema = z.object({
action: z.literal("abort"),
key: z.string().min(1).max(512),
@ -107,7 +85,6 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema,
ResumeSchema,
CompleteSchema,
AbortSchema,
]);
@ -135,49 +112,23 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) {
const error = new Error("Invalid upload object key");
error.name = "InvalidUploadObjectKeyError";
throw error;
throw new Error("Invalid upload object key");
}
return normalized;
}
function isAllowedDirectUpload(
contentType: string,
purpose: string | undefined,
filename: string
) {
function isAllowedDirectUpload(contentType: string, purpose?: string) {
return (
allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
allowedOpenClawVideoUploadTypes.has(contentType)) ||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({ name: filename, type: contentType })
)) ||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
allowedOpenClawVideoUploadTypes.has(contentType))
);
}
function getPurposeMaxBytes(purpose?: string) {
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
}
if (
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
) {
return DIRECT_UPLOAD_MAX_BYTES;
}
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
}
function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
return 64 * 1024 * 1024;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
return 16 * 1024 * 1024;
}
async function createPartUrls(params: {
@ -185,24 +136,24 @@ async function createPartUrls(params: {
bucket: string;
key: string;
uploadId: string;
partNumbers: number[];
partCount: number;
}) {
return Promise.all(
params.partNumbers.map(async (partNumber) => {
const urls = [];
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
const command = new UploadPartCommand({
Bucket: params.bucket,
Key: params.key,
UploadId: params.uploadId,
PartNumber: partNumber,
});
return {
urls.push({
partNumber,
url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}),
};
})
);
});
}
return urls;
}
export async function POST(request: Request) {
@ -265,7 +216,7 @@ export async function POST(request: Request) {
payload.filename
);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
if (!isAllowedDirectUpload(contentType, payload.purpose)) {
logger.warn("unsupported_direct_upload_type", {
contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -276,17 +227,11 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
},
{ status: 400 }
);
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json(
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
{ status: 413 }
);
}
const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey(
@ -294,13 +239,7 @@ export async function POST(request: Request) {
userUploadPrefix(session.user.id)
);
const multipartThreshold =
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DEFAULT_PART_SIZE_BYTES
: MULTIPART_THRESHOLD_BYTES;
if (payload.size < multipartThreshold) {
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
const presignStartedAt = Date.now();
const command = new PutObjectCommand({
Bucket: bucket,
@ -360,7 +299,7 @@ export async function POST(request: Request) {
bucket,
key,
uploadId,
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
partCount,
});
logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt),
@ -384,98 +323,6 @@ export async function POST(request: Request) {
});
}
if (payload.action === "resume") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
}
const expectedPartSize = computePartSize(payload.size);
if (payload.partSize !== expectedPartSize) {
return NextResponse.json(
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
try {
const listed = await client.send(
new ListPartsCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
MaxParts: 1000,
})
);
const uploadedParts = (listed.Parts || []).flatMap((part) => {
if (!part.ETag || typeof part.PartNumber !== "number") return [];
return [
{
ETag: part.ETag,
PartNumber: part.PartNumber,
size: Number(part.Size || 0),
},
];
});
const uploadedPartNumbers = new Set(
uploadedParts.map((part) => part.PartNumber)
);
const partCount = Math.ceil(payload.size / expectedPartSize);
const missingPartNumbers = Array.from(
{ length: partCount },
(_, index) => index + 1
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
const parts = await createPartUrls({
client,
bucket,
key,
uploadId: payload.uploadId,
partNumbers: missingPartNumbers,
});
logger.info("direct_upload_multipart_resumed", {
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
uploadedPartCount: uploadedParts.length,
missingPartCount: missingPartNumbers.length,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
mode: "multipart",
key,
uploadId: payload.uploadId,
partSize: expectedPartSize,
partCount,
parts,
uploadedParts,
resumed: true,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
} catch (error) {
const head = await client
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
.catch(() => null);
if (head?.ContentLength === payload.size) {
return NextResponse.json({
mode: "completed",
key,
clientUploadId: payload.clientUploadId || null,
});
}
logger.warn("direct_upload_resume_session_missing", error, {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
}
if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now();
@ -496,18 +343,15 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
if (!isAllowedDirectUpload(contentType, payload.purpose)) {
return NextResponse.json(
{
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
},
{ status: 400 }
);
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
}
if (payload.uploadId) {
const completeMultipartStartedAt = Date.now();
@ -553,10 +397,8 @@ export async function POST(request: Request) {
const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize !== payload.size) {
throw new Error(
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
if (actualSize !== null && actualSize <= 0) {
throw new Error("Uploaded object is empty");
}
const url = buildS3PublicUrl(target.config, key);
@ -565,42 +407,20 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "file"
: resolvedContentType.startsWith("video/")
? "video"
: "image",
mediaType: resolvedContentType.startsWith("video/") ? "video" : "image",
sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "upload",
status: "ready",
originalUrl: url,
previewUrl:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
thumbnailUrl:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
previewUrl: url,
thumbnailUrl: url,
mimeType: resolvedContentType,
displayName: payload.filename,
sourceTable:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "direct_upload",
metadata: {
pathname: key,
@ -648,13 +468,7 @@ export async function POST(request: Request) {
error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error),
},
{
status:
error instanceof Error &&
error.name === "InvalidUploadObjectKeyError"
? 403
: 500,
}
{ status: 500 }
);
}
}

View File

@ -53,10 +53,6 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig,
@ -130,12 +126,6 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({
@ -379,10 +369,6 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value);
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value);
}

View File

@ -13,7 +13,6 @@ import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
const requestSchema = z.object({
prompt: z.string().min(1).max(5000),
@ -53,16 +52,6 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
const availability = getAiModelFrontendAvailability(selectedModel.model);
if (availability.selectable === false) {
return NextResponse.json(
{
success: false,
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
},
{ status: 400 }
);
}
const imageSize = body.imageSize?.trim() || "2K";
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {

View File

@ -9,6 +9,7 @@ import {
} from "@/lib/openclaw-chat/folder-archive";
import {
OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
} from "@/lib/openclaw-chat/media-attachments";
import { createLogger } from "@/lib/observability/logger";
import {
@ -20,7 +21,6 @@ export const runtime = "nodejs";
export const maxDuration = 600;
const logger = createLogger("openclaw-folder-upload");
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
function isUploadedFile(value: FormDataEntryValue): value is File {
return (
@ -68,9 +68,9 @@ export async function POST(request: Request) {
}
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
return NextResponse.json(
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
{ error: "文件夹总大小不能超过 300MB" },
{ status: 413 }
);
}

View File

@ -564,7 +564,7 @@ export async function POST(request: Request) {
(attachment.type === "video"
? "视频附件"
: attachment.type === "file"
? "文件附件"
? "文件夹压缩包.zip"
: "图片附件"),
url: attachment.url,
contentType:
@ -572,11 +572,11 @@ export async function POST(request: Request) {
(attachment.type === "video"
? "video/mp4"
: attachment.type === "file"
? "application/octet-stream"
? "application/zip"
: "image/jpeg"),
}));
await persistPendingClawdTask({
const persistedTask = await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
@ -587,6 +587,22 @@ export async function POST(request: Request) {
skillTagName,
processingHint,
});
if (
persistedTask &&
persistedTask.assistantMessageId !== assistantMessageId
) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: persistedTask.assistantMessageId,
status: persistedTask.status,
processingHint:
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
const userProfile = await getUserById({ id: session.user.id }).catch(
() => null

View File

@ -21,13 +21,7 @@ import {
} from "@/lib/canvas/scale-adjust-semantic";
import {
getPointsSystemConfig,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { uploadFile } from "@/lib/storage/service";
@ -624,23 +618,7 @@ export async function POST(request: NextRequest) {
);
}
const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([
getPointsSystemConfig(),
getSiteSettings().catch(() => null),
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
]);
const localEditConfig = normalizeCanvasLocalEditConfig(
localEditConfigSetting?.value
);
const siteDetailDefaultModelId =
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
: "";
const configuredLocalEditModelId = localEditConfig.modelId;
const configuredScaleAdjustModelId =
localEditConfig.scaleAdjustModelId ||
siteDetailDefaultModelId ||
SCALE_ADJUST_REPAIR_MODEL_REF;
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
@ -809,7 +787,7 @@ export async function POST(request: NextRequest) {
operation,
requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: configuredScaleAdjustModelId,
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied,
@ -836,7 +814,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: configuredScaleAdjustModelId,
model: SCALE_ADJUST_REPAIR_MODEL_REF,
negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt,
@ -869,7 +847,7 @@ export async function POST(request: NextRequest) {
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel: configuredScaleAdjustModelId,
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
sourceImageUrl,
},
}
@ -898,7 +876,7 @@ export async function POST(request: NextRequest) {
...(hasPositionMove
? {
scaleAdjustModelRelight: {
model: configuredScaleAdjustModelId,
model: SCALE_ADJUST_REPAIR_MODEL_REF,
overlayImageUrl: overlayUpload.url,
quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({
@ -997,7 +975,6 @@ export async function POST(request: NextRequest) {
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null,
resolvedAspectRatio,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length,
@ -1009,7 +986,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: requestedModel || configuredLocalEditModelId || undefined,
model: requestedModel || undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt,

View File

@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { getSystemSetting, getUserById } from "@/lib/db/queries";
import { getSystemSetting } from "@/lib/db/queries";
import {
findStyleProImageModelByRef,
getStyleProImageModelDisplayEntries,
@ -26,16 +26,10 @@ export async function GET() {
);
}
const currentUser = await getUserById({ id: session.user.id });
const allowHiddenModels =
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
const [displayEntries, entries, realisticModelEntries, setting] =
await Promise.all([
getStyleProImageModelDisplayEntries({
includeHidden: allowHiddenModels,
}),
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
getStyleProImageModelDisplayEntries({ includeHidden: false }),
getStyleProImageModelEntries({ includeHidden: false }),
getStyleProImageModelEntries({ includeHidden: true }),
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
]);

View File

@ -192,11 +192,6 @@ import {
getAgentWorkflowPageFeatureSettings,
sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config";
import {
loadCanvasChatPanelModule,
preloadCanvasChatPanel,
} from "@/components/canvas/canvas-chat-panel-loader";
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
import { cn, fetcher } from "@/lib/utils";
import {
buildVideoWorkflowCanvasNode,
@ -318,7 +313,7 @@ const CanvasWorkspace = dynamic(
const CanvasChatPanel = dynamic(
() =>
loadCanvasChatPanelModule().then(
import("@/components/canvas/CanvasChatPanel").then(
(module) => module.CanvasChatPanel
),
{
@ -2034,7 +2029,7 @@ export default function CanvasEditorLegacyPage({
);
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [floatingPanelWidth, setFloatingPanelWidth] =
useState(FLOATING_PANEL_WIDTH);
const [
@ -3355,15 +3350,6 @@ export default function CanvasEditorLegacyPage({
};
}, [checkCanvasAuthSession]);
useEffect(() => {
preloadCanvasChatPanel();
preloadCanvasChatData(id);
return scheduleCanvasIdleTask(() => {
void import("@/components/canvas/CanvasFileListPanel");
}, 700);
}, [id]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setAreSecondaryPanelsReady(true);

View File

@ -2798,9 +2798,9 @@ export default function CanvasModel3DPage() {
]);
useEffect(() => {
setWorldFirstPerson(
workspaceMode === "world" && worldViewportMode === "splat"
);
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
setWorldFirstPerson(false);
}
}, [workspaceMode, worldViewportMode]);
return (
@ -4008,7 +4008,6 @@ export default function CanvasModel3DPage() {
previewImageUrl={currentPreviewImage}
resetViewToken={viewerResetToken}
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
showInfoOverlay={false}
showMovementControls={
worldViewportMode === "splat" || worldViewportMode === "mesh"
}

View File

@ -1238,7 +1238,7 @@ function DetailResultLocalEditDialog({
);
if (unchangedRegion) {
setActiveRegionId(unchangedRegion.id);
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
return;
}
} else {

View File

@ -610,9 +610,7 @@ export function AiWashWorkspace({
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
);
const selectedPresetHasExecutableModel = Boolean(
selectedExecutionState?.effectiveModelRef &&
selectedExecutionState?.resolvedModel &&
selectedExecutionState.resolvedModel.selectable !== false
selectedExecutionState?.effectiveModelRef
);
const shouldShowUserModelSelector = Boolean(
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
@ -759,10 +757,7 @@ export function AiWashWorkspace({
return;
}
if (!selectedPresetHasExecutableModel) {
toast.error(
selectedExecutionState?.resolvedModel?.disabledReason ||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
);
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
return;
}
@ -823,15 +818,6 @@ export function AiWashWorkspace({
if (!executionState.effectiveModelRef) {
throw new Error(`${preset.title} 未配置可用模型`);
}
if (
!executionState.resolvedModel ||
executionState.resolvedModel.selectable === false
) {
throw new Error(
executionState.resolvedModel?.disabledReason ||
`${preset.title} 配置的模型不可用`
);
}
const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST",
@ -839,9 +825,7 @@ export function AiWashWorkspace({
body: JSON.stringify({
prompt: executionState.resolvedPrompt,
referenceImage: requestReferenceImage,
selectedModelId:
executionState.resolvedModel.id ||
executionState.effectiveModelRef,
selectedModelId: executionState.effectiveModelRef,
imageSize: config.imageSize,
promptExtend: config.promptExtend,
title: preset.title,

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ import {
AlertTriangle,
Check,
Download,
FileImage,
Flag,
GitBranch,
Heart,
@ -715,8 +716,14 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLocalEdit"
);
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
const selectedImagePsdTemplateReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -2529,6 +2536,29 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{selectedImagePsdTemplateReplaceEnabled &&
selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
}
title={selectedImagePsdTemplateReplaceItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={FileImage}
item={selectedImagePsdTemplateReplaceItem}
/>
{selectedImagePsdTemplateReplaceItem.label}
</Button>
) : null}
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"

View File

@ -277,10 +277,6 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
const key = normalizeImageIdentityUrl(url);
if (key) urlKeys.add(key);
};
const addUrlsFromTableText = (tableText: string) => {
extractAssistantImageUrls(tableText).forEach(addUrl);
extractPlainImageUrls(tableText).forEach(addUrl);
};
for (let index = 0; index < lines.length - 1; index += 1) {
const line = lines[index] || "";
@ -301,36 +297,12 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
endIndex += 1;
}
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
const tableText = lines.slice(index, endIndex).join("\n");
extractAssistantImageUrls(tableText).forEach(addUrl);
extractPlainImageUrls(tableText).forEach(addUrl);
index = endIndex - 1;
}
// Claw may emit pipe tables without a separator row while streaming. Treat a
// contiguous block of at least two pipe rows as a table for media deduping.
for (let index = 0; index < lines.length; ) {
if (!isMarkdownTableRow(lines[index] || "")) {
index += 1;
continue;
}
let endIndex = index + 1;
while (
endIndex < lines.length &&
isMarkdownTableRow(lines[endIndex] || "")
) {
endIndex += 1;
}
if (endIndex - index >= 2) {
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
}
index = endIndex;
}
const htmlTablePattern = /<table\b[^>]*>[\s\S]*?<\/table>/gi;
let htmlTableMatch: RegExpExecArray | null = null;
while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) {
addUrlsFromTableText(htmlTableMatch[0] || "");
}
return urlKeys;
}

View File

@ -1,4 +1,5 @@
import type { Attachment, ChatMessage } from "@/lib/types";
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
export const SESSION_TITLE_MAX_LEN = 18;
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
@ -80,6 +81,9 @@ export function normalizeChatMediaType(value?: string | null): string {
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
return normalized;
}
if (isOpenClawChatFileAttachment("", normalized)) {
return normalized;
}
return "image/jpeg";
}

View File

@ -278,10 +278,6 @@ function configureMaterialTextures(
geometry.setAttribute("uv2", geometry.attributes.uv);
}
if (geometry?.attributes.color && "vertexColors" in materialRecord) {
materialRecord.vertexColors = true;
}
for (const key of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[key];
if (!texture?.isTexture) continue;
@ -291,36 +287,12 @@ function configureMaterialTextures(
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
}
texture.anisotropy = Math.max(Number(texture.anisotropy || 1), 8);
texture.needsUpdate = true;
}
material.needsUpdate = true;
}
function cloneSceneWithIndependentMaterials(scene: Object3D) {
const clonedScene = scene.clone(true);
clonedScene.traverse((node) => {
if (!isMesh(node)) return;
const sourceMaterials = Array.isArray(node.material)
? node.material
: [node.material];
const clonedMaterials = sourceMaterials.map((material) => {
const clonedMaterial = material.clone();
configureMaterialTextures(clonedMaterial, node.geometry);
return clonedMaterial;
});
node.material = Array.isArray(node.material)
? clonedMaterials
: clonedMaterials[0] || node.material;
});
return clonedScene;
}
function resolveSceneGroundY(
scene: Object3D,
modelRotation?: [number, number, number]
@ -377,10 +349,7 @@ function ModelScene({
loader.setKTX2Loader(ktx2Loader);
},
);
const scene = useMemo(
() => cloneSceneWithIndependentMaterials(model.scene),
[model.scene]
);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
const modelRotationKey = modelRotation?.join("|") || "";
const groundY = useMemo(
@ -405,20 +374,6 @@ function ModelScene({
}
}, [layerVisibility, meta.meshRefs]);
useEffect(() => {
return () => {
scene.traverse((node) => {
if (!isMesh(node)) return;
const materials = Array.isArray(node.material)
? node.material
: [node.material];
for (const material of materials) {
material.dispose();
}
});
};
}, [scene]);
useEffect(() => {
for (const { mesh } of meta.meshRefs) {
mesh.castShadow = true;
@ -428,6 +383,7 @@ function ModelScene({
: [mesh.material];
for (const material of materials) {
if (!material) continue;
configureMaterialTextures(material, mesh.geometry);
if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe;
}
@ -696,7 +652,7 @@ export function Model3DViewer({
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08;
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
setGroundY((previousGroundY) =>
Math.abs(previousGroundY - nextGroundY) < 0.001

View File

@ -13,6 +13,7 @@ import {
Download,
Eraser,
Expand,
FileImage,
FlipHorizontal2,
FlipVertical2,
Globe2,
@ -97,7 +98,6 @@ import {
createLocalEditRegionId,
getLocalEditRegionsFromMetadata,
getNormalizedRectOverlayStyle,
hasScaleAdjustTargetChanged,
isHttpImageUrl,
LOCAL_EDIT_COLORS,
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
@ -131,10 +131,15 @@ import {
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import {
createCanvasPsdTemplateLaunchToken,
saveCanvasPsdTemplateLaunchPayload,
} from "@/lib/canvas/psd-template-launch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -220,9 +225,6 @@ import {
} from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
@ -1351,6 +1353,10 @@ export function ImageNode({
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePsdTemplateReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
@ -1423,6 +1429,8 @@ export function ImageNode({
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
// 也必须默认展示,避免历史配置把新功能吞掉。
const imageLocalEditEnabled = true;
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
const imagePsdTemplateReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
@ -1488,6 +1496,7 @@ export function ImageNode({
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
@ -1523,6 +1532,7 @@ export function ImageNode({
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imagePsdTemplateReplaceEnabled ||
imageNoteEnabled ||
hasCanvasAgentAnnotations ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
@ -1681,6 +1691,37 @@ export function ImageNode({
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
useSWR<{
success?: boolean;
templates?: Array<{ brandName?: string | null }>;
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
const availablePsdTemplateBrands = useMemo(
() =>
Array.from(
new Set(
(psdTemplateSummary?.templates || [])
.map((template) => String(template?.brandName || "").trim())
.filter(Boolean)
)
),
[psdTemplateSummary?.templates]
);
useEffect(() => {
if (
psdTemplateDialogOpen &&
!psdTemplateBrandName.trim() &&
availablePsdTemplateBrands.length === 1
) {
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
}
}, [
availablePsdTemplateBrands,
psdTemplateBrandName,
psdTemplateDialogOpen,
]);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -2053,7 +2094,6 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment
);
@ -4423,11 +4463,15 @@ export function ImageNode({
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
return;
}
const unchangedRegion = validRegions.find(
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
const unchangedRegion = validRegions.find((region) => {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
);
return Math.abs(regionScalePercent) < 1;
});
if (unchangedRegion) {
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
return;
}
} else {
@ -6053,6 +6097,53 @@ export function ImageNode({
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleOpenPsdTemplateReplace = useCallback(() => {
if (!canvasId) {
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
return;
}
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("图片上传完成后再进行模版替换");
return;
}
setPsdTemplateDialogOpen(true);
}, [canvasId, data.src]);
const handleLaunchPsdTemplateReplace = useCallback(() => {
const normalizedBrandName = psdTemplateBrandName.trim();
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
toast.error("当前图片暂时无法进行模版替换");
return;
}
if (!normalizedBrandName) {
toast.error("请先选择或输入品牌名");
return;
}
const token = createCanvasPsdTemplateLaunchToken();
const returnTo =
typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}`
: `/canvas/${canvasId}`;
saveCanvasPsdTemplateLaunchPayload(token, {
brandName: normalizedBrandName,
pendingImages: [
{
id: node.id,
imageUrl: data.src,
title: displayTitle,
},
],
activeImageId: node.id,
returnTo,
templateMode: "main",
createdAt: Date.now(),
});
setPsdTemplateDialogOpen(false);
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
@ -6072,11 +6163,17 @@ export function ImageNode({
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (imageNodeActionRequest.action === "psd-template") {
handleOpenPsdTemplateReplace();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenPsdTemplateReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
@ -6659,58 +6756,8 @@ export function ImageNode({
updateNode,
]);
const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => {
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const capability = getCanvasCapabilityByClientAction(action);
if (!capability) {
toast.error("画布能力未注册");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true);
try {
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: [node.id],
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
toast.success(`${capability.title}完成`);
} catch (error) {
const message =
error instanceof Error ? error.message : `${capability.title}失败`;
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 处理图片编辑操作
const handleAction = async (
action: CanvasCapabilityClientAction | "scene-fusion" | "annotate"
) => {
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
@ -6813,7 +6860,34 @@ export function ImageNode({
toast.info("图层拆分功能未开启");
return;
}
await executeSharedToolbarCapability("layer-split");
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const result = await imageGenerationService.splitImageLayers(data.src);
const layers = result.data?.layers || [];
if (!result.success || layers.length === 0) {
toast.error(result.error || "图层拆分失败");
return;
}
const addedLayerCount = handleLayerSplitComplete(
layers.map((layer) => ({
originalUrl: layer.originalUrl,
title: layer.title,
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds ?? null,
}))
);
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
} catch (error) {
console.error("图层拆分失败:", error);
toast.error("图层拆分失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
return;
}
if (action === "box-cutout") {
@ -6828,6 +6902,10 @@ export function ImageNode({
handleEnterLocalEditMode();
return;
}
if (action === "psd-template") {
handleOpenPsdTemplateReplace();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
@ -6875,13 +6953,102 @@ export function ImageNode({
return;
}
if (
action === "rmbg" ||
action === "watermark" ||
action === "enhance" ||
action === "extend"
) {
await executeSharedToolbarCapability(action);
const actionName = getActionName(action);
setImageAdjustPanelOpen(false);
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "", // 暂时没有图片
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
connections: [node.id],
});
let result;
try {
switch (action) {
case "rmbg":
result = await imageGenerationService.removeBackground(data.src);
break;
case "watermark":
result = await imageGenerationService.removeWatermark(data.src);
break;
case "enhance":
result = await imageGenerationService.enhanceImage(data.src);
break;
case "extend":
result = await imageGenerationService.extendImage(data.src);
break;
default:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
@ -6925,16 +7092,7 @@ export function ImageNode({
const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return;
if (action === "scene-fusion" || action === "annotate") {
void handleAction(action);
return;
}
const capability = getCanvasCapabilityByClientAction(
action as CanvasCapabilityClientAction
);
if (capability) {
void handleAction(capability.clientAction);
}
};
window.addEventListener(
@ -9593,6 +9751,16 @@ export function ImageNode({
{imageTextEditItem.label}
</DropdownMenuItem>
) : null}
{imagePsdTemplateReplaceEnabled ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{imageExtendEnabled ? (
<DropdownMenuItem onClick={() => handleAction("extend")}>
<CanvasToolbarIcon
@ -9744,6 +9912,90 @@ export function ImageNode({
prompt={regeneratePrompt}
/>
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileImage className="h-5 w-5" />
模版替换
</DialogTitle>
<DialogDescription>
当前图片会作为替换素材带入 PSD 模版工作台。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
<img
alt={displayTitle}
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
src={buildImageProxyUrl(data.src, { width: 240 })}
/>
<div className="min-w-0 self-center">
<div className="truncate font-medium text-sm">{displayTitle}</div>
<div className="mt-1 text-muted-foreground text-xs">
已选择 1 张画布图片
</div>
</div>
</div>
{isLoadingPsdTemplateSummary ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取模版品牌...
</div>
) : availablePsdTemplateBrands.length > 0 ? (
<div className="space-y-2">
<Label>快捷选择</Label>
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
{availablePsdTemplateBrands.map((brand) => {
const active = psdTemplateBrandName.trim() === brand;
return (
<button
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted"
)}
key={brand}
onClick={() => setPsdTemplateBrandName(brand)}
type="button"
>
{brand}
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
<Input
id={`psd-template-brand-${node.id}`}
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
placeholder="选择或输入已配置 PSD 模版的品牌名"
value={psdTemplateBrandName}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
取消
</Button>
<Button
disabled={!psdTemplateBrandName.trim()}
onClick={handleLaunchPsdTemplateReplace}
>
<FileImage className="mr-2 h-4 w-4" />
打开模版替换
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
@ -10321,6 +10573,23 @@ export function ImageNode({
</Button>
) : null}
{isSelectedImageActionInline("psdTemplate") ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("psd-template")}
title={imagePsdTemplateReplaceItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
{imagePsdTemplateReplaceItem.label}
</Button>
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
@ -10594,6 +10863,16 @@ export function ImageNode({
<span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("psdTemplate") ? (
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={FileImage}
item={imagePsdTemplateReplaceItem}
/>
<span>{imagePsdTemplateReplaceItem.label}</span>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>

View File

@ -103,7 +103,6 @@ type World3DViewerProps = {
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
showMovementControls?: boolean;
showInfoOverlay?: boolean;
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
@ -118,7 +117,7 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.45;
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
@ -1495,7 +1494,6 @@ function WorldSplatScene({
semanticsMetadata?.ground_plane_offset
);
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
const shouldShowGroundHelper = groundHelperOffset !== null && !firstPerson;
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
orbitControlsRef.current = controls;
setControlsMountVersion((version) => version + 1);
@ -1781,7 +1779,7 @@ function WorldSplatScene({
<>
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{shouldShowGroundHelper ? (
{groundHelperOffset !== null ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundHelperOffset, 0]}
@ -2282,7 +2280,6 @@ export function World3DViewer({
resetViewToken,
onCaptureReady,
showMovementControls = false,
showInfoOverlay = !minimalFrame,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
@ -2389,7 +2386,7 @@ export function World3DViewer({
minimalFrame={minimalFrame}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
showInfoOverlay={!minimalFrame}
showMovementControls={showMovementControls}
splatUrl={splatUrl}
splatUrls={splatUrls}

View File

@ -459,7 +459,7 @@ function MarkdownTableImagePreview({
<>
<button
type="button"
className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40"
data-no-drag="true"
onClick={(event) => {
event.preventDefault();

View File

@ -129,7 +129,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
return {
eventType: "unavailable",
severity: "warning",
counted: true,
counted: false,
reason: "transport_failure_no_provider_response",
};
}
@ -184,7 +184,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
return {
eventType: "timeout",
severity: "warning",
counted: true,
counted: false,
reason: "timeout_without_provider_response",
};
}

View File

@ -220,6 +220,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
label: "局部编辑",
description: "框选多个彩色区域并按区域提示词局部改图",
},
{
key: "imagePsdTemplateReplace",
group: "节点工具栏",
label: "模版替换",
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
},
{
key: "imageRemoveWatermark",
group: "节点工具栏",

View File

@ -207,6 +207,21 @@ export const CANVAS_CAPABILITY_REGISTRY = [
inputSchema: promptArgsSchema,
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
}),
defineCapability({
id: "image.psd_template_replace",
title: "模版替换",
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
clientAction: "psd-template",
toolbarActionKey: "psdTemplate",
toolbarSettingKey: "imagePsdTemplateReplace",
executionMode: "interactive",
risk: "write",
modelPolicy: "none",
confirmation: "interactive",
inputSchema: emptyArgsSchema,
agent: { exposed: false },
mcp: { exposed: false },
}),
defineCapability({
id: "image.scale_adjust",
title: "比例调整",

View File

@ -9,6 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
"image.text_edit",
"image.box_cutout",
"image.local_edit",
"image.psd_template_replace",
"image.scale_adjust",
"image.remove_watermark",
"image.resize",
@ -38,6 +39,7 @@ export type CanvasCapabilityClientAction =
| "text-edit"
| "box-cutout"
| "local-edit"
| "psd-template"
| "scale-adjust"
| "watermark"
| "resize"
@ -63,6 +65,7 @@ export type SelectedImageToolbarActionKey =
| "textEdit"
| "boxCutout"
| "localEdit"
| "psdTemplate"
| "removeWatermark"
| "resize"
| "multiAngle"

View File

@ -1,32 +0,0 @@
export const CANVAS_LOCAL_EDIT_CONFIG_KEY = "canvas_local_edit_config";
export type CanvasLocalEditConfig = {
modelId: string;
scaleAdjustModelId: string;
};
export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig {
return {
modelId: "",
scaleAdjustModelId: "",
};
}
function normalizeModelRef(value: unknown) {
return String(value || "").trim().slice(0, 128);
}
export function normalizeCanvasLocalEditConfig(
value: unknown
): CanvasLocalEditConfig {
const defaults = defaultCanvasLocalEditConfig();
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaults;
}
const raw = value as Record<string, unknown>;
return {
modelId: normalizeModelRef(raw.modelId),
scaleAdjustModelId: normalizeModelRef(raw.scaleAdjustModelId),
};
}

View File

@ -1,30 +1,20 @@
import {
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
OPENCLAW_CHAT_FILE_MAX_BYTES,
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
} from "@/lib/openclaw-chat/media-attachments";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 1;
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 30 * 1000;
const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 60 * 1000;
const DIRECT_UPLOAD_PART_TOTAL_TIMEOUT_MS = 30 * 60 * 1000;
const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 45 * 1000;
const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 3 * 1000;
const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 15 * 1000;
const DIRECT_UPLOAD_ABORT_TIMEOUT_MS = 8 * 1000;
const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 10 * 1000;
const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000;
const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000;
const DIRECT_UPLOAD_SESSION_PREFIX = "yedir:direct-upload:v1:";
const DIRECT_UPLOAD_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
const RETRYABLE_UPLOAD_STATUSES = new Set([
408,
@ -63,7 +53,6 @@ let directUploadSuppressedUntil = 0;
export type UploadProgressPhase =
| "preparing"
| "resuming"
| "direct"
| "multipart"
| "server"
@ -85,7 +74,6 @@ type UploadFormFile = {
contentType: string;
purpose: string;
clientUploadId: string;
lastModified: number;
};
type DirectUploadPart = {
@ -94,10 +82,6 @@ type DirectUploadPart = {
};
type DirectUploadInitiateResponse =
| {
mode: "completed";
key: string;
}
| {
mode: "single";
key: string;
@ -111,37 +95,8 @@ type DirectUploadInitiateResponse =
partSize: number;
partCount: number;
parts: DirectUploadPart[];
uploadedParts?: Array<{
ETag: string;
PartNumber: number;
size?: number;
}>;
resumed?: boolean;
};
type StoredDirectUploadSession = {
version: 1;
fingerprint: string;
key: string;
uploadId: string;
partSize: number;
partCount: number;
clientUploadId: string;
updatedAt: number;
};
class DirectUploadActionError extends Error {
code?: string;
status: number;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "DirectUploadActionError";
this.status = status;
this.code = code;
}
}
function createAbortError() {
const error = new Error("Upload aborted");
error.name = "AbortError";
@ -295,105 +250,6 @@ function normalizeBrowserImageType(contentType: string, filename: string) {
return BROWSER_IMAGE_TYPES_BY_EXTENSION[extension] || normalized;
}
function normalizeUploadContentType(
contentType: string,
filename: string,
purpose: string
) {
if (purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE) {
return (
getOpenClawChatFileContentType({ name: filename, type: contentType }) ||
contentType.trim().toLowerCase() ||
"application/octet-stream"
);
}
if (purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE) {
return contentType.trim().toLowerCase() || "application/octet-stream";
}
return normalizeBrowserImageType(contentType, filename);
}
function hashUploadFingerprint(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function getUploadFingerprint(file: UploadFormFile) {
return [
file.purpose,
file.filename,
file.contentType,
file.blob.size,
file.lastModified,
].join("\u0000");
}
function getUploadSessionStorageKey(file: UploadFormFile) {
return `${DIRECT_UPLOAD_SESSION_PREFIX}${hashUploadFingerprint(
getUploadFingerprint(file)
)}`;
}
function clearStoredUploadSession(file: UploadFormFile) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(getUploadSessionStorageKey(file));
} catch {}
}
function readStoredUploadSession(file: UploadFormFile) {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(getUploadSessionStorageKey(file));
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredDirectUploadSession;
const isValid =
parsed.version === 1 &&
parsed.fingerprint === getUploadFingerprint(file) &&
Boolean(parsed.key && parsed.uploadId && parsed.clientUploadId) &&
Number.isFinite(parsed.partSize) &&
Number.isFinite(parsed.partCount) &&
Date.now() - parsed.updatedAt <= DIRECT_UPLOAD_SESSION_TTL_MS;
if (!isValid) {
clearStoredUploadSession(file);
return null;
}
return parsed;
} catch {
clearStoredUploadSession(file);
return null;
}
}
function saveStoredUploadSession(
file: UploadFormFile,
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>,
clientUploadId = file.clientUploadId
) {
if (typeof window === "undefined") return;
const session: StoredDirectUploadSession = {
version: 1,
fingerprint: getUploadFingerprint(file),
key: upload.key,
uploadId: upload.uploadId,
partSize: upload.partSize,
partCount: upload.partCount,
clientUploadId,
updatedAt: Date.now(),
};
try {
window.localStorage.setItem(
getUploadSessionStorageKey(file),
JSON.stringify(session)
);
} catch {}
}
function createClientUploadId() {
if (
typeof crypto !== "undefined" &&
@ -425,62 +281,37 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
typeof File !== "undefined" && value instanceof File && value.name
? value.name
: `upload-${Date.now()}`;
const purposeValue = formData.get("purpose");
const purpose = typeof purposeValue === "string" ? purposeValue : "";
const contentType = normalizeUploadContentType(
const contentType = normalizeBrowserImageType(
value.type || "application/octet-stream",
filename,
purpose
filename
);
const purposeValue = formData.get("purpose");
const clientUploadId = ensureClientUploadId(formData);
return {
blob: value,
filename,
contentType,
purpose,
purpose: typeof purposeValue === "string" ? purposeValue : "",
clientUploadId,
lastModified:
typeof File !== "undefined" && value instanceof File
? value.lastModified
: 0,
};
}
function shouldTryDirectUpload(file: UploadFormFile) {
if (
file.purpose !== OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
file.purpose !== OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
Date.now() < directUploadSuppressedUntil &&
file.blob.size <= SERVER_UPLOAD_MAX_BYTES
) {
return false;
}
const maxBytes =
file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DIRECT_UPLOAD_MAX_BYTES
: DIRECT_UPLOAD_MEDIA_MAX_BYTES;
if (file.blob.size <= 0 || file.blob.size > maxBytes) {
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
return false;
}
const normalizedContentType = file.contentType.toLowerCase();
const supportsDirectUpload =
DIRECT_UPLOAD_IMAGE_TYPES.has(normalizedContentType) ||
(file.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES.has(normalizedContentType)) ||
(file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({
name: file.filename,
type: normalizedContentType,
})
)) ||
(file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({
name: file.filename,
type: normalizedContentType,
}));
DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES.has(normalizedContentType));
if (!supportsDirectUpload) {
return false;
}
@ -593,16 +424,7 @@ async function postDirectUploadAction<T>(
);
if (!response.ok) {
let code: string | undefined;
let message = response.statusText;
try {
const payload = await response.clone().json();
code = typeof payload?.code === "string" ? payload.code : undefined;
message = String(payload?.error || payload?.details || message);
} catch {
message = String(await readErrorPayload(response));
}
throw new DirectUploadActionError(message, response.status, code);
throw new Error(String(await readErrorPayload(response)));
}
return (await response.json()) as T;
@ -756,7 +578,7 @@ async function fetchDirectPartWithRetry(
maxRetries: number,
signal?: AbortSignal,
onProgress?: (loaded: number, total: number) => void,
options?: { stallTimeoutMs?: number; timeoutMs?: number }
options?: { stallTimeoutMs?: number }
) {
for (let attempt = 0; ; attempt += 1) {
try {
@ -767,7 +589,6 @@ async function fetchDirectPartWithRetry(
onProgress,
signal,
stallTimeoutMs: options?.stallTimeoutMs,
timeoutMs: options?.timeoutMs,
}
);
@ -840,38 +661,11 @@ async function uploadMultipartParts(params: {
maxRetries: number;
signal?: AbortSignal;
onProgress?: UploadProgressHandler;
onPartComplete?: () => void;
}) {
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [
...(params.upload.uploadedParts || []).map((part) => ({
ETag: part.ETag,
PartNumber: part.PartNumber,
})),
];
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
const partLoadedBytes = new Map<number, number>();
let nextIndex = 0;
for (const part of params.upload.uploadedParts || []) {
const start = (part.PartNumber - 1) * params.upload.partSize;
const expectedSize = Math.max(
0,
Math.min(params.upload.partSize, params.file.blob.size - start)
);
partLoadedBytes.set(part.PartNumber, part.size || expectedSize);
}
if (partLoadedBytes.size > 0) {
emitUploadProgress(
params.onProgress,
Array.from(partLoadedBytes.values()).reduce(
(sum, value) => sum + value,
0
),
params.file.blob.size,
"resuming"
);
}
const reportPartProgress = (partNumber: number, loaded: number, total: number) => {
partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded));
const uploadedBytes = Array.from(partLoadedBytes.values()).reduce(
@ -906,10 +700,7 @@ async function uploadMultipartParts(params: {
(loaded, total) => {
reportPartProgress(part.partNumber, loaded, total || body.size);
},
{
stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS,
timeoutMs: DIRECT_UPLOAD_PART_TOTAL_TIMEOUT_MS,
}
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
);
if (!response.ok) {
@ -927,7 +718,6 @@ async function uploadMultipartParts(params: {
ETag: etag,
PartNumber: part.partNumber,
});
params.onPartComplete?.();
}
}
@ -938,11 +728,7 @@ async function uploadMultipartParts(params: {
)
);
return Array.from(
new Map(
uploadedParts.map((part) => [part.PartNumber, part] as const)
).values()
).sort((a, b) => a.PartNumber - b.PartNumber);
return uploadedParts.sort((a, b) => a.PartNumber - b.PartNumber);
}
async function fetchDirectUpload(
@ -959,95 +745,23 @@ async function fetchDirectUpload(
let multipartUpload:
| Extract<DirectUploadInitiateResponse, { mode: "multipart" }>
| null = null;
let activeClientUploadId = file.clientUploadId;
try {
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
percent: 1,
});
const storedSession = readStoredUploadSession(file);
let upload: DirectUploadInitiateResponse | null = null;
if (storedSession) {
activeClientUploadId = storedSession.clientUploadId;
emitUploadProgress(onProgress, 0, file.blob.size, "resuming", {
percent: 1,
});
try {
upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "resume",
key: storedSession.key,
uploadId: storedSession.uploadId,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
partSize: storedSession.partSize,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
},
signal
);
} catch (error) {
if (
error instanceof DirectUploadActionError &&
((error.status === 409 &&
error.code === "RESUME_SESSION_EXPIRED") ||
error.status === 403)
) {
clearStoredUploadSession(file);
activeClientUploadId = file.clientUploadId;
} else {
throw error;
}
}
}
if (!upload) {
upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "initiate",
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
clientUploadId: file.clientUploadId,
},
signal,
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
);
}
if (upload.mode === "completed") {
emitUploadProgress(
onProgress,
file.blob.size,
file.blob.size,
"finalizing",
{ percent: 99 }
);
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
key: upload.key,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
},
signal
);
clearStoredUploadSession(file);
emitUploadProgress(
onProgress,
file.blob.size,
file.blob.size,
"complete",
{ percent: 100 }
);
return createJsonResponse(payload);
}
if (upload.mode === "single") {
emitUploadProgress(onProgress, 0, file.blob.size, "direct", {
@ -1085,7 +799,7 @@ async function fetchDirectUpload(
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
clientUploadId: file.clientUploadId,
},
signal
);
@ -1097,21 +811,15 @@ async function fetchDirectUpload(
}
multipartUpload = upload;
saveStoredUploadSession(file, upload, activeClientUploadId);
if (!upload.resumed) {
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
percent: 2,
});
}
const parts = await uploadMultipartParts({
file,
upload,
maxRetries,
signal,
onProgress,
onPartComplete: () => {
saveStoredUploadSession(file, upload, activeClientUploadId);
},
});
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99,
@ -1126,33 +834,37 @@ async function fetchDirectUpload(
size: file.blob.size,
parts,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
clientUploadId: file.clientUploadId,
},
signal
);
clearStoredUploadSession(file);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100,
});
return createJsonResponse(payload);
} catch (error) {
if (multipartUpload && !isAbortError(error, signal)) {
postDirectUploadAction(
{
action: "abort",
key: multipartUpload.key,
uploadId: multipartUpload.uploadId,
clientUploadId: file.clientUploadId,
},
signal,
DIRECT_UPLOAD_ABORT_TIMEOUT_MS
).catch(() => {});
}
if (!isAbortError(error, signal)) {
if (
multipartUpload ||
file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE ||
file.blob.size > SERVER_UPLOAD_MAX_BYTES
) {
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
return createJsonResponse(
{
error:
multipartUpload
? "上传已暂停,重新选择同一文件将从已完成的分片继续上传。"
: "文件直传失败,请重新选择同一文件后重试。",
"文件超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
details: error instanceof Error ? error.message : String(error),
resumable: Boolean(multipartUpload),
clientUploadId: activeClientUploadId,
clientUploadId: file.clientUploadId,
},
{ status: 502 }
);

View File

@ -1,18 +1,13 @@
export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video";
export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder";
export const OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE = "openclaw-chat-file";
export const OPENCLAW_CHAT_FILE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_FILE_MAX_FILES = 10;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = 500 * 1024 * 1024;
export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5;
export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000;
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = 300 * 1024 * 1024;
export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16;
export const OPENCLAW_CHAT_VIDEO_ACCEPT =
"video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv";
export const OPENCLAW_CHAT_FILE_ACCEPT =
".zip,.rar,.7z,.tar,.gz,.tgz,.pdf,.txt,.md,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.tsv,.json,.xml,.mp3,.wav,.m4a,.aac,.flac,.ogg,.psd,.ai,.dxf,.dwg,.step,.stp,application/zip,application/x-zip-compressed,application/vnd.rar,application/x-rar-compressed,application/x-7z-compressed,application/x-tar,application/gzip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/csv,text/tab-separated-values,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/flac,audio/ogg";
const VIDEO_CONTENT_TYPES = new Set([
"video/mp4",
@ -32,49 +27,25 @@ const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
const OPENCLAW_FILE_EXTENSION_PATTERN =
/\.(zip|rar|7z|tar|gz|tgz|pdf|txt|md|docx?|pptx?|xlsx?|csv|tsv|json|xml|mp3|wav|m4a|aac|flac|ogg|psd|ai|dxf|dwg|step|stp|folder\.json)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_EXTENSION_PATTERN =
/\.(html?|xhtml|svg|js|mjs|cjs|exe|dll|msi|bat|cmd|com|scr|ps1|sh|jar|apk|dmg|pkg|deb|rpm)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_CONTENT_TYPES = new Set([
"application/java-archive",
"application/javascript",
"application/vnd.android.package-archive",
"application/x-apple-diskimage",
"application/x-dosexec",
"application/x-msdownload",
"application/x-msi",
"application/x-sh",
"application/xhtml+xml",
"image/svg+xml",
"text/html",
"text/javascript",
]);
const FILE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
"7z": "application/x-7z-compressed",
csv: "text/csv",
gz: "application/gzip",
rar: "application/vnd.rar",
tar: "application/x-tar",
tgz: "application/gzip",
tsv: "text/tab-separated-values",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
zip: "application/zip",
};
const GENERIC_FILE_CONTENT_TYPES = new Set([
const FILE_EXTENSION_PATTERN =
/\.(zip|rar|7z|tar|gz|tgz|pdf|txt|md|docx?|pptx?|xlsx?|csv|tsv|json|xml|mp3|wav|m4a|aac|flac|ogg|psd|ai|dxf|dwg|step|stp)(?:$|[?#])/i;
const FILE_CONTENT_TYPES = new Set([
"application/gzip",
"application/octet-stream",
"application/msword",
"application/pdf",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.rar",
"application/vnd.yedir.folder-manifest+json",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-zip-compressed",
"application/zip",
"text/csv",
"text/plain",
"text/tab-separated-values",
]);
@ -155,67 +126,15 @@ export function isOpenClawChatArchiveAttachment(
);
}
export function getOpenClawChatFileContentType(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
if (
normalized.startsWith("image/") ||
normalized.startsWith("video/") ||
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
) {
return "";
}
if (
normalized &&
normalized !== "application/octet-stream" &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized)
) {
return normalized;
}
if (file.name.toLowerCase().endsWith(".folder.json")) {
return "application/vnd.yedir.folder-manifest+json";
}
return (
FILE_CONTENT_TYPE_BY_EXTENSION[getExtension(file.name)] ||
(OPENCLAW_FILE_EXTENSION_PATTERN.test(file.name)
? "application/octet-stream"
: "")
);
}
export function isOpenClawChatFile(file: {
name: string;
type?: string | null;
}) {
return Boolean(getOpenClawChatFileContentType(file));
}
export function isOpenClawChatFolderFileAllowed(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
return !(
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
);
}
export function isOpenClawChatFileAttachment(
url: string,
contentType?: string | null
) {
const normalizedContentType = normalizeContentType(contentType);
return (
GENERIC_FILE_CONTENT_TYPES.has(normalizedContentType) ||
(Boolean(normalizedContentType) &&
!normalizedContentType.startsWith("image/") &&
!normalizedContentType.startsWith("video/") &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalizedContentType)) ||
OPENCLAW_FILE_EXTENSION_PATTERN.test(String(url || ""))
FILE_CONTENT_TYPES.has(normalizedContentType) ||
normalizedContentType.startsWith("audio/") ||
FILE_EXTENSION_PATTERN.test(String(url || ""))
);
}
@ -249,37 +168,40 @@ export function normalizeOpenClawChatMediaAttachments(
const isVideo =
requestedType === "video" ||
isOpenClawChatVideoAttachment(url, rawContentType);
const isFile =
const isArchive =
!isVideo &&
(requestedType === "file" ||
requestedType === "archive" ||
isOpenClawChatFileAttachment(url, rawContentType));
isOpenClawChatArchiveAttachment(url, rawContentType));
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
continue;
}
if (isFile && archiveCount >= OPENCLAW_CHAT_FILE_MAX_FILES) {
if (isArchive && archiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
continue;
}
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
break;
}
const name = String(raw.name || "")
.trim()
.slice(0, 200);
const type = isVideo ? "video" : isFile ? "file" : "image";
const type = isVideo ? "video" : isArchive ? "file" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: isFile
? getOpenClawChatFileContentType({ name: name || url, type: rawContentType }) ||
"application/octet-stream"
: isArchive
? "application/zip"
: "image/jpeg";
const contentType = isFile
? rawContentType || fallbackContentType
const contentType = isArchive
? rawContentType === "application/zip" ||
rawContentType === "application/x-zip-compressed"
? rawContentType
: fallbackContentType
: rawContentType.startsWith(`${type}/`)
? rawContentType
: fallbackContentType;
const name = String(raw.name || "")
.trim()
.slice(0, 200);
result.push({
type,
url,
@ -290,7 +212,7 @@ export function normalizeOpenClawChatMediaAttachments(
if (isVideo) {
videoCount += 1;
}
if (isFile) {
if (isArchive) {
archiveCount += 1;
}
}
@ -347,20 +269,6 @@ export function buildOpenClawArchiveDisplayText(
return `已上传文件夹:\n${lines.join("\n")}`;
}
export function buildOpenClawFileDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) => `- ${attachment.name || `文件附件${index + 1}`}`
);
return `已上传文件:\n${lines.join("\n")}`;
}
export function buildOpenClawArchiveReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
@ -380,21 +288,3 @@ export function buildOpenClawArchiveReferenceText(
lines.join("\n"),
].join("\n");
}
export function buildOpenClawFileReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) =>
`- ${attachment.name || `文件附件${index + 1}`}: ${attachment.url} (${attachment.content_type || "application/octet-stream"})`
);
return [
"用户上传了以下文件。表格请下载后读取,压缩包请下载并解压;application/vnd.yedir.folder-manifest+json 是保留目录结构和各文件下载地址的文件夹清单:",
lines.join("\n"),
].join("\n");
}

View File

@ -1,9 +1,7 @@
import Papa from "papaparse";
import { OPENCLAW_CHAT_FILE_MAX_BYTES } from "@/lib/openclaw-chat/media-attachments";
export const OPENCLAW_SPREADSHEET_MAX_FILES = 3;
export const OPENCLAW_SPREADSHEET_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_SPREADSHEET_INLINE_PARSE_MAX_BYTES = 10 * 1024 * 1024;
export const OPENCLAW_SPREADSHEET_MAX_BYTES = 10 * 1024 * 1024;
export const OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET = 200;
export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30;
export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5;
@ -332,10 +330,8 @@ export async function spreadsheetFileToOpenClawMarkdown(file: File) {
if (file.size <= 0) {
throw new Error(`${getSafeFileName(file.name)} 是空文件`);
}
if (file.size > OPENCLAW_SPREADSHEET_INLINE_PARSE_MAX_BYTES) {
throw new Error(
`${getSafeFileName(file.name)} 超过 10MB,请使用 Claw 文件附件直传(最高 2GB)`
);
if (file.size > OPENCLAW_SPREADSHEET_MAX_BYTES) {
throw new Error(`${getSafeFileName(file.name)} 超过 10MB,请先拆分或压缩`);
}
const extension = getSpreadsheetExtension(file.name);

View File

@ -8,7 +8,10 @@ import {
updateMessageById,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import { encryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
import {
decryptOpenClawTaskSecret,
encryptOpenClawTaskSecret,
} from "@/lib/openclaw-chat/task-secret";
import {
appendClawdTaskProgress,
CLAWD_TASK_KIND,
@ -37,6 +40,32 @@ export type ClawdUpstreamTaskState = {
lastEventId?: number | null;
};
export async function findPersistedClawdUpstreamTaskState(params: {
chatId: string;
taskId: string;
}): Promise<ClawdUpstreamTaskState | null> {
const messages = await getMessagesByChatId({ id: params.chatId }).catch(
() => []
);
for (const message of messages.toReversed()) {
const metadata = normalizeClawdTaskMetadata(message.videoMetadata);
if (
!metadata ||
metadata.upstreamTaskId !== params.taskId ||
!metadata.upstreamTaskKey
) {
continue;
}
return {
taskId: metadata.upstreamTaskId,
taskKey: decryptOpenClawTaskSecret(metadata.upstreamTaskKey),
eventsEndpoint: metadata.upstreamEventsEndpoint,
lastEventId: metadata.upstreamLastEventId,
};
}
return null;
}
function buildTaskMetadata(params: {
userMessageId: string;
assistantMessageId: string;

View File

@ -2,6 +2,7 @@ import "server-only";
import {
appendPersistedClawdTaskProgress,
findPersistedClawdUpstreamTaskState,
type ClawdUpstreamTaskState,
persistClawdUpstreamTaskState,
} from "@/lib/openclaw-chat/task-store";
@ -301,19 +302,37 @@ export async function submitOpenClawDurableTask(params: {
const task = readRecord(root.task);
const taskId = readText(root.task_id) || readText(task.task_id);
if (!taskId) throw new Error("OpenClaw 未返回 task_id");
const responseTaskKey = readText(root.task_key);
const persistedTask = responseTaskKey
? null
: await findPersistedClawdUpstreamTaskState({
chatId: params.target.chatId,
taskId,
});
const taskKey = responseTaskKey || persistedTask?.taskKey || null;
if (!taskKey) {
throw new OpenClawTaskCredentialUnavailableError(taskId);
}
const submittedTask = mergeUpstreamTaskState(
{
taskId,
taskKey: readText(root.task_key) || null,
eventsEndpoint: getTaskEventsEndpoint(payload, taskId),
lastEventId: 0,
taskKey,
eventsEndpoint:
getTaskEventsEndpoint(payload, taskId) ||
persistedTask?.eventsEndpoint ||
null,
lastEventId: persistedTask?.lastEventId || 0,
},
payload
);
// A task-creation response can include the latest upstream cursor even though
// none of its events have been persisted locally yet. Always consume the
// event stream from the beginning after accepting a new durable task.
const upstreamTask = { ...submittedTask, lastEventId: 0 };
const upstreamTask = {
...submittedTask,
taskKey,
lastEventId: persistedTask?.lastEventId || 0,
};
await persistClawdUpstreamTaskState({
...params.target,
upstreamTask,
@ -342,6 +361,11 @@ export async function refreshOpenClawDurableTask(params: {
target: ClawdTaskPersistenceTarget;
signal?: AbortSignal;
}): Promise<OpenClawTaskOutcome> {
if (!params.upstreamTask.taskKey) {
throw new OpenClawTaskCredentialUnavailableError(
params.upstreamTask.taskId
);
}
const eventsUrl = buildUpstreamUrl(
params.config.baseUrl,
params.upstreamTask.eventsEndpoint ||
@ -459,6 +483,14 @@ export class OpenClawTaskDetachedError extends Error {
}
}
export class OpenClawTaskCredentialUnavailableError extends OpenClawTaskDetachedError {
constructor(readonly taskId: string) {
super();
this.name = "OpenClawTaskCredentialUnavailableError";
this.message = "OpenClaw task key is unavailable; keep the task detached";
}
}
export async function waitForOpenClawDurableTask(params: {
config: OpenClawUpstreamConfig;
upstreamTask: ClawdUpstreamTaskState;

View File

@ -12,8 +12,8 @@ export default defineConfig({
/routes\/settings-visual-consistency\.test\.ts/,
/routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/canvas-psd-template-entry\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/model3d-style-pro-regressions\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
/routes\/canvas-skill-tag-order\.test\.ts/,
/routes\/openclaw-client-errors\.test\.ts/,

View File

@ -29,7 +29,7 @@ const skippedExtensions = new Set([
const skippedFiles = new Set(["pnpm-lock.yaml"]);
const secretPatterns = [
{ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{24,}/, reason: "OpenAI-style API key" },
{ pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{24,}/, reason: "OpenAI-style API key" },
{ pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, reason: "GitHub token" },
{ pattern: /AKIA[0-9A-Z]{16}/, reason: "AWS access key id" },
{ pattern: /xox[baprs]-[A-Za-z0-9-]{24,}/, reason: "Slack token" },

View File

@ -26,11 +26,7 @@ expectIncludes(testHelpersScript, "playwright test --config=playwright.helpers.c
expectIncludes(qualityScript, "pnpm test:helpers", "helper tests in quality gate");
expectIncludes(helpersConfig, 'testDir: "./tests"', "tests directory");
expectIncludes(
helpersConfig,
"/routes\\/canvas-chat-helpers\\.test\\.ts/",
"canvas helper test target"
);
expectIncludes(helpersConfig, "testMatch: /routes\\/canvas-chat-helpers\\.test\\.ts/", "single pure helper test target");
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");

View File

@ -272,26 +272,8 @@ export const canvasService = {
return response;
};
let response = await doSaveWithRateLimitRetry(buildRequestBody(options));
let data = await response.json().catch(() => ({}));
const serverRevision = normalizeRevisionText(data?.canvasUpdatedAt);
if (
response.status === 409 &&
data?.code === "STALE_CANVAS_REVISION" &&
serverRevision
) {
response = await doSaveWithRateLimitRetry(
buildRequestBody(
{
baseCanvasRevision: serverRevision,
baseCanvasUpdatedAt: serverRevision,
},
false
)
);
data = await response.json().catch(() => ({}));
}
const response = await doSaveWithRateLimitRetry(buildRequestBody(options));
const data = await response.json().catch(() => ({}));
if (!response.ok) {
return {

View File

@ -22,6 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
"textEdit",
"boxCutout",
"localEdit",
"psdTemplate",
"removeWatermark",
"resize",
"multiAngle",

View File

@ -235,7 +235,11 @@ import {
normalizeUploadedAssetId,
validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
import {
normalizeChatMediaType,
sanitizeChatAttachment,
type PickedObjectAttachment,
} from "@/components/canvas/chat-panel/canvas-chat-session";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types";
import type { DBMessage } from "@/lib/db/schema";
@ -253,6 +257,22 @@ function dbTextMessage(
}
test.describe("canvas chat pure helpers", () => {
test("preserves spreadsheet attachment content types", () => {
const spreadsheetContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
expect(normalizeChatMediaType(spreadsheetContentType)).toBe(
spreadsheetContentType
);
expect(
sanitizeChatAttachment({
name: "测试用.xlsx",
url: "https://cdn.example.com/uploads/test.xlsx",
contentType: spreadsheetContentType,
})?.contentType
).toBe(spreadsheetContentType);
});
test("keeps optimistic messages without timestamps after persisted history", () => {
const messages = [
{
@ -4855,6 +4875,18 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("keeps the PSD template replacement action enabled by default", () => {
expect(
getCanvasToolbarItem(
defaultCanvasToolbarConfig,
"imagePsdTemplateReplace"
)
).toMatchObject({
label: "模版替换",
enabled: true,
});
});
test("syncs image notes into structured canvas annotations", () => {
const first = syncCanvasNodeNoteAnnotationMetadata({
metadata: {
@ -5201,22 +5233,6 @@ test.describe("canvas chat pure helpers", () => {
]);
});
test("dedupes images in nonstandard pipe tables and HTML tables", () => {
const markdown = [
"| 商品 | 主图 |",
"| A | ![主图](https://cdn.example.com/pipe-table.png?token=secret) |",
"",
'<table><tr><td>商品 B</td><td><img src="https://cdn.example.com/html-table.webp?token=secret" /></td></tr></table>',
"",
"![补充](https://cdn.example.com/standalone.jpg?token=secret)",
].join("\n");
const imageUrls = extractAssistantImageUrls(markdown);
expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([
"https://cdn.example.com/standalone.jpg?token=secret",
]);
});
test("extracts and strips video markup without removing surrounding text", () => {
const text = [
"生成完成",

View File

@ -11,12 +11,8 @@ test.describe("canvas overlay density", () => {
const source = readSource("components/canvas/CanvasChatPanel.tsx");
expect(source).toContain(
"max-h-[360px] max-w-[calc(100vw-24px)]"
"max-h-[360px] w-[292px] max-w-[calc(100vw-24px)]"
);
expect(source).toMatch(
/style=\{\{ width: `\$\{modelSelectorWidth\}px` \}\}/
);
expect(source).toContain("DEFAULT_CANVAS_MODEL_SELECTOR_WIDTH = 360");
expect(source).toContain(
"h-9 rounded-[var(--ui-radius-control)] px-2.5 text-[13px]"
);

View File

@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("canvas PSD template replacement entry", () => {
test("keeps the selected-image toolbar action and launch flow wired", () => {
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
expect(imageNode).toContain(
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imagePsdTemplateReplace"'
);
expect(imageNode).toContain('isSelectedImageActionInline("psdTemplate")');
expect(imageNode).toContain('isSelectedImageActionInMore("psdTemplate")');
expect(imageNode).toContain('handleAction("psd-template")');
expect(imageNode).toContain("handleOpenPsdTemplateReplace");
expect(imageNode).toContain("/psd-template?launch=");
expect(workspace).toContain(
'requestImageNodeAction(selectedImageNodes[0].id, "psd-template")'
);
});
});

View File

@ -37,9 +37,7 @@ test.describe("Claw request lifecycle", () => {
expect(source).not.toContain('request.signal.addEventListener("abort"');
expect(source).not.toContain("cancelClawdRequest(cancelKey)");
expect(source).toMatch(
/registerActiveClawdRequest\(\s*(?:params\.)?cancelKey/
);
expect(source).toContain("registerActiveClawdRequest(cancelKey");
});
test("does not classify every AbortError as an explicit stop action", () => {

View File

@ -25,11 +25,11 @@ test.describe("HiCAD embedded startup", () => {
expect(vendorScript).toContain(
'const brandedFaviconUrl = "/api/favicon?surface=hicad"'
);
expect(vendorScript).toMatch(
/` {2}<link rel="icon" href="\$\{brandedFaviconUrl\}" \/>`/
expect(vendorScript).toContain(
'` <link rel="icon" href="${brandedFaviconUrl}" />`'
);
expect(vendorScript).toMatch(
/` {2}<link rel="apple-touch-icon" href="\$\{brandedFaviconUrl\}" \/>`/
expect(vendorScript).toContain(
'` <link rel="apple-touch-icon" href="${brandedFaviconUrl}" />`'
);
expect(vendorScript).toMatch(
/manifest\.icons = \[\s*\{\s*src: brandedFaviconUrl,\s*sizes: "any",/

View File

@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("3D workspace and Style Pro regressions", () => {
test("opens World splats in first-person mode without the debug HUD", () => {
const page = readSource("app/canvas/[id]/model3d/page.tsx");
const viewer = readSource(
"components/canvas/world3d/World3DViewer.tsx"
);
expect(page).toContain(
'workspaceMode === "world" && worldViewportMode === "splat"'
);
expect(page).toContain("showInfoOverlay={false}");
expect(viewer).toContain("showInfoOverlay = !minimalFrame");
expect(viewer).toContain("showInfoOverlay={showInfoOverlay}");
});
test("keeps each GLB viewer material instance isolated and texture-aware", () => {
const viewer = readSource(
"components/canvas/model3d/Model3DViewer.tsx"
);
expect(viewer).toContain("cloneSceneWithIndependentMaterials");
expect(viewer).toContain("const clonedMaterial = material.clone()");
expect(viewer).toContain(
"configureMaterialTextures(clonedMaterial, node.geometry)"
);
});
test("uses the same hidden-model access rule in Style Pro as canvas", () => {
const route = readSource("app/api/style-pro/model-options/route.ts");
expect(route).toContain("getUserById");
expect(route).toContain('session.user.type === "admin"');
expect(route).toContain("Boolean(currentUser?.isHighVip)");
expect(route).toContain("includeHidden: allowHiddenModels");
});
test("opens the canvas conversation panel by default", () => {
const page = readSource(
"app/canvas/[id]/CanvasEditorLegacyPageClient.tsx"
);
expect(page).toContain(
"const [isPanelOpen, setIsPanelOpen] = useState(true)"
);
});
});

View File

@ -7,13 +7,8 @@ import {
import {
buildOpenClawArchiveDisplayText,
buildOpenClawArchiveReferenceText,
buildOpenClawFileReferenceText,
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
isOpenClawChatArchiveAttachment,
isOpenClawChatFileAttachment,
normalizeOpenClawChatMediaAttachments,
OPENCLAW_CHAT_FILE_MAX_BYTES,
} from "@/lib/openclaw-chat/media-attachments";
test.describe("OpenClaw folder attachments", () => {
@ -79,71 +74,4 @@ test.describe("OpenClaw folder attachments", () => {
);
expect(buildOpenClawArchiveReferenceText(attachments)).toContain("解压");
});
test("accepts 2GB spreadsheet and folder-manifest attachments", () => {
expect(OPENCLAW_CHAT_FILE_MAX_BYTES).toBe(2 * 1024 * 1024 * 1024);
expect(
getOpenClawChatFileContentType({
name: "清单.xlsx",
type: "application/octet-stream",
})
).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
expect(
getOpenClawChatFileContentType({
name: "产品资料.folder.json",
type: "application/vnd.yedir.folder-manifest+json",
})
).toBe("application/vnd.yedir.folder-manifest+json");
const attachments = normalizeOpenClawChatMediaAttachments([
{
type: "file",
url: "https://cdn.example.com/清单.xlsx",
name: "清单.xlsx",
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
type: "file",
url: "https://cdn.example.com/产品资料.folder.json",
name: "产品资料.folder.json",
contentType: "application/vnd.yedir.folder-manifest+json",
},
]);
expect(attachments).toHaveLength(2);
expect(
isOpenClawChatFileAttachment(
attachments[0].url,
attachments[0].content_type
)
).toBe(true);
expect(buildOpenClawFileReferenceText(attachments)).toContain("表格请下载");
expect(buildOpenClawFileReferenceText(attachments)).toContain(
"application/vnd.yedir.folder-manifest+json"
);
});
test("allows normal folder media while blocking executable web content", () => {
expect(
isOpenClawChatFolderFileAllowed({
name: "商品图/主图.png",
type: "image/png",
})
).toBe(true);
expect(
isOpenClawChatFolderFileAllowed({
name: "页面/index.html",
type: "text/html",
})
).toBe(false);
expect(
isOpenClawChatFolderFileAllowed({
name: "工具/setup.exe",
type: "application/octet-stream",
})
).toBe(false);
});
});

View File

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
},
"strictNullChecks": true
},
@ -39,7 +45,10 @@
".next-build-hicad-20260728-140320/types/**/*.ts",
".next-build-hicad-20260728-140320/dev/types/**/*.ts",
".next-candidate-home-banner-20260804-bg/types/**/*.ts",
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts"
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts",
],
"exclude": ["node_modules", "services/pascal-editor"]
"exclude": [
"node_modules",
"services/pascal-editor"
]
}