Compare commits
6 Commits
codex/48-m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a8f2dda3 | ||
|
|
731aa9725a | ||
|
|
1dd489100d | ||
|
|
3f2b18d18d | ||
|
|
e225e06f0b | ||
|
|
1a9888d825 |
@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
|
||||
aiWashConfig,
|
||||
setAiWashConfig,
|
||||
aiWashModeIconUploading,
|
||||
canvasLocalEditConfig,
|
||||
setCanvasLocalEditConfig,
|
||||
extendConfig,
|
||||
setExtendConfig,
|
||||
watermarkRemoveConfig,
|
||||
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
|
||||
handleMoveEnhanceProPreset,
|
||||
handleAiWashModeIconUpload,
|
||||
handleSaveAiWashConfig,
|
||||
handleSaveCanvasLocalEditConfig,
|
||||
handleSaveExtendConfig,
|
||||
handleSaveWatermarkRemoveConfig,
|
||||
handleSaveAngleEditConfig,
|
||||
@ -1463,6 +1466,112 @@ 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={
|
||||
<>
|
||||
|
||||
@ -12,6 +12,7 @@ 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,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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,
|
||||
@ -333,6 +334,8 @@ 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;
|
||||
@ -375,6 +378,7 @@ 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;
|
||||
|
||||
@ -200,6 +200,11 @@ 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,
|
||||
@ -1901,6 +1906,13 @@ 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,
|
||||
@ -2613,6 +2625,9 @@ export function AdminSettingsClient() {
|
||||
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
|
||||
useState("");
|
||||
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
|
||||
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
|
||||
defaultCanvasLocalEditConfig()
|
||||
);
|
||||
|
||||
// 图像拓展API配置状态
|
||||
const [extendConfig, setExtendConfig] = useState({
|
||||
@ -2857,6 +2872,13 @@ export function AdminSettingsClient() {
|
||||
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
|
||||
}, [aiWashConfigData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasLocalEditConfigData) return;
|
||||
setCanvasLocalEditConfig(
|
||||
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
|
||||
);
|
||||
}, [canvasLocalEditConfigData]);
|
||||
|
||||
const enhanceProApiKeysDraftCount = useMemo(
|
||||
() =>
|
||||
normalizeEnhanceProKreaConfig({
|
||||
@ -5302,6 +5324,34 @@ 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,
|
||||
@ -18727,6 +18777,7 @@ export function AdminSettingsClient() {
|
||||
angleEditConfig={angleEditConfig}
|
||||
aiWashConfig={aiWashConfig}
|
||||
aiWashModeIconUploading={aiWashModeIconUploading}
|
||||
canvasLocalEditConfig={canvasLocalEditConfig}
|
||||
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
|
||||
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
|
||||
enhanceConfig={enhanceConfig}
|
||||
@ -18747,6 +18798,7 @@ export function AdminSettingsClient() {
|
||||
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
|
||||
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
|
||||
handleSaveAiWashConfig={handleSaveAiWashConfig}
|
||||
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
|
||||
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
|
||||
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
|
||||
handleSaveExtendConfig={handleSaveExtendConfig}
|
||||
@ -18768,6 +18820,7 @@ export function AdminSettingsClient() {
|
||||
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
|
||||
setAngleEditConfig={setAngleEditConfig}
|
||||
setAiWashConfig={setAiWashConfig}
|
||||
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
|
||||
setEnhanceConfig={setEnhanceConfig}
|
||||
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
|
||||
setEnhanceProConfig={setEnhanceProConfig}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
HeadObjectCommand,
|
||||
ListPartsCommand,
|
||||
PutObjectCommand,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
@ -21,13 +22,22 @@ import {
|
||||
maybeEnqueueStorageMigrationTask,
|
||||
resolveS3UploadTarget,
|
||||
} from "@/lib/storage/service";
|
||||
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
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";
|
||||
|
||||
const logger = createLogger("direct-upload-api");
|
||||
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
const DIRECT_UPLOAD_MEDIA_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 = 15 * 60;
|
||||
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
|
||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
@ -76,6 +86,18 @@ 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),
|
||||
@ -85,6 +107,7 @@ const AbortSchema = z.object({
|
||||
|
||||
const RequestSchema = z.discriminatedUnion("action", [
|
||||
InitiateSchema,
|
||||
ResumeSchema,
|
||||
CompleteSchema,
|
||||
AbortSchema,
|
||||
]);
|
||||
@ -112,23 +135,49 @@ function assertUserOwnedKey(key: string, userId: string) {
|
||||
const normalized = String(key || "").replace(/^\/+/, "");
|
||||
const prefix = `${userUploadPrefix(userId)}/`;
|
||||
if (!normalized.startsWith(prefix)) {
|
||||
throw new Error("Invalid upload object key");
|
||||
const error = new Error("Invalid upload object key");
|
||||
error.name = "InvalidUploadObjectKeyError";
|
||||
throw error;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isAllowedDirectUpload(contentType: string, purpose?: string) {
|
||||
function isAllowedDirectUpload(
|
||||
contentType: string,
|
||||
purpose: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
return (
|
||||
allowedDirectUploadTypes.has(contentType) ||
|
||||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
allowedOpenClawVideoUploadTypes.has(contentType))
|
||||
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 }))
|
||||
);
|
||||
}
|
||||
|
||||
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 <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
||||
return 16 * 1024 * 1024;
|
||||
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||
return 64 * 1024 * 1024;
|
||||
}
|
||||
|
||||
async function createPartUrls(params: {
|
||||
@ -136,24 +185,24 @@ async function createPartUrls(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partCount: number;
|
||||
partNumbers: number[];
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
urls.push({
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return urls;
|
||||
return Promise.all(
|
||||
params.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -216,7 +265,7 @@ export async function POST(request: Request) {
|
||||
payload.filename
|
||||
);
|
||||
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose)) {
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
logger.warn("unsupported_direct_upload_type", {
|
||||
contentType,
|
||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||
@ -227,11 +276,17 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ 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(
|
||||
@ -239,7 +294,13 @@ export async function POST(request: Request) {
|
||||
userUploadPrefix(session.user.id)
|
||||
);
|
||||
|
||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
||||
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) {
|
||||
const presignStartedAt = Date.now();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
@ -299,7 +360,7 @@ export async function POST(request: Request) {
|
||||
bucket,
|
||||
key,
|
||||
uploadId,
|
||||
partCount,
|
||||
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
||||
});
|
||||
logger.info("direct_upload_multipart_prepared", {
|
||||
elapsedMs: elapsedMs(multipartStartedAt),
|
||||
@ -323,6 +384,98 @@ 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();
|
||||
@ -343,15 +496,18 @@ 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)) {
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||
}
|
||||
|
||||
if (payload.uploadId) {
|
||||
const completeMultipartStartedAt = Date.now();
|
||||
@ -397,8 +553,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const actualSize =
|
||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||
if (actualSize !== null && actualSize <= 0) {
|
||||
throw new Error("Uploaded object is empty");
|
||||
if (actualSize !== null && actualSize !== payload.size) {
|
||||
throw new Error(
|
||||
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = buildS3PublicUrl(target.config, key);
|
||||
@ -407,21 +565,43 @@ export async function POST(request: Request) {
|
||||
const assetStartedAt = Date.now();
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: resolvedContentType.startsWith("video/") ? "video" : "image",
|
||||
mediaType:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "file"
|
||||
: resolvedContentType.startsWith("video/")
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: "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: url,
|
||||
thumbnailUrl: 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,
|
||||
mimeType: resolvedContentType,
|
||||
displayName: payload.filename,
|
||||
sourceTable:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: "direct_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,
|
||||
size: actualSize ?? payload.size,
|
||||
@ -468,7 +648,13 @@ export async function POST(request: Request) {
|
||||
error: "Direct upload failed",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
{
|
||||
status:
|
||||
error instanceof Error &&
|
||||
error.name === "InvalidUploadObjectKeyError"
|
||||
? 403
|
||||
: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,10 @@ 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,
|
||||
@ -126,6 +130,12 @@ 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({
|
||||
@ -369,6 +379,10 @@ 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);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ 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),
|
||||
@ -52,6 +53,16 @@ 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", {
|
||||
|
||||
@ -9,7 +9,6 @@ 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 {
|
||||
@ -21,6 +20,7 @@ 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 > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
|
||||
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: "文件夹总大小不能超过 300MB" },
|
||||
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -564,7 +564,7 @@ export async function POST(request: Request) {
|
||||
(attachment.type === "video"
|
||||
? "视频附件"
|
||||
: attachment.type === "file"
|
||||
? "文件夹压缩包.zip"
|
||||
? "文件附件"
|
||||
: "图片附件"),
|
||||
url: attachment.url,
|
||||
contentType:
|
||||
@ -572,7 +572,7 @@ export async function POST(request: Request) {
|
||||
(attachment.type === "video"
|
||||
? "video/mp4"
|
||||
: attachment.type === "file"
|
||||
? "application/zip"
|
||||
? "application/octet-stream"
|
||||
: "image/jpeg"),
|
||||
}));
|
||||
|
||||
|
||||
@ -21,7 +21,13 @@ 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";
|
||||
@ -618,7 +624,23 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
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 pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||
@ -787,7 +809,7 @@ export async function POST(request: NextRequest) {
|
||||
operation,
|
||||
requestedModel: requestedModel || null,
|
||||
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
||||
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
resolvedModel: configuredScaleAdjustModelId,
|
||||
scaleAdjustProductLockMaskEditApplied: true,
|
||||
scaleAdjustPersonExclusionApplied:
|
||||
productLockAssets.personExclusionApplied,
|
||||
@ -814,7 +836,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
imageCount: 1,
|
||||
imageSize: requestedImageSize,
|
||||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
model: configuredScaleAdjustModelId,
|
||||
negativePrompt:
|
||||
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
||||
prompt,
|
||||
@ -847,7 +869,7 @@ export async function POST(request: NextRequest) {
|
||||
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
|
||||
target: region.targetRect,
|
||||
})),
|
||||
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
relightModel: configuredScaleAdjustModelId,
|
||||
sourceImageUrl,
|
||||
},
|
||||
}
|
||||
@ -876,7 +898,7 @@ export async function POST(request: NextRequest) {
|
||||
...(hasPositionMove
|
||||
? {
|
||||
scaleAdjustModelRelight: {
|
||||
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||
model: configuredScaleAdjustModelId,
|
||||
overlayImageUrl: overlayUpload.url,
|
||||
quality: "medium" as const,
|
||||
regions: scaleAdjustIntentRegions.map((region) => ({
|
||||
@ -975,6 +997,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
operation,
|
||||
requestedModel: requestedModel || null,
|
||||
resolvedModel: requestedModel || configuredLocalEditModelId || null,
|
||||
resolvedAspectRatio,
|
||||
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||||
regionReferenceImageCount: referenceImages.length,
|
||||
@ -986,7 +1009,7 @@ export async function POST(request: NextRequest) {
|
||||
displayPrompt,
|
||||
imageCount: 1,
|
||||
imageSize: requestedImageSize,
|
||||
model: requestedModel || undefined,
|
||||
model: requestedModel || configuredLocalEditModelId || undefined,
|
||||
negativePrompt:
|
||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||||
prompt,
|
||||
|
||||
@ -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 } from "@/lib/db/queries";
|
||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelDisplayEntries,
|
||||
@ -26,10 +26,16 @@ 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: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelDisplayEntries({
|
||||
includeHidden: allowHiddenModels,
|
||||
}),
|
||||
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
@ -192,6 +192,11 @@ 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,
|
||||
@ -313,7 +318,7 @@ const CanvasWorkspace = dynamic(
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
loadCanvasChatPanelModule().then(
|
||||
(module) => module.CanvasChatPanel
|
||||
),
|
||||
{
|
||||
@ -2029,7 +2034,7 @@ export default function CanvasEditorLegacyPage({
|
||||
);
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||
useState(FLOATING_PANEL_WIDTH);
|
||||
const [
|
||||
@ -3350,6 +3355,15 @@ 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);
|
||||
|
||||
@ -2798,9 +2798,9 @@ export default function CanvasModel3DPage() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||
setWorldFirstPerson(false);
|
||||
}
|
||||
setWorldFirstPerson(
|
||||
workspaceMode === "world" && worldViewportMode === "splat"
|
||||
);
|
||||
}, [workspaceMode, worldViewportMode]);
|
||||
|
||||
return (
|
||||
@ -4008,6 +4008,7 @@ export default function CanvasModel3DPage() {
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={
|
||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||
}
|
||||
|
||||
@ -1238,7 +1238,7 @@ function DetailResultLocalEditDialog({
|
||||
);
|
||||
if (unchangedRegion) {
|
||||
setActiveRegionId(unchangedRegion.id);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -610,7 +610,9 @@ export function AiWashWorkspace({
|
||||
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
|
||||
);
|
||||
const selectedPresetHasExecutableModel = Boolean(
|
||||
selectedExecutionState?.effectiveModelRef
|
||||
selectedExecutionState?.effectiveModelRef &&
|
||||
selectedExecutionState?.resolvedModel &&
|
||||
selectedExecutionState.resolvedModel.selectable !== false
|
||||
);
|
||||
const shouldShowUserModelSelector = Boolean(
|
||||
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
|
||||
@ -757,7 +759,10 @@ export function AiWashWorkspace({
|
||||
return;
|
||||
}
|
||||
if (!selectedPresetHasExecutableModel) {
|
||||
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
|
||||
toast.error(
|
||||
selectedExecutionState?.resolvedModel?.disabledReason ||
|
||||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -818,6 +823,15 @@ 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",
|
||||
@ -825,7 +839,9 @@ export function AiWashWorkspace({
|
||||
body: JSON.stringify({
|
||||
prompt: executionState.resolvedPrompt,
|
||||
referenceImage: requestReferenceImage,
|
||||
selectedModelId: executionState.effectiveModelRef,
|
||||
selectedModelId:
|
||||
executionState.resolvedModel.id ||
|
||||
executionState.effectiveModelRef,
|
||||
imageSize: config.imageSize,
|
||||
promptExtend: config.promptExtend,
|
||||
title: preset.title,
|
||||
|
||||
@ -309,18 +309,26 @@ import {
|
||||
updateClawdRequestState,
|
||||
} from "@/components/canvas/chat-panel/clawd-request-state";
|
||||
import {
|
||||
buildOpenClawArchiveDisplayText,
|
||||
buildOpenClawArchiveReferenceText,
|
||||
buildOpenClawFileDisplayText,
|
||||
buildOpenClawFileReferenceText,
|
||||
buildOpenClawVideoDisplayText,
|
||||
buildOpenClawVideoReferenceText,
|
||||
getOpenClawChatFileContentType,
|
||||
getOpenClawChatVideoContentType,
|
||||
isOpenClawChatArchiveAttachment,
|
||||
isOpenClawChatFolderFileAllowed,
|
||||
isOpenClawChatFile,
|
||||
isOpenClawChatFileAttachment,
|
||||
isOpenClawChatVideoAttachment,
|
||||
isOpenClawChatVideoFile,
|
||||
normalizeOpenClawChatMediaAttachments,
|
||||
OPENCLAW_CHAT_FILE_ACCEPT,
|
||||
OPENCLAW_CHAT_FILE_MAX_BYTES,
|
||||
OPENCLAW_CHAT_FILE_MAX_FILES,
|
||||
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES,
|
||||
OPENCLAW_CHAT_FOLDER_MAX_FILES,
|
||||
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
|
||||
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_VIDEO_ACCEPT,
|
||||
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
|
||||
OPENCLAW_CHAT_VIDEO_MAX_FILES,
|
||||
@ -486,9 +494,6 @@ function isClawdChatMessage(message: unknown) {
|
||||
return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd";
|
||||
}
|
||||
|
||||
const CLAWD_TABLE_ACCEPT =
|
||||
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
|
||||
const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
|
||||
const CLAWD_IMAGE_ACCEPT =
|
||||
".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff";
|
||||
const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
|
||||
@ -502,8 +507,24 @@ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
|
||||
"tif",
|
||||
"tiff",
|
||||
]);
|
||||
const CLAWD_TABLE_MAX_FILES = 3;
|
||||
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
function isClawdSpreadsheetAttachment(
|
||||
url: string,
|
||||
contentType?: string | null
|
||||
) {
|
||||
const normalizedType = String(contentType || "").toLowerCase();
|
||||
return (
|
||||
normalizedType.includes("spreadsheet") ||
|
||||
normalizedType === "text/csv" ||
|
||||
normalizedType === "text/tab-separated-values" ||
|
||||
/\.(xlsx|csv|tsv)(?:$|[?#])/i.test(String(url || ""))
|
||||
);
|
||||
}
|
||||
|
||||
function getClawdFileBadge(name: string, contentType?: string) {
|
||||
if (String(contentType || "").includes("folder-manifest")) return "FOLDER";
|
||||
const extension = name.split(".").pop()?.trim().toUpperCase() || "";
|
||||
return extension && extension.length <= 6 ? extension : "FILE";
|
||||
}
|
||||
const CLAWD_PROCESSING_HINT =
|
||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||
const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000;
|
||||
@ -512,12 +533,107 @@ const CLAWD_SUBMITTED_COMPOSER_RESTORE_GUARD_DELAYS_MS = [
|
||||
];
|
||||
const CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY =
|
||||
"ai-chatbot:clawd-submitted-composer";
|
||||
const CLAWD_FOLDER_UPLOAD_STORAGE_PREFIX = "yedir:clawd-folder-upload:v1:";
|
||||
const CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const CLAWD_FOLDER_UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
type ClawdFolderUploadedEntry = {
|
||||
path: string;
|
||||
signature: string;
|
||||
name: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
url: string | null;
|
||||
assetId?: string;
|
||||
};
|
||||
|
||||
type ClawdFolderUploadResumeState = {
|
||||
version: 1;
|
||||
fingerprint: string;
|
||||
updatedAt: number;
|
||||
entries: Record<string, ClawdFolderUploadedEntry>;
|
||||
};
|
||||
|
||||
function hashClawdUploadValue(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 getClawdFolderFilePath(file: File) {
|
||||
return (file.webkitRelativePath || file.name).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function getClawdFolderFileSignature(file: File, path: string) {
|
||||
return `${path}\u0000${file.size}\u0000${file.lastModified}`;
|
||||
}
|
||||
|
||||
function getClawdFolderFingerprint(files: File[]) {
|
||||
return files
|
||||
.map((file) =>
|
||||
getClawdFolderFileSignature(file, getClawdFolderFilePath(file))
|
||||
)
|
||||
.sort()
|
||||
.join("\u0001");
|
||||
}
|
||||
|
||||
function getClawdFolderUploadStorageKey(fingerprint: string) {
|
||||
return `${CLAWD_FOLDER_UPLOAD_STORAGE_PREFIX}${hashClawdUploadValue(
|
||||
fingerprint
|
||||
)}`;
|
||||
}
|
||||
|
||||
function readClawdFolderUploadResumeState(files: File[]) {
|
||||
if (typeof window === "undefined") return null;
|
||||
const fingerprint = getClawdFolderFingerprint(files);
|
||||
const storageKey = getClawdFolderUploadStorageKey(fingerprint);
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as ClawdFolderUploadResumeState;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
parsed.fingerprint !== fingerprint ||
|
||||
Date.now() - parsed.updatedAt > CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS
|
||||
) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeClawdFolderUploadResumeState(
|
||||
files: File[],
|
||||
state: ClawdFolderUploadResumeState | null
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
const fingerprint = getClawdFolderFingerprint(files);
|
||||
const storageKey = getClawdFolderUploadStorageKey(fingerprint);
|
||||
try {
|
||||
if (!state) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ ...state, updatedAt: Date.now() })
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const CLAWD_SIMPLE_PROMPT_PATTERNS = [
|
||||
/^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i,
|
||||
/^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/,
|
||||
];
|
||||
|
||||
function getClawdTableFileExtension(fileName: string) {
|
||||
function getClawdFileExtension(fileName: string) {
|
||||
return String(fileName || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
@ -525,19 +641,12 @@ function getClawdTableFileExtension(fileName: string) {
|
||||
.trim() || "";
|
||||
}
|
||||
|
||||
function isClawdTableFile(file: File) {
|
||||
return CLAWD_TABLE_ALLOWED_EXTENSIONS.has(
|
||||
getClawdTableFileExtension(file.name)
|
||||
);
|
||||
}
|
||||
|
||||
function isClawdImageFile(file: File) {
|
||||
return (
|
||||
file.type.startsWith("image/") ||
|
||||
CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name))
|
||||
CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdFileExtension(file.name))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeClawdComposerText(value: string) {
|
||||
return String(value || "")
|
||||
.replace(/\u200b/g, " ")
|
||||
@ -1711,6 +1820,8 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
|
||||
switch (phase) {
|
||||
case "preparing":
|
||||
return "正在准备上传";
|
||||
case "resuming":
|
||||
return "正在恢复上传断点";
|
||||
case "direct":
|
||||
return "正在上传到云储存";
|
||||
case "multipart":
|
||||
@ -1718,11 +1829,11 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
|
||||
case "server":
|
||||
return "正在通过服务器上传";
|
||||
case "finalizing":
|
||||
return "正在确认图片地址";
|
||||
return "正在确认文件地址";
|
||||
case "complete":
|
||||
return "上传完成";
|
||||
default:
|
||||
return "正在上传图片";
|
||||
return "正在上传文件";
|
||||
}
|
||||
}
|
||||
|
||||
@ -4621,15 +4732,15 @@ export function CanvasChatPanel({
|
||||
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
|
||||
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
|
||||
const isVideo = isOpenClawChatVideoAttachment(url, contentType);
|
||||
const isArchive = isOpenClawChatArchiveAttachment(url, contentType);
|
||||
const isFile = isOpenClawChatFileAttachment(url, contentType);
|
||||
|
||||
let mediaPreview: HTMLElement;
|
||||
if (isArchive) {
|
||||
if (isFile) {
|
||||
mediaPreview = document.createElement('span');
|
||||
mediaPreview.className =
|
||||
'inline-flex h-5 min-w-7 items-center justify-center rounded border border-border/60 bg-background px-1 text-[9px] font-semibold text-muted-foreground';
|
||||
mediaPreview.textContent = 'ZIP';
|
||||
mediaPreview.setAttribute('aria-label', '文件夹压缩包');
|
||||
mediaPreview.textContent = getClawdFileBadge(name, contentType);
|
||||
mediaPreview.setAttribute('aria-label', '文件附件');
|
||||
} else if (isVideo) {
|
||||
const videoPreview = document.createElement('video');
|
||||
videoPreview.src = url;
|
||||
@ -4992,7 +5103,7 @@ export function CanvasChatPanel({
|
||||
tagSpan.getAttribute('data-attachment-content-type') || undefined;
|
||||
|
||||
if (url) {
|
||||
if (isOpenClawChatArchiveAttachment(url, tagContentType)) {
|
||||
if (isOpenClawChatFileAttachment(url, tagContentType)) {
|
||||
setTagPreview(null);
|
||||
return;
|
||||
}
|
||||
@ -12080,7 +12191,7 @@ export function CanvasChatPanel({
|
||||
);
|
||||
const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter(
|
||||
(attachment) =>
|
||||
isOpenClawChatArchiveAttachment(
|
||||
isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
@ -12091,7 +12202,7 @@ export function CanvasChatPanel({
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
) &&
|
||||
!isOpenClawChatArchiveAttachment(
|
||||
!isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
@ -12106,7 +12217,7 @@ export function CanvasChatPanel({
|
||||
attachment.contentType
|
||||
)
|
||||
? "video"
|
||||
: isOpenClawChatArchiveAttachment(
|
||||
: isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
@ -12145,7 +12256,7 @@ export function CanvasChatPanel({
|
||||
const videoReferenceText = buildOpenClawVideoReferenceText(
|
||||
openClawMediaAttachments
|
||||
);
|
||||
const folderReferenceText = buildOpenClawArchiveReferenceText(
|
||||
const folderReferenceText = buildOpenClawFileReferenceText(
|
||||
openClawMediaAttachments
|
||||
);
|
||||
const requestMessage = [
|
||||
@ -12173,7 +12284,7 @@ export function CanvasChatPanel({
|
||||
buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot),
|
||||
buildClawdImageDisplayText(imageAttachmentsSnapshot),
|
||||
buildOpenClawVideoDisplayText(openClawMediaAttachments),
|
||||
buildOpenClawArchiveDisplayText(openClawMediaAttachments),
|
||||
buildOpenClawFileDisplayText(openClawMediaAttachments),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@ -12423,9 +12534,10 @@ export function CanvasChatPanel({
|
||||
void mutateClawdHistory();
|
||||
hasHydratedHistoryRef.current = true;
|
||||
} catch (error) {
|
||||
const wasCanceled =
|
||||
canceledClawdMessageIdsRef.current.has(userMessageId) ||
|
||||
(error instanceof Error && error.name === "AbortError");
|
||||
const wasCanceled = shouldRestoreClawdComposerAfterFailure({
|
||||
explicitlyCanceled:
|
||||
canceledClawdMessageIdsRef.current.has(userMessageId),
|
||||
});
|
||||
if (wasCanceled) {
|
||||
const shouldRestoreRequest =
|
||||
pendingClawdRequestRestoresRef.current.get(requestChatId) ===
|
||||
@ -13157,8 +13269,8 @@ export function CanvasChatPanel({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error("图片大小不能超过 50MB");
|
||||
if (file.size > OPENCLAW_CHAT_FILE_MAX_BYTES) {
|
||||
toast.error("图片大小不能超过 2GB");
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -13281,7 +13393,7 @@ export function CanvasChatPanel({
|
||||
return null;
|
||||
}
|
||||
if (file.size > OPENCLAW_CHAT_VIDEO_MAX_BYTES) {
|
||||
toast.error(`${file.name} 超过 500MB,请先压缩视频`);
|
||||
toast.error(`${file.name} 超过 2GB,请先压缩视频`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -13332,6 +13444,81 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||
|
||||
const uploadClawdFileForComposerAttachment = useCallback(async (
|
||||
file: File,
|
||||
progressOptions: CanvasUploadProgressOptions,
|
||||
options?: {
|
||||
purpose?: string;
|
||||
onProgress?: (progress: UploadProgress) => void;
|
||||
clearProgress?: boolean;
|
||||
}
|
||||
): Promise<ComposerUploadedImage> => {
|
||||
const purpose = options?.purpose || OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE;
|
||||
const isFolderFile = purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE;
|
||||
if (isFolderFile && !isOpenClawChatFolderFileAllowed(file)) {
|
||||
throw new Error(`${file.name} 为不安全的可执行或网页文件,已拒绝上传`);
|
||||
}
|
||||
const normalizedContentType = isFolderFile
|
||||
? file.type || "application/octet-stream"
|
||||
: getOpenClawChatFileContentType(file);
|
||||
|
||||
if (!isFolderFile && !normalizedContentType) {
|
||||
throw new Error(`${file.name} 格式不支持`);
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
throw new Error(`${file.name} 是空文件`);
|
||||
}
|
||||
if (file.size > OPENCLAW_CHAT_FILE_MAX_BYTES) {
|
||||
throw new Error(`${file.name} 超过 2GB`);
|
||||
}
|
||||
|
||||
const uploadFile =
|
||||
file.type === normalizedContentType
|
||||
? file
|
||||
: new File([file], file.name, {
|
||||
type: normalizedContentType,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("file", uploadFile);
|
||||
formData.append("purpose", purpose);
|
||||
updateComposerUploadProgress(progressOptions);
|
||||
|
||||
const response = await fetchUploadWithRetry(formData, {
|
||||
onProgress: (progress) => {
|
||||
if (options?.onProgress) {
|
||||
options.onProgress(progress);
|
||||
} else {
|
||||
updateComposerUploadProgress(progressOptions, progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
String(data?.error || data?.details || `${file.name} 上传失败`)
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = String(data?.url || "").trim();
|
||||
if (!isValidChatHttpUrl(imageUrl)) {
|
||||
throw new Error("储存桶未返回可访问的文件地址");
|
||||
}
|
||||
if (options?.clearProgress !== false) {
|
||||
clearComposerUploadProgress(250);
|
||||
}
|
||||
return {
|
||||
imageUrl,
|
||||
name: file.name,
|
||||
contentType:
|
||||
String(data?.contentType || "").trim() || normalizedContentType,
|
||||
assetId:
|
||||
typeof data?.assetId === "string" && data.assetId.trim()
|
||||
? data.assetId.trim()
|
||||
: undefined,
|
||||
};
|
||||
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||
|
||||
const uploadClawdFolderForComposerAttachment = useCallback(async (
|
||||
files: File[]
|
||||
): Promise<ComposerUploadedImage | null> => {
|
||||
@ -13346,13 +13533,11 @@ export function CanvasChatPanel({
|
||||
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
|
||||
toast.error("文件夹总大小不能超过 300MB");
|
||||
toast.error("文件夹总大小不能超过 2GB");
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePaths = files.map(
|
||||
(file) => file.webkitRelativePath || file.name
|
||||
);
|
||||
const relativePaths = files.map(getClawdFolderFilePath);
|
||||
const rootFolders = Array.from(
|
||||
new Set(
|
||||
relativePaths
|
||||
@ -13365,16 +13550,10 @@ export function CanvasChatPanel({
|
||||
? rootFolders[0]
|
||||
: `${rootFolders.length}个文件夹-${Date.now()}`;
|
||||
const progressOptions: CanvasUploadProgressOptions = {
|
||||
title: "正在打包并上传文件夹",
|
||||
fileName: `${archiveBaseName}.zip`,
|
||||
title: "正在上传文件夹",
|
||||
fileName: archiveBaseName,
|
||||
target: "composer-reference",
|
||||
};
|
||||
const formData = new FormData();
|
||||
files.forEach((file, index) => {
|
||||
formData.append("files", file, file.name);
|
||||
formData.append("relativePaths", relativePaths[index]);
|
||||
});
|
||||
formData.append("archiveName", archiveBaseName);
|
||||
|
||||
updateComposerUploadProgress(progressOptions, {
|
||||
loaded: 0,
|
||||
@ -13384,74 +13563,191 @@ export function CanvasChatPanel({
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await new Promise<Record<string, unknown>>(
|
||||
(resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/canvas/clawd-chat/folder-upload");
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
xhr.upload.onprogress = (event) => {
|
||||
const total = event.lengthComputable ? event.total : totalBytes;
|
||||
const rawPercent = total > 0 ? (event.loaded / total) * 100 : 5;
|
||||
updateComposerUploadProgress(progressOptions, {
|
||||
loaded: event.loaded,
|
||||
total,
|
||||
percent: Math.max(1, Math.min(92, Math.round(rawPercent))),
|
||||
phase: "server",
|
||||
});
|
||||
const fingerprint = getClawdFolderFingerprint(files);
|
||||
const resumeState = readClawdFolderUploadResumeState(files);
|
||||
const state: ClawdFolderUploadResumeState = resumeState || {
|
||||
version: 1,
|
||||
fingerprint,
|
||||
updatedAt: Date.now(),
|
||||
entries: {},
|
||||
};
|
||||
const uploadedEntries: Array<ClawdFolderUploadedEntry | undefined> =
|
||||
new Array(files.length);
|
||||
const loadedByIndex = new Map<number, number>();
|
||||
let aggregateLoaded = 0;
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const path = relativePaths[index];
|
||||
const signature = getClawdFolderFileSignature(file, path);
|
||||
const restored = state.entries[path];
|
||||
if (
|
||||
restored?.signature === signature &&
|
||||
(file.size === 0 ||
|
||||
(restored.url && isValidChatHttpUrl(restored.url)))
|
||||
) {
|
||||
uploadedEntries[index] = restored;
|
||||
loadedByIndex.set(index, file.size);
|
||||
aggregateLoaded += file.size;
|
||||
}
|
||||
});
|
||||
|
||||
const reportFolderProgress = (
|
||||
index: number,
|
||||
loaded: number,
|
||||
phase: UploadProgress["phase"]
|
||||
) => {
|
||||
const nextLoaded = Math.min(files[index].size, Math.max(0, loaded));
|
||||
const previousLoaded = loadedByIndex.get(index) || 0;
|
||||
loadedByIndex.set(index, nextLoaded);
|
||||
aggregateLoaded += nextLoaded - previousLoaded;
|
||||
updateComposerUploadProgress(progressOptions, {
|
||||
loaded: aggregateLoaded,
|
||||
total: totalBytes,
|
||||
percent:
|
||||
totalBytes > 0
|
||||
? Math.max(
|
||||
1,
|
||||
Math.min(96, Math.round((aggregateLoaded / totalBytes) * 96))
|
||||
)
|
||||
: 96,
|
||||
phase,
|
||||
});
|
||||
};
|
||||
|
||||
let nextIndex = 0;
|
||||
const uploadWorker = async () => {
|
||||
for (;;) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= files.length) return;
|
||||
if (uploadedEntries[index]) continue;
|
||||
|
||||
const file = files[index];
|
||||
const path = relativePaths[index];
|
||||
const signature = getClawdFolderFileSignature(file, path);
|
||||
if (file.size === 0) {
|
||||
const emptyEntry: ClawdFolderUploadedEntry = {
|
||||
path,
|
||||
signature,
|
||||
name: file.name,
|
||||
size: 0,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
url: null,
|
||||
};
|
||||
uploadedEntries[index] = emptyEntry;
|
||||
state.entries[path] = emptyEntry;
|
||||
writeClawdFolderUploadResumeState(files, state);
|
||||
continue;
|
||||
}
|
||||
|
||||
const uploaded = await uploadClawdFileForComposerAttachment(
|
||||
file,
|
||||
{
|
||||
...progressOptions,
|
||||
fileName: path,
|
||||
fileIndex: index,
|
||||
fileTotal: files.length,
|
||||
},
|
||||
{
|
||||
purpose: OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||
clearProgress: false,
|
||||
onProgress: (progress) => {
|
||||
reportFolderProgress(index, progress.loaded, progress.phase);
|
||||
},
|
||||
}
|
||||
);
|
||||
reportFolderProgress(index, file.size, "complete");
|
||||
const entry: ClawdFolderUploadedEntry = {
|
||||
path,
|
||||
signature,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
contentType: uploaded.contentType || "application/octet-stream",
|
||||
url: uploaded.imageUrl,
|
||||
assetId: uploaded.assetId,
|
||||
};
|
||||
xhr.upload.onload = () => {
|
||||
uploadedEntries[index] = entry;
|
||||
state.entries[path] = entry;
|
||||
writeClawdFolderUploadResumeState(files, state);
|
||||
}
|
||||
};
|
||||
|
||||
const workerResults = await Promise.allSettled(
|
||||
Array.from(
|
||||
{
|
||||
length: Math.min(
|
||||
CLAWD_FOLDER_UPLOAD_CONCURRENCY,
|
||||
files.length
|
||||
),
|
||||
},
|
||||
() => uploadWorker()
|
||||
)
|
||||
);
|
||||
const failedWorker = workerResults.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected"
|
||||
);
|
||||
if (failedWorker) {
|
||||
throw failedWorker.reason;
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
kind: "yedir-folder-manifest",
|
||||
name: archiveBaseName,
|
||||
createdAt: new Date().toISOString(),
|
||||
fileCount: files.length,
|
||||
totalSize: totalBytes,
|
||||
files: uploadedEntries.filter(
|
||||
(entry): entry is ClawdFolderUploadedEntry => Boolean(entry)
|
||||
),
|
||||
};
|
||||
const manifestName = `${archiveBaseName}.folder.json`;
|
||||
const manifestFile = new File(
|
||||
[JSON.stringify(manifest)],
|
||||
manifestName,
|
||||
{ type: "application/vnd.yedir.folder-manifest+json" }
|
||||
);
|
||||
const uploadedManifest = await uploadClawdFileForComposerAttachment(
|
||||
manifestFile,
|
||||
{
|
||||
...progressOptions,
|
||||
fileName: manifestName,
|
||||
},
|
||||
{
|
||||
clearProgress: false,
|
||||
onProgress: (progress) => {
|
||||
updateComposerUploadProgress(progressOptions, {
|
||||
loaded: totalBytes,
|
||||
total: totalBytes,
|
||||
percent: 94,
|
||||
percent: Math.max(97, Math.min(99, progress.percent)),
|
||||
phase: "finalizing",
|
||||
});
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("文件夹上传网络异常"));
|
||||
xhr.ontimeout = () => reject(new Error("文件夹打包上传超时"));
|
||||
xhr.onload = () => {
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText || "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
reject(new Error("文件夹上传返回格式不正确"));
|
||||
return;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(String(payload.error || "文件夹上传失败")));
|
||||
return;
|
||||
}
|
||||
resolve(payload);
|
||||
};
|
||||
xhr.send(formData);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const url = String(data.url || "").trim();
|
||||
if (!isValidChatHttpUrl(url)) {
|
||||
throw new Error("七牛云未返回可访问的文件地址");
|
||||
}
|
||||
updateComposerUploadProgress(progressOptions, {
|
||||
loaded: totalBytes,
|
||||
total: totalBytes,
|
||||
percent: 100,
|
||||
phase: "complete",
|
||||
});
|
||||
writeClawdFolderUploadResumeState(files, null);
|
||||
clearComposerUploadProgress(350);
|
||||
return {
|
||||
imageUrl: url,
|
||||
name: String(data.name || `${archiveBaseName}.zip`),
|
||||
contentType: "application/zip",
|
||||
...uploadedManifest,
|
||||
name: manifestName,
|
||||
contentType: "application/vnd.yedir.folder-manifest+json",
|
||||
};
|
||||
} catch (error) {
|
||||
clearComposerUploadProgress();
|
||||
toast.error(error instanceof Error ? error.message : "文件夹上传失败");
|
||||
return null;
|
||||
}
|
||||
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||
}, [
|
||||
clearComposerUploadProgress,
|
||||
updateComposerUploadProgress,
|
||||
uploadClawdFileForComposerAttachment,
|
||||
]);
|
||||
|
||||
const handleClawdFolderSelect = useCallback(async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
@ -13461,14 +13757,14 @@ export function CanvasChatPanel({
|
||||
if (files.length === 0) return;
|
||||
|
||||
const existingArchiveCount = attachments.filter((attachment) =>
|
||||
isOpenClawChatArchiveAttachment(
|
||||
isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
).length;
|
||||
if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
|
||||
toast.info(
|
||||
`一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个文件夹压缩包`
|
||||
`一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个表格、压缩包或文件夹`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -13476,7 +13772,7 @@ export function CanvasChatPanel({
|
||||
const uploaded = await uploadClawdFolderForComposerAttachment(files);
|
||||
if (!uploaded) return;
|
||||
addUploadedImageAttachmentToComposer(uploaded);
|
||||
toast.success("文件夹已打包上传到七牛云,可继续添加其他文件夹");
|
||||
toast.success("文件夹已上传并保留目录结构,可继续添加其他文件夹");
|
||||
}, [
|
||||
addUploadedImageAttachmentToComposer,
|
||||
attachments,
|
||||
@ -13761,31 +14057,31 @@ export function CanvasChatPanel({
|
||||
let selectedFiles = Array.from(files);
|
||||
|
||||
if (isClawdMode) {
|
||||
const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file));
|
||||
const imageFiles = selectedFiles.filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
const videoFiles = selectedFiles.filter((file) =>
|
||||
isOpenClawChatVideoFile(file)
|
||||
);
|
||||
const genericFiles = selectedFiles.filter((file) =>
|
||||
isOpenClawChatFile(file)
|
||||
);
|
||||
const unsupportedFiles = selectedFiles.filter(
|
||||
(file) =>
|
||||
!isClawdTableFile(file) &&
|
||||
!file.type.startsWith("image/") &&
|
||||
!isOpenClawChatVideoFile(file)
|
||||
!isOpenClawChatVideoFile(file) &&
|
||||
!isOpenClawChatFile(file)
|
||||
);
|
||||
|
||||
unsupportedFiles.forEach((file) => {
|
||||
toast.error(
|
||||
`${file.name} 格式不支持,请上传图片、视频或 .xlsx/.csv/.tsv`
|
||||
`${file.name} 格式不支持,请上传图片、视频、表格或压缩包`
|
||||
);
|
||||
});
|
||||
if (tableFiles.length > 0) {
|
||||
addClawdTableFiles(tableFiles);
|
||||
}
|
||||
|
||||
let handledImageCount = 0;
|
||||
let handledVideoCount = 0;
|
||||
let handledFileCount = 0;
|
||||
if (imageFiles.length > 0) {
|
||||
try {
|
||||
for (const [index, file] of imageFiles.entries()) {
|
||||
@ -13857,6 +14153,53 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
if (genericFiles.length > 0) {
|
||||
const existingFileCount = attachments.filter((attachment) =>
|
||||
isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
).length;
|
||||
const availableFileSlots = Math.max(
|
||||
0,
|
||||
OPENCLAW_CHAT_FILE_MAX_FILES - existingFileCount
|
||||
);
|
||||
const acceptedFiles = genericFiles.slice(0, availableFileSlots);
|
||||
if (acceptedFiles.length < genericFiles.length) {
|
||||
toast.info(
|
||||
`${CLAW_MODE_LABEL} 对话一次最多添加 ${OPENCLAW_CHAT_FILE_MAX_FILES} 个文件附件`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [index, file] of acceptedFiles.entries()) {
|
||||
try {
|
||||
const uploaded = await uploadClawdFileForComposerAttachment(
|
||||
file,
|
||||
{
|
||||
title:
|
||||
acceptedFiles.length === 1
|
||||
? "正在上传文件到储存桶"
|
||||
: `正在上传 ${acceptedFiles.length} 个文件`,
|
||||
fileName: file.name,
|
||||
fileIndex: index,
|
||||
fileTotal: acceptedFiles.length,
|
||||
target: "composer-reference",
|
||||
}
|
||||
);
|
||||
handledFileCount += 1;
|
||||
addUploadedImageAttachmentToComposer(uploaded);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : `${file.name} 上传失败`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearComposerUploadProgress(450);
|
||||
}
|
||||
}
|
||||
|
||||
if (handledImageCount > 0) {
|
||||
toast.success(
|
||||
handledImageCount === 1
|
||||
@ -13871,6 +14214,13 @@ export function CanvasChatPanel({
|
||||
: `已添加 ${handledVideoCount} 个视频为 ${CLAW_MODE_LABEL} 附件`
|
||||
);
|
||||
}
|
||||
if (handledFileCount > 0) {
|
||||
toast.success(
|
||||
handledFileCount === 1
|
||||
? "文件已上传并添加到 Claw 对话"
|
||||
: `已上传并添加 ${handledFileCount} 个文件`
|
||||
);
|
||||
}
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
@ -14105,58 +14455,6 @@ export function CanvasChatPanel({
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
function addClawdTableFiles(selectedFiles: File[]) {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length;
|
||||
if (remainingSlots <= 0) {
|
||||
toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`);
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedFiles: File[] = [];
|
||||
for (const file of selectedFiles) {
|
||||
if (!isClawdTableFile(file)) {
|
||||
toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`);
|
||||
continue;
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
toast.error(`${file.name} 是空文件`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > CLAWD_TABLE_MAX_BYTES) {
|
||||
toast.error(`${file.name} 超过 10MB,请先拆分或压缩`);
|
||||
continue;
|
||||
}
|
||||
acceptedFiles.push(file);
|
||||
}
|
||||
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
setClawdTableFiles((currentFiles) => {
|
||||
const existingSignatures = new Set(
|
||||
currentFiles.map(
|
||||
(file) => `${file.name}:${file.size}:${file.lastModified}`
|
||||
)
|
||||
);
|
||||
const nextFiles = [...currentFiles];
|
||||
for (const file of acceptedFiles) {
|
||||
if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break;
|
||||
const signature = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (existingSignatures.has(signature)) {
|
||||
continue;
|
||||
}
|
||||
existingSignatures.add(signature);
|
||||
nextFiles.push(file);
|
||||
}
|
||||
return nextFiles;
|
||||
});
|
||||
|
||||
if (acceptedFiles.length > remainingSlots) {
|
||||
toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`);
|
||||
}
|
||||
}
|
||||
|
||||
const removeClawdTableFile = useCallback((index: number) => {
|
||||
setClawdTableFiles((currentFiles) =>
|
||||
currentFiles.filter((_, fileIndex) => fileIndex !== index)
|
||||
@ -15626,7 +15924,7 @@ export function CanvasChatPanel({
|
||||
message.role === "user"
|
||||
? collectMessageVideoAttachments(message as any)
|
||||
: [];
|
||||
const userArchiveAttachments: Array<{
|
||||
const userFileAttachments: Array<{
|
||||
url: string;
|
||||
name: string;
|
||||
contentType?: string;
|
||||
@ -15651,15 +15949,20 @@ export function CanvasChatPanel({
|
||||
).trim();
|
||||
return {
|
||||
url,
|
||||
name: String(part?.name || "Folder.zip").trim() || "Folder.zip",
|
||||
name: String(part?.name || "文件").trim() || "文件",
|
||||
contentType: contentType || undefined,
|
||||
};
|
||||
})
|
||||
.filter((attachment) =>
|
||||
isOpenClawChatArchiveAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
.filter(
|
||||
(attachment) =>
|
||||
isOpenClawChatFileAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
) &&
|
||||
!isClawdSpreadsheetAttachment(
|
||||
attachment.url,
|
||||
attachment.contentType
|
||||
)
|
||||
)
|
||||
.map((attachment) => [attachment.url, attachment])
|
||||
).values()
|
||||
@ -15883,10 +16186,10 @@ export function CanvasChatPanel({
|
||||
);
|
||||
});
|
||||
|
||||
userArchiveAttachments.forEach((attachment, idx) => {
|
||||
userFileAttachments.forEach((attachment, idx) => {
|
||||
renderedUserParts.push(
|
||||
<a
|
||||
key={`archive-${attachment.url}-${idx}`}
|
||||
key={`file-${attachment.url}-${idx}`}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@ -15896,7 +16199,7 @@ export function CanvasChatPanel({
|
||||
? "border border-border/60 bg-background"
|
||||
: "border border-amber-200/80 bg-amber-50/90 text-amber-950 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-100"
|
||||
)}
|
||||
title="打开文件夹压缩包"
|
||||
title="打开文件"
|
||||
>
|
||||
<FileArchive className="h-4 w-4 shrink-0" />
|
||||
<span className="max-w-[120px] truncate text-current">
|
||||
@ -16009,9 +16312,11 @@ export function CanvasChatPanel({
|
||||
const shouldDeferAssistantMedia =
|
||||
(isClawAssistantMessage || shouldTypeAgentAssistantMessage) &&
|
||||
(shouldAnimateAnalysis || shouldAnimatePlainText);
|
||||
const standaloneImageUrls = isClawAssistantMessage
|
||||
? filterStandaloneAssistantImageUrls(rawText, imageUrls)
|
||||
: imageUrls;
|
||||
const standaloneImageUrls =
|
||||
filterStandaloneAssistantImageUrls(
|
||||
rawText,
|
||||
imageUrls
|
||||
);
|
||||
const visibleImageUrls = shouldDeferAssistantMedia
|
||||
? []
|
||||
: standaloneImageUrls;
|
||||
@ -17943,7 +18248,7 @@ export function CanvasChatPanel({
|
||||
type="file"
|
||||
accept={
|
||||
isClawdMode
|
||||
? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${CLAWD_TABLE_ACCEPT}`
|
||||
? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${OPENCLAW_CHAT_FILE_ACCEPT}`
|
||||
: supportsAtlasVideoReference
|
||||
? "image/*,video/mp4,video/quicktime,video/webm"
|
||||
: "image/*"
|
||||
|
||||
@ -277,6 +277,10 @@ 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] || "";
|
||||
@ -297,12 +301,36 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
const tableText = lines.slice(index, endIndex).join("\n");
|
||||
extractAssistantImageUrls(tableText).forEach(addUrl);
|
||||
extractPlainImageUrls(tableText).forEach(addUrl);
|
||||
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -278,6 +278,10 @@ 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;
|
||||
@ -287,12 +291,36 @@ 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]
|
||||
@ -349,7 +377,10 @@ function ModelScene({
|
||||
loader.setKTX2Loader(ktx2Loader);
|
||||
},
|
||||
);
|
||||
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||
const scene = useMemo(
|
||||
() => cloneSceneWithIndependentMaterials(model.scene),
|
||||
[model.scene]
|
||||
);
|
||||
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
||||
const modelRotationKey = modelRotation?.join("|") || "";
|
||||
const groundY = useMemo(
|
||||
@ -374,6 +405,20 @@ 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;
|
||||
@ -383,7 +428,6 @@ 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;
|
||||
}
|
||||
@ -652,7 +696,7 @@ export function Model3DViewer({
|
||||
const resolvedModelUrl = useMemo(() => {
|
||||
return buildThreeAssetUrl(modelUrl);
|
||||
}, [modelUrl]);
|
||||
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
|
||||
const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08;
|
||||
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
|
||||
setGroundY((previousGroundY) =>
|
||||
Math.abs(previousGroundY - nextGroundY) < 0.001
|
||||
|
||||
@ -97,6 +97,7 @@ import {
|
||||
createLocalEditRegionId,
|
||||
getLocalEditRegionsFromMetadata,
|
||||
getNormalizedRectOverlayStyle,
|
||||
hasScaleAdjustTargetChanged,
|
||||
isHttpImageUrl,
|
||||
LOCAL_EDIT_COLORS,
|
||||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
||||
@ -219,6 +220,9 @@ 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,
|
||||
@ -2049,6 +2053,7 @@ 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
|
||||
);
|
||||
@ -4418,15 +4423,11 @@ export function ImageNode({
|
||||
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
||||
return;
|
||||
}
|
||||
const unchangedRegion = validRegions.find((region) => {
|
||||
const regionScalePercent = deriveCanvasScalePercentFromRects(
|
||||
region.rect,
|
||||
region.targetRect
|
||||
);
|
||||
return Math.abs(regionScalePercent) < 1;
|
||||
});
|
||||
const unchangedRegion = validRegions.find(
|
||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
||||
);
|
||||
if (unchangedRegion) {
|
||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
|
||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -6658,8 +6659,58 @@ export function ImageNode({
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
// 处理图片编辑操作 - 集成我们现有的 API
|
||||
const handleAction = async (action: string) => {
|
||||
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"
|
||||
) => {
|
||||
if (!data.src) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
@ -6762,34 +6813,7 @@ export function ImageNode({
|
||||
toast.info("图层拆分功能未开启");
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
await executeSharedToolbarCapability("layer-split");
|
||||
return;
|
||||
}
|
||||
if (action === "box-cutout") {
|
||||
@ -6851,102 +6875,13 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
if (
|
||||
action === "rmbg" ||
|
||||
action === "watermark" ||
|
||||
action === "enhance" ||
|
||||
action === "extend"
|
||||
) {
|
||||
await executeSharedToolbarCapability(action);
|
||||
}
|
||||
};
|
||||
|
||||
@ -6990,7 +6925,16 @@ export function ImageNode({
|
||||
const action = customEvent.detail?.action;
|
||||
|
||||
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
||||
void handleAction(action);
|
||||
if (action === "scene-fusion" || action === "annotate") {
|
||||
void handleAction(action);
|
||||
return;
|
||||
}
|
||||
const capability = getCanvasCapabilityByClientAction(
|
||||
action as CanvasCapabilityClientAction
|
||||
);
|
||||
if (capability) {
|
||||
void handleAction(capability.clientAction);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
|
||||
@ -103,6 +103,7 @@ type World3DViewerProps = {
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
showMovementControls?: boolean;
|
||||
showInfoOverlay?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
@ -117,7 +118,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.22;
|
||||
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.45;
|
||||
|
||||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||
forward: false,
|
||||
@ -1494,6 +1495,7 @@ 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);
|
||||
@ -1779,7 +1781,7 @@ function WorldSplatScene({
|
||||
<>
|
||||
<ambientLight intensity={1.6} />
|
||||
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
||||
{groundHelperOffset !== null ? (
|
||||
{shouldShowGroundHelper ? (
|
||||
<gridHelper
|
||||
args={[18, 18, "#334155", "#0f172a"]}
|
||||
position={[0, groundHelperOffset, 0]}
|
||||
@ -2280,6 +2282,7 @@ export function World3DViewer({
|
||||
resetViewToken,
|
||||
onCaptureReady,
|
||||
showMovementControls = false,
|
||||
showInfoOverlay = !minimalFrame,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
@ -2386,7 +2389,7 @@ export function World3DViewer({
|
||||
minimalFrame={minimalFrame}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={!minimalFrame}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
showMovementControls={showMovementControls}
|
||||
splatUrl={splatUrl}
|
||||
splatUrls={splatUrls}
|
||||
|
||||
@ -459,7 +459,7 @@ function MarkdownTableImagePreview({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40"
|
||||
className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
|
||||
data-no-drag="true"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@ -129,7 +129,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
return {
|
||||
eventType: "unavailable",
|
||||
severity: "warning",
|
||||
counted: false,
|
||||
counted: true,
|
||||
reason: "transport_failure_no_provider_response",
|
||||
};
|
||||
}
|
||||
@ -184,7 +184,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
return {
|
||||
eventType: "timeout",
|
||||
severity: "warning",
|
||||
counted: false,
|
||||
counted: true,
|
||||
reason: "timeout_without_provider_response",
|
||||
};
|
||||
}
|
||||
|
||||
32
lib/canvas/local-edit-config.ts
Normal file
32
lib/canvas/local-edit-config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@ -1,20 +1,30 @@
|
||||
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
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";
|
||||
|
||||
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 = 500 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
const DIRECT_UPLOAD_MEDIA_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 = 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 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 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,
|
||||
@ -53,6 +63,7 @@ let directUploadSuppressedUntil = 0;
|
||||
|
||||
export type UploadProgressPhase =
|
||||
| "preparing"
|
||||
| "resuming"
|
||||
| "direct"
|
||||
| "multipart"
|
||||
| "server"
|
||||
@ -74,6 +85,7 @@ type UploadFormFile = {
|
||||
contentType: string;
|
||||
purpose: string;
|
||||
clientUploadId: string;
|
||||
lastModified: number;
|
||||
};
|
||||
|
||||
type DirectUploadPart = {
|
||||
@ -82,6 +94,10 @@ type DirectUploadPart = {
|
||||
};
|
||||
|
||||
type DirectUploadInitiateResponse =
|
||||
| {
|
||||
mode: "completed";
|
||||
key: string;
|
||||
}
|
||||
| {
|
||||
mode: "single";
|
||||
key: string;
|
||||
@ -95,8 +111,37 @@ 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";
|
||||
@ -250,6 +295,105 @@ 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" &&
|
||||
@ -281,37 +425,62 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
|
||||
typeof File !== "undefined" && value instanceof File && value.name
|
||||
? value.name
|
||||
: `upload-${Date.now()}`;
|
||||
const contentType = normalizeBrowserImageType(
|
||||
value.type || "application/octet-stream",
|
||||
filename
|
||||
);
|
||||
const purposeValue = formData.get("purpose");
|
||||
const purpose = typeof purposeValue === "string" ? purposeValue : "";
|
||||
const contentType = normalizeUploadContentType(
|
||||
value.type || "application/octet-stream",
|
||||
filename,
|
||||
purpose
|
||||
);
|
||||
const clientUploadId = ensureClientUploadId(formData);
|
||||
|
||||
return {
|
||||
blob: value,
|
||||
filename,
|
||||
contentType,
|
||||
purpose: typeof purposeValue === "string" ? purposeValue : "",
|
||||
purpose,
|
||||
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;
|
||||
}
|
||||
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
|
||||
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) {
|
||||
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));
|
||||
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,
|
||||
}));
|
||||
if (!supportsDirectUpload) {
|
||||
return false;
|
||||
}
|
||||
@ -424,7 +593,16 @@ async function postDirectUploadAction<T>(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(String(await readErrorPayload(response)));
|
||||
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);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
@ -578,7 +756,7 @@ async function fetchDirectPartWithRetry(
|
||||
maxRetries: number,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
options?: { stallTimeoutMs?: number }
|
||||
options?: { stallTimeoutMs?: number; timeoutMs?: number }
|
||||
) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
@ -589,6 +767,7 @@ async function fetchDirectPartWithRetry(
|
||||
onProgress,
|
||||
signal,
|
||||
stallTimeoutMs: options?.stallTimeoutMs,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
@ -661,11 +840,38 @@ async function uploadMultipartParts(params: {
|
||||
maxRetries: number;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: UploadProgressHandler;
|
||||
onPartComplete?: () => void;
|
||||
}) {
|
||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
|
||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [
|
||||
...(params.upload.uploadedParts || []).map((part) => ({
|
||||
ETag: part.ETag,
|
||||
PartNumber: part.PartNumber,
|
||||
})),
|
||||
];
|
||||
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(
|
||||
@ -700,7 +906,10 @@ async function uploadMultipartParts(params: {
|
||||
(loaded, total) => {
|
||||
reportPartProgress(part.partNumber, loaded, total || body.size);
|
||||
},
|
||||
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
|
||||
{
|
||||
stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS,
|
||||
timeoutMs: DIRECT_UPLOAD_PART_TOTAL_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@ -718,6 +927,7 @@ async function uploadMultipartParts(params: {
|
||||
ETag: etag,
|
||||
PartNumber: part.partNumber,
|
||||
});
|
||||
params.onPartComplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
@ -728,7 +938,11 @@ async function uploadMultipartParts(params: {
|
||||
)
|
||||
);
|
||||
|
||||
return uploadedParts.sort((a, b) => a.PartNumber - b.PartNumber);
|
||||
return Array.from(
|
||||
new Map(
|
||||
uploadedParts.map((part) => [part.PartNumber, part] as const)
|
||||
).values()
|
||||
).sort((a, b) => a.PartNumber - b.PartNumber);
|
||||
}
|
||||
|
||||
async function fetchDirectUpload(
|
||||
@ -745,23 +959,95 @@ 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 upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
|
||||
{
|
||||
action: "initiate",
|
||||
filename: file.filename,
|
||||
contentType: file.contentType,
|
||||
size: file.blob.size,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal,
|
||||
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
|
||||
);
|
||||
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>(
|
||||
{
|
||||
action: "initiate",
|
||||
filename: file.filename,
|
||||
contentType: file.contentType,
|
||||
size: file.blob.size,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: activeClientUploadId,
|
||||
},
|
||||
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", {
|
||||
@ -799,7 +1085,7 @@ async function fetchDirectUpload(
|
||||
contentType: file.contentType,
|
||||
size: file.blob.size,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
clientUploadId: activeClientUploadId,
|
||||
},
|
||||
signal
|
||||
);
|
||||
@ -811,15 +1097,21 @@ async function fetchDirectUpload(
|
||||
}
|
||||
|
||||
multipartUpload = upload;
|
||||
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
|
||||
percent: 2,
|
||||
});
|
||||
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,
|
||||
@ -834,37 +1126,33 @@ async function fetchDirectUpload(
|
||||
size: file.blob.size,
|
||||
parts,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
clientUploadId: activeClientUploadId,
|
||||
},
|
||||
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 (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
|
||||
if (
|
||||
multipartUpload ||
|
||||
file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE ||
|
||||
file.blob.size > SERVER_UPLOAD_MAX_BYTES
|
||||
) {
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
"文件超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
|
||||
multipartUpload
|
||||
? "上传已暂停,重新选择同一文件将从已完成的分片继续上传。"
|
||||
: "文件直传失败,请重新选择同一文件后重试。",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
clientUploadId: file.clientUploadId,
|
||||
resumable: Boolean(multipartUpload),
|
||||
clientUploadId: activeClientUploadId,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
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 = 500 * 1024 * 1024;
|
||||
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
export const OPENCLAW_CHAT_FILE_MAX_FILES = 10;
|
||||
export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5;
|
||||
export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000;
|
||||
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = 300 * 1024 * 1024;
|
||||
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
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",
|
||||
@ -27,6 +32,51 @@ 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([
|
||||
"application/gzip",
|
||||
"application/octet-stream",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"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/tab-separated-values",
|
||||
]);
|
||||
|
||||
export type OpenClawChatMediaAttachment = {
|
||||
type: "image" | "video" | "file";
|
||||
@ -105,6 +155,70 @@ 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 || ""))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOpenClawChatMediaAttachments(
|
||||
value: unknown
|
||||
): OpenClawChatMediaAttachment[] {
|
||||
@ -135,40 +249,37 @@ export function normalizeOpenClawChatMediaAttachments(
|
||||
const isVideo =
|
||||
requestedType === "video" ||
|
||||
isOpenClawChatVideoAttachment(url, rawContentType);
|
||||
const isArchive =
|
||||
const isFile =
|
||||
!isVideo &&
|
||||
(requestedType === "file" ||
|
||||
requestedType === "archive" ||
|
||||
isOpenClawChatArchiveAttachment(url, rawContentType));
|
||||
isOpenClawChatFileAttachment(url, rawContentType));
|
||||
|
||||
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
|
||||
continue;
|
||||
}
|
||||
if (isArchive && archiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
|
||||
if (isFile && archiveCount >= OPENCLAW_CHAT_FILE_MAX_FILES) {
|
||||
continue;
|
||||
}
|
||||
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = isVideo ? "video" : isArchive ? "file" : "image";
|
||||
const fallbackContentType = isVideo
|
||||
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
|
||||
: isArchive
|
||||
? "application/zip"
|
||||
: "image/jpeg";
|
||||
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);
|
||||
|
||||
const type = isVideo ? "video" : isFile ? "file" : "image";
|
||||
const fallbackContentType = isVideo
|
||||
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
|
||||
: isFile
|
||||
? getOpenClawChatFileContentType({ name: name || url, type: rawContentType }) ||
|
||||
"application/octet-stream"
|
||||
: "image/jpeg";
|
||||
const contentType = isFile
|
||||
? rawContentType || fallbackContentType
|
||||
: rawContentType.startsWith(`${type}/`)
|
||||
? rawContentType
|
||||
: fallbackContentType;
|
||||
result.push({
|
||||
type,
|
||||
url,
|
||||
@ -179,7 +290,7 @@ export function normalizeOpenClawChatMediaAttachments(
|
||||
if (isVideo) {
|
||||
videoCount += 1;
|
||||
}
|
||||
if (isArchive) {
|
||||
if (isFile) {
|
||||
archiveCount += 1;
|
||||
}
|
||||
}
|
||||
@ -236,6 +347,20 @@ 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[]
|
||||
) {
|
||||
@ -255,3 +380,21 @@ 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");
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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 = 10 * 1024 * 1024;
|
||||
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_ROWS_PER_SHEET = 200;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30;
|
||||
export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5;
|
||||
@ -330,8 +332,10 @@ export async function spreadsheetFileToOpenClawMarkdown(file: File) {
|
||||
if (file.size <= 0) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 是空文件`);
|
||||
}
|
||||
if (file.size > OPENCLAW_SPREADSHEET_MAX_BYTES) {
|
||||
throw new Error(`${getSafeFileName(file.name)} 超过 10MB,请先拆分或压缩`);
|
||||
if (file.size > OPENCLAW_SPREADSHEET_INLINE_PARSE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`${getSafeFileName(file.name)} 超过 10MB,请使用 Claw 文件附件直传(最高 2GB)`
|
||||
);
|
||||
}
|
||||
|
||||
const extension = getSpreadsheetExtension(file.name);
|
||||
|
||||
@ -13,6 +13,7 @@ export default defineConfig({
|
||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/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/,
|
||||
|
||||
@ -29,7 +29,7 @@ const skippedExtensions = new Set([
|
||||
const skippedFiles = new Set(["pnpm-lock.yaml"]);
|
||||
|
||||
const secretPatterns = [
|
||||
{ pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{24,}/, reason: "OpenAI-style API key" },
|
||||
{ pattern: /\bsk-(?: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" },
|
||||
|
||||
@ -26,7 +26,11 @@ 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, "testMatch: /routes\\/canvas-chat-helpers\\.test\\.ts/", "single pure helper test target");
|
||||
expectIncludes(
|
||||
helpersConfig,
|
||||
"/routes\\/canvas-chat-helpers\\.test\\.ts/",
|
||||
"canvas helper test target"
|
||||
);
|
||||
expectIncludes(helpersConfig, 'reporter: process.env.CI ? "github" : "list"', "fast non-html reporter");
|
||||
expectIncludes(helpersConfig, "timeout: 60 * 1000", "bounded helper test timeout");
|
||||
|
||||
|
||||
@ -272,8 +272,26 @@ export const canvasService = {
|
||||
return response;
|
||||
};
|
||||
|
||||
const response = await doSaveWithRateLimitRetry(buildRequestBody(options));
|
||||
const data = await response.json().catch(() => ({}));
|
||||
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(() => ({}));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
|
||||
@ -5201,6 +5201,22 @@ test.describe("canvas chat pure helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("dedupes images in nonstandard pipe tables and HTML tables", () => {
|
||||
const markdown = [
|
||||
"| 商品 | 主图 |",
|
||||
"| A |  |",
|
||||
"",
|
||||
'<table><tr><td>商品 B</td><td><img src="https://cdn.example.com/html-table.webp?token=secret" /></td></tr></table>',
|
||||
"",
|
||||
"",
|
||||
].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 = [
|
||||
"生成完成",
|
||||
|
||||
@ -11,8 +11,12 @@ test.describe("canvas overlay density", () => {
|
||||
const source = readSource("components/canvas/CanvasChatPanel.tsx");
|
||||
|
||||
expect(source).toContain(
|
||||
"max-h-[360px] w-[292px] max-w-[calc(100vw-24px)]"
|
||||
"max-h-[360px] 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]"
|
||||
);
|
||||
|
||||
@ -37,7 +37,9 @@ test.describe("Claw request lifecycle", () => {
|
||||
|
||||
expect(source).not.toContain('request.signal.addEventListener("abort"');
|
||||
expect(source).not.toContain("cancelClawdRequest(cancelKey)");
|
||||
expect(source).toContain("registerActiveClawdRequest(cancelKey");
|
||||
expect(source).toMatch(
|
||||
/registerActiveClawdRequest\(\s*(?:params\.)?cancelKey/
|
||||
);
|
||||
});
|
||||
|
||||
test("does not classify every AbortError as an explicit stop action", () => {
|
||||
|
||||
@ -25,11 +25,11 @@ test.describe("HiCAD embedded startup", () => {
|
||||
expect(vendorScript).toContain(
|
||||
'const brandedFaviconUrl = "/api/favicon?surface=hicad"'
|
||||
);
|
||||
expect(vendorScript).toContain(
|
||||
'` <link rel="icon" href="${brandedFaviconUrl}" />`'
|
||||
expect(vendorScript).toMatch(
|
||||
/` {2}<link rel="icon" href="\$\{brandedFaviconUrl\}" \/>`/
|
||||
);
|
||||
expect(vendorScript).toContain(
|
||||
'` <link rel="apple-touch-icon" href="${brandedFaviconUrl}" />`'
|
||||
expect(vendorScript).toMatch(
|
||||
/` {2}<link rel="apple-touch-icon" href="\$\{brandedFaviconUrl\}" \/>`/
|
||||
);
|
||||
expect(vendorScript).toMatch(
|
||||
/manifest\.icons = \[\s*\{\s*src: brandedFaviconUrl,\s*sizes: "any",/
|
||||
|
||||
54
tests/routes/model3d-style-pro-regressions.test.ts
Normal file
54
tests/routes/model3d-style-pro-regressions.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
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)"
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -7,8 +7,13 @@ 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", () => {
|
||||
@ -74,4 +79,71 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -23,9 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
@ -45,10 +39,7 @@
|
||||
".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"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user