+ 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
+ VIP98K 服务商读取,仅在这里维护扩展比例。
+
+ ) : (
+
handleCanvasSkillTagChange(
@@ -14757,7 +15034,32 @@ export function AdminSettingsClient() {
placeholder={`标签名称 ${index + 1}`}
value={tag.name}
/>
+
)}
+ {provider.type === "atlascloud" && (
+
+ )}
+ {provider.type === "vip98k" && (
+
+ )}
{provider.type === "bltcy-video" && (
-
+
开启后,该模型不会出现在前端,也不会参与第三方生图
@@ -17394,6 +17729,11 @@ export function AdminSettingsClient() {
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
});
+ const is3DModel = is3DModelDescriptor({
+ providerType: provider?.type,
+ modelId: model.modelId,
+ capabilities: (model as any).capabilities,
+ });
const isImageModel = isImageGenerationModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
@@ -17456,6 +17796,11 @@ export function AdminSettingsClient() {
Agent
)}
+ {is3DModel && (
+
+ 3D模型
+
+ )}
{isImageModel && (
图片模型
@@ -17466,7 +17811,7 @@ export function AdminSettingsClient() {
视频模型
)}
- {!isImageModel && !isVideoModel && (
+ {!is3DModel && !isImageModel && !isVideoModel && (
对话模型
diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx
index 514994dd6..42a73be8c 100644
--- a/app/(chat)/ai-cad/page.tsx
+++ b/app/(chat)/ai-cad/page.tsx
@@ -4,7 +4,8 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
-const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
+const ZOO_MODELING_APP_SRC =
+ "/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts
index 538f45348..8d1dc9e92 100644
--- a/app/(chat)/api/chat/_lib/image-failover.ts
+++ b/app/(chat)/api/chat/_lib/image-failover.ts
@@ -17,6 +17,8 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"openai-image",
"bananaimage",
"visionary",
+ "atlascloud",
+ "vip98k",
"stepfun-image",
"runway",
"krea",
@@ -247,6 +249,8 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
case "openai-image":
case "bananaimage":
case "visionary":
+ case "atlascloud":
+ case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
diff --git a/app/(chat)/api/chat/_lib/image-media.ts b/app/(chat)/api/chat/_lib/image-media.ts
index c28cdcb19..5209c2673 100644
--- a/app/(chat)/api/chat/_lib/image-media.ts
+++ b/app/(chat)/api/chat/_lib/image-media.ts
@@ -526,6 +526,8 @@ export async function convertMessagesWithImages(
normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
+ normalizedProviderType === "atlascloud" ||
+ normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||
diff --git a/app/(chat)/api/chat/_lib/stream.ts b/app/(chat)/api/chat/_lib/stream.ts
index 1bff20f8c..65f44f5e2 100644
--- a/app/(chat)/api/chat/_lib/stream.ts
+++ b/app/(chat)/api/chat/_lib/stream.ts
@@ -190,6 +190,8 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
+ normalized === "atlascloud" ||
+ normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts
index 3904da16e..71286129d 100644
--- a/app/(chat)/api/chat/route.ts
+++ b/app/(chat)/api/chat/route.ts
@@ -5745,44 +5745,53 @@ export async function POST(request: Request) {
);
const assistantMessageId = generateUUID();
- await saveMessages({
- messages: [
- {
- id: assistantMessageId,
- chatId: id,
- role: "assistant",
- parts: [{ type: "text", text: assistantText }],
- attachments: [],
- createdAt: new Date(),
- videoStorageStatus: null,
- temporaryVideoUrl: null,
- permanentVideoUrl: null,
- archivedAt: null,
- videoMetadata: null,
- },
- ],
- });
+ try {
+ await saveMessages({
+ messages: [
+ {
+ id: assistantMessageId,
+ chatId: id,
+ role: "assistant",
+ parts: [{ type: "text", text: assistantText }],
+ attachments: [],
+ createdAt: new Date(),
+ videoStorageStatus: null,
+ temporaryVideoUrl: null,
+ permanentVideoUrl: null,
+ archivedAt: null,
+ videoMetadata: null,
+ },
+ ],
+ });
- await saveGeneratedImages(
- assistantText,
- session.user.id,
- id,
- assistantMessageId,
- params.promptForPersistence || baseReplacementPrompt,
- resolvedModelRef,
- {
- sourceAssetIds,
- sourceImageUrls: params.sourceImageUrls,
- relationType: "edited_from",
- sourceType: "detail_replacement",
- projectId: contextPayload?.projectId || null,
- userPrompt: requestTextContent,
- optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
- requestedImageCount: 1,
- aspectRatio: resolvedAspectRatio,
- imageSize: persistedImageSize,
- },
- );
+ await saveGeneratedImages(
+ assistantText,
+ session.user.id,
+ id,
+ assistantMessageId,
+ params.promptForPersistence || baseReplacementPrompt,
+ resolvedModelRef,
+ {
+ sourceAssetIds,
+ sourceImageUrls: params.sourceImageUrls,
+ relationType: "edited_from",
+ sourceType: "detail_replacement",
+ projectId: contextPayload?.projectId || null,
+ userPrompt: requestTextContent,
+ optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
+ requestedImageCount: 1,
+ aspectRatio: resolvedAspectRatio,
+ imageSize: persistedImageSize,
+ },
+ );
+ } catch (persistError) {
+ chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
+ chatId: id,
+ referenceId: params.reference.id,
+ referenceLabel: params.reference.label,
+ error: persistError,
+ });
+ }
writeAssistantTextChunk(
writer,
@@ -5810,6 +5819,7 @@ export async function POST(request: Request) {
Array.from(replacementReferences.entries()),
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
async ([referenceIndex, reference]) => {
+ const itemStartedAt = Date.now();
if (reference.passthrough === true) {
const referenceSourceImageUrls = Array.from(
new Set([
@@ -5830,9 +5840,29 @@ export async function POST(request: Request) {
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
});
successCount += 1;
+ chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
+ referenceIndex: referenceIndex + 1,
+ totalReferences: replacementReferences.length,
+ referenceLabel: reference.label,
+ elapsedMs: Date.now() - itemStartedAt,
+ });
return;
}
+ const fallbackSourceImageUrls = Array.from(
+ new Set([
+ ...productInputImageUrls,
+ ...(modelInputImageUrl ? [modelInputImageUrl] : []),
+ ...(reference.rerunReferenceImages || [])
+ .map((image) => String(image.url || "").trim())
+ .filter((url) => /^https?:\/\//i.test(url))
+ .slice(0, 6),
+ reference.imageUrl,
+ ]),
+ );
+
+ try {
+ const planningStartedAt = Date.now();
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy,
@@ -5847,10 +5877,12 @@ export async function POST(request: Request) {
planningRuntime: replacementScenePlanningRuntime,
models: replacementVisionModels,
});
+ const planningElapsedMs = Date.now() - planningStartedAt;
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
+ planningElapsedMs,
shouldReplace: replacementExecutionPlan.shouldReplace,
reason: replacementExecutionPlan.reason,
detectionSource: replacementExecutionPlan.detectionSource,
@@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
});
+ let generationStartedAt = 0;
+ let generationElapsedMs: number | null = null;
+ let normalizeStartedAt = 0;
+ let normalizeElapsedMs: number | null = null;
try {
+ generationStartedAt = Date.now();
const result = await generateCanvasImage({
prompt: replacementPrompt,
negativePrompt: effectiveImageParams?.negativePrompt,
@@ -6000,6 +6037,7 @@ export async function POST(request: Request) {
}
: undefined,
});
+ generationElapsedMs = Date.now() - generationStartedAt;
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0];
if (!firstImage) {
@@ -6009,6 +6047,7 @@ export async function POST(request: Request) {
let finalImage = firstImage;
let finalImageSizeLabel: string = resolvedImageSize;
try {
+ normalizeStartedAt = Date.now();
const normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl,
@@ -6022,7 +6061,11 @@ export async function POST(request: Request) {
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
};
finalImageSizeLabel = normalizedImage.imageSize;
+ normalizeElapsedMs = Date.now() - normalizeStartedAt;
} catch (normalizeError) {
+ if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
+ normalizeElapsedMs = Date.now() - normalizeStartedAt;
+ }
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
referenceLabel: reference.label,
referenceImageUrl: reference.imageUrl,
@@ -6040,6 +6083,22 @@ export async function POST(request: Request) {
}
}
+ chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
+ referenceIndex: referenceIndex + 1,
+ totalReferences: replacementReferences.length,
+ referenceLabel: reference.label,
+ modelDisplayName: resolvedModelDisplayName,
+ resultImageSize: finalImageSizeLabel,
+ sourceImageCount: sourceImageUrls.length,
+ generationAspectRatio,
+ pageKind: replacementExecutionPlan.scenePlan.pageKind,
+ generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
+ promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
+ elapsedMs: Date.now() - itemStartedAt,
+ planningElapsedMs,
+ generationElapsedMs,
+ normalizeElapsedMs,
+ });
await emitReplacementResultMessage({
reference,
image: finalImage,
@@ -6051,9 +6110,16 @@ export async function POST(request: Request) {
});
successCount += 1;
} catch (replacementItemError) {
+ if (generationStartedAt > 0 && generationElapsedMs === null) {
+ generationElapsedMs = Date.now() - generationStartedAt;
+ }
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
referenceIndex,
referenceLabel: reference.label,
+ elapsedMs: Date.now() - itemStartedAt,
+ planningElapsedMs,
+ generationElapsedMs,
+ normalizeElapsedMs,
error: replacementItemError,
});
await emitReplacementResultMessage({
@@ -6073,6 +6139,30 @@ export async function POST(request: Request) {
});
successCount += 1;
}
+ } catch (replacementPlanError) {
+ chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
+ referenceIndex,
+ referenceLabel: reference.label,
+ elapsedMs: Date.now() - itemStartedAt,
+ error: replacementPlanError,
+ });
+ await emitReplacementResultMessage({
+ reference,
+ bodyText: `生成失败,当前先沿用参考图:${
+ replacementPlanError instanceof Error
+ ? replacementPlanError.message
+ : "请稍后重试"
+ }`,
+ image: {
+ originalUrl: reference.imageUrl,
+ thumbnailUrl: reference.imageUrl,
+ },
+ modelDisplayName: null,
+ sourceImageUrls: fallbackSourceImageUrls,
+ streamLabel: "agent-mode:image:detail-replacement:item-fallback",
+ });
+ successCount += 1;
+ }
});
} finally {
stopKeepalive();
@@ -9131,6 +9221,8 @@ export async function POST(request: Request) {
"openai-image": 6,
bananaimage: 7,
visionary: 7,
+ atlascloud: 7,
+ vip98k: 7,
"stepfun-image": 7,
"grsai-gpt-image": 8,
runway: 9,
@@ -9524,6 +9616,8 @@ export async function POST(request: Request) {
"openai-image",
"bananaimage",
"visionary",
+ "atlascloud",
+ "vip98k",
"stepfun-image",
"midjourney",
"runway",
@@ -9750,6 +9844,10 @@ export async function POST(request: Request) {
? "bananaimage"
: (languageModel as any).provider === "visionary"
? "visionary"
+ : (languageModel as any).provider === "atlascloud"
+ ? "atlascloud"
+ : (languageModel as any).provider === "vip98k"
+ ? "vip98k"
: (languageModel as any).provider === "stepfun-image"
? "stepfun-image"
: (languageModel as any).provider === "midjourney"
@@ -9855,6 +9953,8 @@ export async function POST(request: Request) {
"openai-image": 6,
bananaimage: 7,
visionary: 7,
+ atlascloud: 7,
+ vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@@ -9902,6 +10002,10 @@ export async function POST(request: Request) {
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
+ : candidate.provider.type === "atlascloud"
+ ? "atlascloud"
+ : candidate.provider.type === "vip98k"
+ ? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@@ -10290,6 +10394,8 @@ export async function POST(request: Request) {
"openai-image": 5,
bananaimage: 6,
visionary: 6,
+ atlascloud: 6,
+ vip98k: 6,
"stepfun-image": 6,
runway: 7,
krea: 8,
@@ -10338,6 +10444,10 @@ export async function POST(request: Request) {
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
+ : candidate.provider.type === "atlascloud"
+ ? "atlascloud"
+ : candidate.provider.type === "vip98k"
+ ? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@@ -10551,7 +10661,7 @@ export async function POST(request: Request) {
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
if (
- ["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
+ ["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
typeof (languageModel as any).doGenerate === "function"
) {
const currentVideoProvider = (languageModel as any).provider as
@@ -10560,7 +10670,8 @@ export async function POST(request: Request) {
| "ruxa-video"
| "runway"
| "krea"
- | "bltcy-video";
+ | "bltcy-video"
+ | "vip98k";
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
const videoGenerationStream = createUIMessageStream({
@@ -11215,6 +11326,8 @@ export async function POST(request: Request) {
"openai-image": 6,
bananaimage: 7,
visionary: 7,
+ atlascloud: 7,
+ vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@@ -11265,6 +11378,10 @@ export async function POST(request: Request) {
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
+ : candidate.provider.type === "atlascloud"
+ ? "atlascloud"
+ : candidate.provider.type === "vip98k"
+ ? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts
index 7236ed280..6b168b7a6 100644
--- a/app/(chat)/api/files/direct-upload/route.ts
+++ b/app/(chat)/api/files/direct-upload/route.ts
@@ -21,6 +21,7 @@ import {
maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget,
} from "@/lib/storage/service";
+import { 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;
@@ -39,6 +40,13 @@ const allowedDirectUploadTypes = new Set([
"image/png",
"image/webp",
]);
+const allowedOpenClawVideoUploadTypes = new Set([
+ "video/mp4",
+ "video/quicktime",
+ "video/webm",
+ "video/x-m4v",
+ "video/x-matroska",
+]);
const InitiateSchema = z.object({
action: z.literal("initiate"),
@@ -109,8 +117,12 @@ function assertUserOwnedKey(key: string, userId: string) {
return normalized;
}
-function isAllowedDirectUpload(contentType: string) {
- return allowedDirectUploadTypes.has(contentType);
+function isAllowedDirectUpload(contentType: string, purpose?: string) {
+ return (
+ allowedDirectUploadTypes.has(contentType) ||
+ (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
+ allowedOpenClawVideoUploadTypes.has(contentType))
+ );
}
function computePartSize(size: number) {
@@ -204,7 +216,7 @@ export async function POST(request: Request) {
payload.filename
);
- if (!isAllowedDirectUpload(contentType)) {
+ if (!isAllowedDirectUpload(contentType, payload.purpose)) {
logger.warn("unsupported_direct_upload_type", {
contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@@ -213,7 +225,10 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
- { error: "Direct upload only supports JPG, PNG and WebP images" },
+ {
+ error:
+ "直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
+ },
{ status: 400 }
);
}
@@ -328,9 +343,12 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
- if (!isAllowedDirectUpload(contentType)) {
+ if (!isAllowedDirectUpload(contentType, payload.purpose)) {
return NextResponse.json(
- { error: "Direct upload only supports JPG, PNG and WebP images" },
+ {
+ error:
+ "直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
+ },
{ status: 400 }
);
}
@@ -389,15 +407,21 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({
userId: session.user.id,
- mediaType: "image",
- sourceType: "upload",
+ mediaType: resolvedContentType.startsWith("video/") ? "video" : "image",
+ sourceType:
+ payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
+ ? "openclaw_chat_video_upload"
+ : "upload",
status: "ready",
originalUrl: url,
previewUrl: url,
thumbnailUrl: url,
mimeType: resolvedContentType,
displayName: payload.filename,
- sourceTable: "direct_upload",
+ sourceTable:
+ payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
+ ? "openclaw_chat_video_upload"
+ : "direct_upload",
metadata: {
pathname: key,
size: actualSize ?? payload.size,
diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts
index 6c11efe0b..26adc7335 100644
--- a/app/(chat)/api/files/upload/route.ts
+++ b/app/(chat)/api/files/upload/route.ts
@@ -7,6 +7,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger";
+import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@@ -14,12 +15,32 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg",
"image/png",
"image/webp",
+ "image/avif",
+ "image/tiff",
+ "image/x-tiff",
"image/svg+xml",
"image/heic",
"image/heif",
]);
+const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
+ "video/mp4",
+ "video/quicktime",
+ "video/webm",
+]);
+const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
+ "video/mp4",
+ "video/quicktime",
+ "video/webm",
+ "video/x-m4v",
+ "video/x-matroska",
+]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
+const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
+ "image/avif",
+ "image/tiff",
+ "image/x-tiff",
+]);
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
@@ -35,8 +56,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
}
+ if (
+ SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
+ extension === "avif" ||
+ extension === "tif" ||
+ extension === "tiff"
+ ) {
+ return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
+ }
- return "File type should be JPEG, PNG, WebP or SVG";
+ return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
}
function isHeicUpload(contentType: string, filename: string) {
@@ -48,10 +77,20 @@ function isHeicUpload(contentType: string, filename: string) {
);
}
+function isSharpCompatibilityUpload(contentType: string, filename: string) {
+ const extension = filename.toLowerCase().split(".").pop() || "";
+ return (
+ SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
+ extension === "avif" ||
+ extension === "tif" ||
+ extension === "tiff"
+ );
+}
+
function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`;
- if (/\.(heic|heif)$/i.test(trimmed)) {
- return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
+ if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
+ return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
}
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@@ -71,6 +110,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise {
}
return Buffer.from(output);
}
+
+async function convertSharpCompatibilityImageToJpeg(
+ buffer: Buffer
+): Promise {
+ const sharp = (await import("sharp")).default;
+ return sharp(buffer, { animated: false, failOn: "none" })
+ .rotate()
+ .flatten({ background: "#ffffff" })
+ .jpeg({
+ quality: 92,
+ mozjpeg: true,
+ progressive: true,
+ })
+ .toBuffer();
+}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@@ -197,6 +251,12 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
+ const isAtlasCloudReferenceVideo =
+ uploadPurpose === "canvas-video-process" &&
+ ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
+ const isOpenClawChatVideoUpload =
+ uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
+ ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
@@ -227,7 +287,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
- if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
+ if (
+ !ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
+ !isAtlasCloudReferenceVideo &&
+ !isOpenClawChatVideoUpload
+ ) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@@ -277,6 +341,38 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
+ } else if (isSharpCompatibilityUpload(contentType, filename)) {
+ try {
+ const convertedBuffer =
+ await convertSharpCompatibilityImageToJpeg(uploadBuffer);
+ logger.info("[Upload API] Compatibility image converted to JPEG", {
+ clientUploadId,
+ originalContentType: contentType,
+ originalSize: uploadBuffer.length,
+ convertedSize: convertedBuffer.length,
+ });
+ uploadBuffer = convertedBuffer;
+ filename = toJpegFilename(filename);
+ convertedFrom = contentType;
+ contentType = "image/jpeg";
+ } catch (conversionError) {
+ logger.warn("[Upload API] Compatibility image conversion failed", {
+ clientUploadId,
+ contentType,
+ size: uploadBuffer.length,
+ error:
+ conversionError instanceof Error
+ ? conversionError.message
+ : String(conversionError),
+ });
+ return NextResponse.json(
+ {
+ error: createUnsupportedImageTypeMessage(contentType, filename),
+ clientUploadId,
+ },
+ { status: 400 }
+ );
+ }
}
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
@@ -361,18 +457,32 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
- mediaType: "image",
- sourceType: "upload",
+ mediaType:
+ isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
+ ? "video"
+ : "image",
+ sourceType: isOpenClawChatVideoUpload
+ ? "openclaw_chat_video_upload"
+ : "upload",
status: "ready",
originalUrl: data.url,
- previewUrl: data.url,
- thumbnailUrl: data.url,
+ previewUrl:
+ isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
+ ? null
+ : data.url,
+ thumbnailUrl:
+ isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
+ ? null
+ : data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
- sourceTable: "upload",
+ sourceTable: isOpenClawChatVideoUpload
+ ? "openclaw_chat_video_upload"
+ : "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
+ purpose: uploadPurpose || null,
clientUploadId,
convertedFrom,
},
diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts
index 48ac62f47..4ed8895e7 100644
--- a/app/api/admin/ai-models/route.ts
+++ b/app/api/admin/ai-models/route.ts
@@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
+ supportsQuality: z.boolean().optional(),
+ supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
+ supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
+ maxReferenceImages: z.number().min(0).max(64).optional(),
+ maxVideoReferences: z.number().min(0).max(16).optional(),
ruxaTaskInputMode: z
.enum(["size-image", "resolution-based"])
.optional(),
@@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
+ vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts
index 1982abfd4..b440cfee9 100644
--- a/app/api/admin/ai-providers/route.ts
+++ b/app/api/admin/ai-providers/route.ts
@@ -9,6 +9,7 @@ import {
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
+import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const providerTypes = [
@@ -34,6 +35,8 @@ const providerTypes = [
"openai-image",
"bananaimage",
"visionary",
+ "atlascloud",
+ "vip98k",
"stepfun-image",
"midjourney",
"fal-video",
@@ -77,11 +80,13 @@ const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const VISIONARY_BASE_URL = "https://api.visionary.beer";
+const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
+const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
-const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
+const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai";
@@ -245,6 +250,10 @@ export async function POST(request: Request) {
? data.baseUrl || BANANAIMAGE_BASE_URL
: data.type === "visionary"
? data.baseUrl || VISIONARY_BASE_URL
+ : data.type === "atlascloud"
+ ? data.baseUrl || ATLASCLOUD_BASE_URL
+ : data.type === "vip98k"
+ ? data.baseUrl || VIP98K_PROVIDER_BASE_URL
: data.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney"
@@ -307,6 +316,12 @@ export async function PUT(request: Request) {
...data,
apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type),
+ baseUrl:
+ data.baseUrl !== undefined
+ ? data.baseUrl
+ : data.type === "vip98k"
+ ? VIP98K_PROVIDER_BASE_URL
+ : undefined,
updatedBy: session.user.id,
});
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
new file mode 100644
index 000000000..965957638
--- /dev/null
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -0,0 +1,182 @@
+import { and, eq } from "drizzle-orm";
+import { NextResponse } from "next/server";
+import {
+ fetchAtlasCloudModelPresets,
+ resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
+} from "@/lib/ai/atlascloud-models";
+import { db } from "@/lib/db";
+import { getAiProviderById } from "@/lib/db/queries";
+import { type AiModel, aiModel } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/middleware/admin";
+
+export const runtime = "nodejs";
+export const maxDuration = 300;
+
+export async function GET() {
+ try {
+ await requireAdmin();
+ const result = await fetchAtlasCloudModelPresets();
+ return NextResponse.json({
+ presets: result.presets,
+ errors: result.errors.length > 0 ? result.errors : undefined,
+ counts: {
+ total: result.catalogCount,
+ available: result.presets.length,
+ failed: result.errors.length,
+ },
+ });
+ } catch (error) {
+ console.error("[Atlas Cloud Models API] GET error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to fetch Atlas Cloud models",
+ },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ await requireAdmin();
+ const body = await request.json();
+ const providerId = String(body?.providerId || "").trim();
+ const requestedModelIds = Array.isArray(body?.modelIds)
+ ? new Set(
+ body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
+ )
+ : null;
+ if (!providerId) {
+ return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
+ }
+
+ const provider = await getAiProviderById({ id: providerId });
+ if (!provider || provider.type !== "atlascloud") {
+ return NextResponse.json(
+ { error: "Provider must be an Atlas Cloud provider" },
+ { status: 400 }
+ );
+ }
+
+ const catalog = await fetchAtlasCloudModelPresets();
+ const presets = requestedModelIds
+ ? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
+ : catalog.presets;
+ const addedModels: AiModel[] = [];
+ const updatedModels: AiModel[] = [];
+ const errors = [...catalog.errors];
+ const providerModels = await db.query.aiModel.findMany({
+ where: eq(aiModel.providerId, providerId),
+ });
+ const existingByModelId = new Map(
+ providerModels.map((model) => [model.modelId, model])
+ );
+ const existingMediaKinds = new Set(
+ providerModels.map((model) => {
+ const capabilities = model.capabilities as Record | null;
+ return String(capabilities?.mediaType || "image");
+ })
+ );
+ let preservedDeletedCount = 0;
+
+ for (const preset of presets) {
+ try {
+ const existing = existingByModelId.get(preset.modelId);
+ const presetCapabilities = preset.capabilities as Record;
+ const mediaKind = String(presetCapabilities.mediaType || "image");
+ if (
+ !existing &&
+ !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
+ ) {
+ preservedDeletedCount += 1;
+ continue;
+ }
+ const values = {
+ displayName: resolveAtlasCloudDisplayName(
+ existing?.displayName,
+ preset.displayName,
+ preset.modelId
+ ),
+ avatarUrl: preset.avatarUrl || null,
+ description: preset.description,
+ supportsVision: preset.supportsVision,
+ supportsImageGen: preset.supportsImageGen,
+ sortOrder: existing?.sortOrder ?? preset.sortOrder,
+ capabilities: preset.capabilities,
+ defaultParams: preset.defaultParams,
+ imageGenConfig: preset.imageGenConfig,
+ updatedAt: new Date(),
+ };
+
+ if (existing) {
+ const [updated] = await db
+ .update(aiModel)
+ .set(values)
+ .where(
+ and(
+ eq(aiModel.providerId, providerId),
+ eq(aiModel.modelId, preset.modelId)
+ )
+ )
+ .returning();
+ if (updated) updatedModels.push(updated);
+ } else {
+ const [created] = await db
+ .insert(aiModel)
+ .values({
+ providerId,
+ modelId: preset.modelId,
+ ...values,
+ isEnabled: true,
+ isHidden: false,
+ isDefault: false,
+ isSubModel: false,
+ parentModelId: null,
+ })
+ .returning();
+ if (created) addedModels.push(created);
+ }
+ } catch (error) {
+ console.error(
+ `[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
+ error
+ );
+ errors.push({
+ modelId: preset.modelId,
+ error: error instanceof Error ? error.message : "Sync failed",
+ });
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ addedCount: addedModels.length,
+ updatedCount: updatedModels.length,
+ addedModels,
+ updatedModels,
+ errors: errors.length > 0 ? errors : undefined,
+ counts: {
+ requested: presets.length,
+ total: catalog.catalogCount,
+ available: catalog.presets.length,
+ skippedUnsupported: catalog.skipped.length,
+ preservedDeleted: preservedDeletedCount,
+ },
+ });
+ } catch (error) {
+ console.error("[Atlas Cloud Models API] POST error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to sync Atlas Cloud models",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/vip98k-models/route.ts b/app/api/admin/vip98k-models/route.ts
new file mode 100644
index 000000000..d1c406818
--- /dev/null
+++ b/app/api/admin/vip98k-models/route.ts
@@ -0,0 +1,213 @@
+import { and, eq } from "drizzle-orm";
+import { NextResponse } from "next/server";
+import {
+ fetchVip98kModelPresets,
+ type Vip98kModelPreset,
+} from "@/lib/ai/vip98k-models";
+import { db } from "@/lib/db";
+import { getAiProviderById } from "@/lib/db/queries";
+import { aiModel, type AiModel } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/middleware/admin";
+
+export const runtime = "nodejs";
+export const maxDuration = 300;
+
+function summarizeByType(presets: Vip98kModelPreset[]) {
+ return presets.reduce>((acc, preset) => {
+ const type = String(preset.type || "model");
+ acc[type] = (acc[type] || 0) + 1;
+ return acc;
+ }, {});
+}
+
+export async function GET(request: Request) {
+ try {
+ await requireAdmin();
+ const { searchParams } = new URL(request.url);
+ const providerId = String(searchParams.get("providerId") || "").trim();
+ if (!providerId) {
+ return NextResponse.json(
+ { error: "Provider ID is required to fetch VIP98K catalog" },
+ { status: 400 },
+ );
+ }
+
+ const provider = await getAiProviderById({ id: providerId });
+ if (!provider || provider.type !== "vip98k") {
+ return NextResponse.json(
+ { error: "Provider must be a VIP98K provider" },
+ { status: 400 },
+ );
+ }
+
+ const presets = await fetchVip98kModelPresets({
+ baseURL: provider.baseUrl,
+ apiKey: provider.apiKey,
+ });
+ return NextResponse.json({
+ presets,
+ counts: {
+ total: presets.length,
+ byType: summarizeByType(presets),
+ },
+ });
+ } catch (error) {
+ console.error("[VIP98K Models API] GET error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to fetch VIP98K models",
+ },
+ { status: 500 },
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ await requireAdmin();
+ const body = await request.json();
+ const providerId = String(body?.providerId || "").trim();
+ const requestedModelIds = Array.isArray(body?.modelIds)
+ ? new Set(
+ body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
+ )
+ : null;
+ const preserveDeletedModels = body?.preserveDeletedModels !== false;
+ if (!providerId) {
+ return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
+ }
+
+ const provider = await getAiProviderById({ id: providerId });
+ if (!provider || provider.type !== "vip98k") {
+ return NextResponse.json(
+ { error: "Provider must be a VIP98K provider" },
+ { status: 400 },
+ );
+ }
+
+ const catalogPresets = await fetchVip98kModelPresets({
+ baseURL: provider.baseUrl,
+ apiKey: provider.apiKey,
+ });
+ const existingModels = await db.query.aiModel.findMany({
+ where: eq(aiModel.providerId, providerId),
+ });
+ const existingModelIds = new Set(existingModels.map((model) => model.modelId));
+ const preserveExistingCatalog =
+ requestedModelIds === null &&
+ preserveDeletedModels &&
+ existingModels.length > 0;
+ const presets = requestedModelIds
+ ? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
+ : preserveExistingCatalog
+ ? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
+ : catalogPresets;
+
+ const addedModels: AiModel[] = [];
+ const updatedModels: AiModel[] = [];
+ const disabledModels: AiModel[] = [];
+ const errors: Array<{ modelId: string; error: string }> = [];
+
+ if (requestedModelIds === null) {
+ const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
+ for (const existing of existingModels) {
+ if (catalogModelIds.has(existing.modelId)) continue;
+ const [disabled] = await db
+ .update(aiModel)
+ .set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
+ .where(eq(aiModel.id, existing.id))
+ .returning();
+ if (disabled) disabledModels.push(disabled);
+ }
+ }
+
+ for (const preset of presets) {
+ try {
+ const existing = await db.query.aiModel.findFirst({
+ where: and(
+ eq(aiModel.providerId, providerId),
+ eq(aiModel.modelId, preset.modelId),
+ ),
+ });
+ const values = {
+ displayName: preset.displayName,
+ avatarUrl: null,
+ description: preset.description,
+ supportsVision: preset.supportsVision,
+ supportsImageGen: preset.supportsImageGen,
+ isEnabled:
+ requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
+ isHidden: preset.isHidden,
+ sortOrder: preset.sortOrder,
+ capabilities: preset.capabilities,
+ defaultParams: preset.defaultParams,
+ imageGenConfig: preset.imageGenConfig,
+ updatedAt: new Date(),
+ };
+
+ if (existing) {
+ const [updated] = await db
+ .update(aiModel)
+ .set(values)
+ .where(
+ and(
+ eq(aiModel.providerId, providerId),
+ eq(aiModel.modelId, preset.modelId),
+ ),
+ )
+ .returning();
+ if (updated) updatedModels.push(updated);
+ } else {
+ const [created] = await db
+ .insert(aiModel)
+ .values({
+ providerId,
+ modelId: preset.modelId,
+ ...values,
+ isDefault: false,
+ isSubModel: false,
+ parentModelId: null,
+ })
+ .returning();
+ if (created) addedModels.push(created);
+ }
+ } catch (error) {
+ console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
+ errors.push({
+ modelId: preset.modelId,
+ error: error instanceof Error ? error.message : "Sync failed",
+ });
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ addedCount: addedModels.length,
+ updatedCount: updatedModels.length,
+ disabledCount: disabledModels.length,
+ addedModels,
+ updatedModels,
+ disabledModelIds: disabledModels.map((model) => model.modelId),
+ errors: errors.length > 0 ? errors : undefined,
+ counts: {
+ requested: presets.length,
+ total: catalogPresets.length,
+ byType: summarizeByType(catalogPresets),
+ },
+ });
+ } catch (error) {
+ console.error("[VIP98K Models API] POST error:", error);
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to sync VIP98K models",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/canvas-skill-tags/route.ts b/app/api/canvas-skill-tags/route.ts
index 22c9ec3c0..07f5b4e35 100644
--- a/app/api/canvas-skill-tags/route.ts
+++ b/app/api/canvas-skill-tags/route.ts
@@ -7,9 +7,9 @@ import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY,
- type CanvasSkillTag,
normalizeCanvasSkillTagSettings,
} from "@/lib/canvas-skill-tags";
+import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
function getUserSkillTagOrderKey(userId: string) {
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
@@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) {
});
}
-function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
- if (tags.length < 2 || order.length === 0) return tags;
-
- const tagById = new Map(tags.map((tag) => [tag.id, tag]));
- const ordered = order
- .map((id) => tagById.get(id))
- .filter((tag): tag is CanvasSkillTag => Boolean(tag));
- const orderedIds = new Set(ordered.map((tag) => tag.id));
- const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
- return [...ordered, ...remaining];
-}
-
export async function GET() {
try {
const session = await auth().catch(() => null);
@@ -71,7 +59,7 @@ export async function GET() {
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
return NextResponse.json({
...settings,
- tags: applySkillTagOrder(settings.tags, userOrder),
+ tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
userOrder,
});
} catch (error) {
@@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({
success: true,
userOrder: tagIds,
- tags: applySkillTagOrder(settings.tags, tagIds),
+ tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
});
} catch (error) {
console.error("[CanvasSkillTags API] Save order error:", error);
@@ -152,10 +140,12 @@ export async function PUT(request: NextRequest) {
const normalized = normalizeCanvasSkillTagSettings(body);
const enabled = normalized.enabled;
const title = normalized.title;
+ const categories = normalized.categories;
const tags = normalized.tags;
const nextValue = {
enabled,
title,
+ categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
@@ -183,7 +173,7 @@ export async function PUT(request: NextRequest) {
});
}
- return NextResponse.json({ success: true, enabled, title, tags });
+ return NextResponse.json({ success: true, enabled, title, categories, tags });
} catch (error) {
console.error("[CanvasSkillTags API] Update error:", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
diff --git a/app/api/canvas/[id]/revision/route.ts b/app/api/canvas/[id]/revision/route.ts
new file mode 100644
index 000000000..cc2dfcfe0
--- /dev/null
+++ b/app/api/canvas/[id]/revision/route.ts
@@ -0,0 +1,53 @@
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { db } from "@/lib/db";
+import { canvasProject } from "@/lib/db/schema";
+
+const canvasIdSchema = z.string().uuid();
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return Response.json(
+ { success: false, error: "未登录" },
+ { status: 401 }
+ );
+ }
+
+ const { id: rawId } = await params;
+ const parsedId = canvasIdSchema.safeParse(rawId);
+ if (!parsedId.success) {
+ return Response.json(
+ { success: false, error: "画布 ID 无效" },
+ { status: 400 }
+ );
+ }
+
+ const id = parsedId.data;
+ const [project] = await db
+ .select({ updatedAt: canvasProject.updatedAt })
+ .from(canvasProject)
+ .where(
+ and(
+ eq(canvasProject.id, id),
+ eq(canvasProject.userId, session.user.id)
+ )
+ )
+ .limit(1);
+
+ if (!project) {
+ return Response.json(
+ { success: false, error: "画布不存在" },
+ { status: 404 }
+ );
+ }
+
+ return Response.json(
+ { success: true, data: { updatedAt: project.updatedAt.toISOString() } },
+ { headers: { "Cache-Control": "no-store" } }
+ );
+}
diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts
index dc4a14769..bb8f5a7dc 100644
--- a/app/api/canvas/[id]/route.ts
+++ b/app/api/canvas/[id]/route.ts
@@ -192,23 +192,53 @@ export async function PUT(
})
: undefined;
- // 创建新画布(使用指定 ID)
+ const nextWorkspaceType = workspaceType || "canvas";
+ const nextCanvasData = syncedCanvasData || {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ };
+ const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
+ const insertData: any = {
+ id, // 使用指定的 ID
+ userId: session.user.id,
+ title: nextTitle,
+ workspaceType: nextWorkspaceType,
+ canvasData: nextCanvasData,
+ };
+ const conflictUpdateData: any = {
+ title: nextTitle,
+ workspaceType: nextWorkspaceType,
+ canvasData: nextCanvasData,
+ updatedAt: new Date(),
+ };
+ if (description !== undefined) {
+ insertData.description = description;
+ conflictUpdateData.description = description;
+ }
+ if (thumbnail !== undefined) {
+ insertData.thumbnail = thumbnail;
+ conflictUpdateData.thumbnail = thumbnail;
+ }
+
+ // 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
const [newProject] = await db
.insert(canvasProject)
- .values({
- id, // 使用指定的 ID
- userId: session.user.id,
- title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
- description,
- workspaceType: workspaceType || "canvas",
- canvasData: syncedCanvasData || {
- nodes: [],
- edges: [],
- viewport: { x: 0, y: 0, zoom: 1 },
- },
+ .values(insertData)
+ .onConflictDoUpdate({
+ target: canvasProject.id,
+ set: conflictUpdateData,
+ where: eq(canvasProject.userId, session.user.id),
})
.returning();
+ if (!newProject) {
+ return NextResponse.json(
+ { success: false, error: '画布ID已被占用' },
+ { status: 409 }
+ );
+ }
+
return NextResponse.json({ success: true, data: newProject });
}
} catch (error) {
diff --git a/app/api/canvas/clawd-chat/cancel/route.ts b/app/api/canvas/clawd-chat/cancel/route.ts
index 138a372e1..970b4ed52 100644
--- a/app/api/canvas/clawd-chat/cancel/route.ts
+++ b/app/api/canvas/clawd-chat/cancel/route.ts
@@ -6,6 +6,11 @@ import {
buildClawdCancelKey,
cancelClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
+import {
+ OPENCLAW_AUTH_REQUIRED_CODE,
+ OPENCLAW_AUTH_REQUIRED_MESSAGE,
+} from "@/lib/openclaw-chat/errors";
+import { deletePersistedClawdTask } from "@/lib/openclaw-chat/task-store";
const cancelSchema = z.object({
id: z.string().uuid(),
@@ -15,7 +20,13 @@ const cancelSchema = z.object({
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ return NextResponse.json(
+ {
+ error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
+ code: OPENCLAW_AUTH_REQUIRED_CODE,
+ },
+ { status: 401 }
+ );
}
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
@@ -36,5 +47,14 @@ export async function POST(request: Request) {
})
);
- return NextResponse.json({ canceled: true, active });
+ const deleted = await deletePersistedClawdTask({
+ chatId: parsed.data.id,
+ userMessageId: parsed.data.userMessageId,
+ }).catch(() => []);
+
+ return NextResponse.json({
+ canceled: true,
+ active,
+ deleted: deleted.length > 0,
+ });
}
diff --git a/app/api/canvas/clawd-chat/folder-upload/route.ts b/app/api/canvas/clawd-chat/folder-upload/route.ts
new file mode 100644
index 000000000..d557df075
--- /dev/null
+++ b/app/api/canvas/clawd-chat/folder-upload/route.ts
@@ -0,0 +1,141 @@
+import JSZip from "jszip";
+import { NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { getAllStorageConfigs } from "@/lib/db/queries";
+import {
+ countOpenClawArchiveRootFolders,
+ sanitizeOpenClawArchiveEntryPath,
+ sanitizeOpenClawArchiveName,
+} 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 {
+ toDbS3UploadConfig,
+ uploadFileWithS3Config,
+} from "@/lib/storage/service";
+
+export const runtime = "nodejs";
+export const maxDuration = 600;
+
+const logger = createLogger("openclaw-folder-upload");
+
+function isUploadedFile(value: FormDataEntryValue): value is File {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "arrayBuffer" in value &&
+ "name" in value &&
+ "size" in value
+ );
+}
+
+function toArrayBuffer(buffer: Buffer) {
+ return buffer.buffer.slice(
+ buffer.byteOffset,
+ buffer.byteOffset + buffer.byteLength
+ ) as ArrayBuffer;
+}
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "请先登录" }, { status: 401 });
+ }
+
+ try {
+ const formData = await request.formData();
+ const files = formData
+ .getAll("files")
+ .filter((entry): entry is File => isUploadedFile(entry));
+ const relativePaths = formData
+ .getAll("relativePaths")
+ .map((entry) => (typeof entry === "string" ? entry : ""));
+
+ if (files.length === 0) {
+ return NextResponse.json(
+ { error: "所选文件夹中没有可上传文件" },
+ { status: 400 }
+ );
+ }
+ if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
+ return NextResponse.json(
+ { error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
+ { status: 413 }
+ );
+ }
+
+ const originalSize = files.reduce((sum, file) => sum + file.size, 0);
+ if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
+ return NextResponse.json(
+ { error: "文件夹总大小不能超过 300MB" },
+ { status: 413 }
+ );
+ }
+
+ const archivePaths = files.map((file, index) =>
+ sanitizeOpenClawArchiveEntryPath(
+ relativePaths[index] || file.name,
+ file.name,
+ index
+ )
+ );
+ const zip = new JSZip();
+ for (const [index, file] of files.entries()) {
+ zip.file(archivePaths[index], await file.arrayBuffer(), {
+ date: new Date(file.lastModified || Date.now()),
+ });
+ }
+
+ const archiveBaseName = sanitizeOpenClawArchiveName(
+ String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
+ );
+ const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
+ const archiveBuffer = await zip.generateAsync({
+ type: "nodebuffer",
+ compression: "DEFLATE",
+ compressionOptions: { level: 6 },
+ platform: "UNIX",
+ });
+
+ const storageConfigs = await getAllStorageConfigs();
+ const qiniuStorage = storageConfigs
+ .filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
+ .sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
+ .find((config) => toDbS3UploadConfig(config));
+ const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
+ if (!qiniuConfig) {
+ return NextResponse.json(
+ { error: "系统尚未配置可用的七牛云储存桶" },
+ { status: 503 }
+ );
+ }
+
+ const uploaded = await uploadFileWithS3Config(
+ archiveName,
+ toArrayBuffer(archiveBuffer),
+ qiniuConfig
+ );
+ if (!/^https?:\/\//i.test(uploaded.url)) {
+ throw new Error("七牛云未返回可访问的公网地址");
+ }
+
+ return NextResponse.json({
+ url: uploaded.url,
+ name: archiveName,
+ contentType: "application/zip",
+ size: archiveBuffer.byteLength,
+ originalSize,
+ fileCount: files.length,
+ folderCount: countOpenClawArchiveRootFolders(archivePaths),
+ });
+ } catch (error) {
+ logger.error("Failed to package and upload Claw folders", error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : "文件夹上传失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/canvas/clawd-chat/history/route.ts b/app/api/canvas/clawd-chat/history/route.ts
index 822587749..97bbf669d 100644
--- a/app/api/canvas/clawd-chat/history/route.ts
+++ b/app/api/canvas/clawd-chat/history/route.ts
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getClawdChatsByUserId } from "@/lib/db/queries";
+import {
+ OPENCLAW_AUTH_REQUIRED_CODE,
+ OPENCLAW_AUTH_REQUIRED_MESSAGE,
+} from "@/lib/openclaw-chat/errors";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@@ -9,7 +13,13 @@ export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ return NextResponse.json(
+ {
+ error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
+ code: OPENCLAW_AUTH_REQUIRED_CODE,
+ },
+ { status: 401 }
+ );
}
const url = new URL(request.url);
diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts
index 01479493a..5e6904e9c 100644
--- a/app/api/canvas/clawd-chat/route.ts
+++ b/app/api/canvas/clawd-chat/route.ts
@@ -1,34 +1,53 @@
-import { NextResponse } from "next/server";
+import { after, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
- getMessagesByChatId,
getSystemSetting,
getUserById,
saveChat,
- saveMessages,
} from "@/lib/db/queries";
-import {
- normalizeOpenClawChatConfig,
- OPENCLAW_CHAT_CONFIG_KEY,
-} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
- cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
+import {
+ normalizeOpenClawChatConfig,
+ OPENCLAW_CHAT_CONFIG_KEY,
+} from "@/lib/openclaw-chat/config";
+import {
+ getOpenClawTaskFailureMessage,
+ OPENCLAW_AUTH_REQUIRED_CODE,
+ OPENCLAW_AUTH_REQUIRED_MESSAGE,
+ OPENCLAW_UPGRADE_RETRY_MESSAGE,
+} from "@/lib/openclaw-chat/errors";
+import {
+ normalizeOpenClawChatMediaAttachments,
+ OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
+ type OpenClawChatMediaAttachment,
+} from "@/lib/openclaw-chat/media-attachments";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
buildOpenClawSpreadsheetMarkdown,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
+import {
+ appendPersistedClawdTaskProgress,
+ getPersistedClawdTask,
+ markClawdTaskCompleted,
+ markClawdTaskFailed,
+ markClawdTaskRunning,
+ persistPendingClawdTask,
+} from "@/lib/openclaw-chat/task-store";
import { generateUUID } from "@/lib/utils";
-const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
+const DEFAULT_PROCESSING_HINT =
+ "已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
+
+export const maxDuration = 1800;
const requestSchema = z.object({
id: z.string().uuid(),
@@ -36,12 +55,78 @@ const requestSchema = z.object({
displayMessage: z.string().trim().max(8000).optional(),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
+ sensitiveInput: z
+ .discriminatedUnion("type", [
+ z.object({
+ type: z.literal("taobao_credentials"),
+ username: z.string().min(1).max(160),
+ password: z.string().min(1).max(256),
+ }),
+ z.object({
+ type: z.literal("taobao_verification"),
+ verification_code: z.string().min(4).max(12),
+ }),
+ ])
+ .optional(),
+ attachments: z
+ .array(z.unknown())
+ .max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
+ .optional()
+ .default([]),
});
-type ParsedClawdChatRequest = z.infer & {
+type ParsedClawdChatRequest = Omit<
+ z.infer,
+ "attachments"
+> & {
+ attachments: OpenClawChatMediaAttachment[];
tableFiles: File[];
};
+type ClawdSpreadsheetAttachment = {
+ name: string;
+ size: number;
+ contentType: string;
+ sheetCount: number;
+ rowCount: number;
+ truncated: boolean;
+};
+
+type OpenClawBackgroundTask = {
+ jobId: string;
+ state: string;
+ stage: string;
+ phase: string;
+ displayText: string;
+ updatedAt: string;
+ completed: number;
+ total: number | null;
+ progressPercent: number | null;
+ pollEndpoint: string;
+ pollAfterMs: number;
+ maxWaitSeconds: number;
+};
+
+type ExecuteClawdTaskParams = {
+ assistantMessageId: string;
+ cancelKey: string;
+ chatId: string;
+ config: { baseUrl: string; token: string; timeoutMs: number };
+ conversationId: string;
+ departmentName: string;
+ metadata: Record | undefined;
+ sensitiveInput?: z.infer["sensitiveInput"];
+ mediaAttachments: OpenClawChatMediaAttachment[];
+ skillTagId: string;
+ skillTagName: string;
+ spreadsheetFiles: unknown[];
+ spreadsheetMarkdownTruncated: boolean;
+ upstreamMessage: string;
+ userId: string;
+ userMessageId: string;
+ userName: string;
+};
+
function buildChatTitle(message: string) {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) return "Claw 对话";
@@ -57,17 +142,15 @@ function getOpenClawUserName(user: {
name?: string | null;
email?: string | null;
}) {
- const name = String(user.name || "").trim();
- if (name) return name;
-
- const email = String(user.email || "").trim();
- if (email) return email;
-
- return user.id;
+ return String(user.name || user.email || user.id).trim();
}
-function getOpenClawDepartmentName(department?: string | null) {
- return String(department || "").trim();
+function getMetadataString(
+ metadata: Record | undefined,
+ key: string
+) {
+ const value = metadata?.[key];
+ return typeof value === "string" ? value.trim() : "";
}
function isRequestFile(value: FormDataEntryValue): value is File {
@@ -86,25 +169,39 @@ async function parseClawdChatRequest(
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
- return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
+ if (!parsed.success) return null;
+ const attachments = normalizeOpenClawChatMediaAttachments(
+ parsed.data.attachments
+ );
+ if (attachments.length !== parsed.data.attachments.length) return null;
+ return { ...parsed.data, attachments, tableFiles: [] };
}
const formData = await request.formData().catch(() => null);
if (!formData) return null;
- const metadataValue = formData.get("metadata");
let metadata: Record | undefined;
- if (typeof metadataValue === "string" && metadataValue.trim()) {
- try {
+ let attachments: unknown[] = [];
+ let sensitiveInput: unknown;
+ const metadataValue = formData.get("metadata");
+ const attachmentsValue = formData.get("attachments");
+ const sensitiveInputValue = formData.get("sensitiveInput");
+ try {
+ if (typeof metadataValue === "string" && metadataValue.trim()) {
const parsedMetadata = JSON.parse(metadataValue);
- if (parsedMetadata && typeof parsedMetadata === "object") {
- metadata = parsedMetadata as Record;
- } else {
- return null;
- }
- } catch {
- return null;
+ if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
+ metadata = parsedMetadata as Record;
}
+ if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
+ const parsedAttachments = JSON.parse(attachmentsValue);
+ if (!Array.isArray(parsedAttachments)) return null;
+ attachments = parsedAttachments;
+ }
+ if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
+ sensitiveInput = JSON.parse(sensitiveInputValue);
+ }
+ } catch {
+ return null;
}
const parsed = requestSchema.safeParse({
@@ -113,68 +210,487 @@ async function parseClawdChatRequest(
displayMessage: formData.get("displayMessage") || undefined,
userMessageId: formData.get("userMessageId") || undefined,
metadata,
+ attachments,
+ sensitiveInput,
});
if (!parsed.success) return null;
+ const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
+ parsed.data.attachments
+ );
+ if (normalizedAttachments.length !== parsed.data.attachments.length) {
+ return null;
+ }
- const tableFiles = formData
- .getAll("tableFiles")
- .filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
-
- return { ...parsed.data, tableFiles };
+ return {
+ ...parsed.data,
+ attachments: normalizedAttachments,
+ tableFiles: formData
+ .getAll("tableFiles")
+ .filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
+ };
}
async function readOpenClawConfig() {
- const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
- () => null
- );
+ const setting = await getSystemSetting({
+ key: OPENCLAW_CHAT_CONFIG_KEY,
+ }).catch(() => null);
return normalizeOpenClawChatConfig(setting?.value);
}
-function getOpenClawErrorCode(payload: unknown) {
- if (!payload || typeof payload !== "object") return "";
- const raw = payload as Record;
- const nestedError =
- raw.error && typeof raw.error === "object"
- ? (raw.error as Record)
+function parseSseBlock(block: string) {
+ let event = "message";
+ const dataLines: string[] = [];
+ for (const line of block.split(/\r?\n/)) {
+ if (line.startsWith("event:")) event = line.slice(6).trim() || "message";
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
+ }
+ if (dataLines.length === 0) return null;
+ try {
+ return { event, data: JSON.parse(dataLines.join("\n")) as unknown };
+ } catch {
+ return null;
+ }
+}
+
+function parseOpenClawBackgroundTask(
+ payload: unknown
+): OpenClawBackgroundTask | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return null;
+ }
+ const rawPayload = payload as Record;
+ const rawTask =
+ rawPayload.background_task &&
+ typeof rawPayload.background_task === "object" &&
+ !Array.isArray(rawPayload.background_task)
+ ? (rawPayload.background_task as Record)
: null;
- const code = nestedError?.code || raw.code;
- return typeof code === "string" ? code.trim() : "";
+ if (!rawTask) return null;
+
+ const jobId = String(rawTask.job_id || "").trim();
+ const state = String(rawTask.state || "").trim();
+ if (!jobId || !state) return null;
+ const completed = Math.max(0, Number(rawTask.completed) || 0);
+ const parsedTotal = Number(rawTask.total);
+ const total =
+ Number.isFinite(parsedTotal) && parsedTotal > 0 ? parsedTotal : null;
+ const parsedPercent = Number(rawTask.progress_percent);
+
+ return {
+ jobId,
+ state,
+ stage: String(rawTask.stage || "").trim(),
+ phase: String(rawTask.phase || "").trim(),
+ displayText: String(
+ rawTask.display_text || rawTask.phase || rawPayload.answer || ""
+ ).trim(),
+ updatedAt: String(rawTask.updated_at || "").trim(),
+ completed,
+ total,
+ progressPercent:
+ rawTask.progress_percent != null && Number.isFinite(parsedPercent)
+ ? Math.max(0, Math.min(100, parsedPercent))
+ : total
+ ? Math.round((completed * 100) / total)
+ : null,
+ pollEndpoint: String(rawTask.poll_endpoint || "").trim(),
+ pollAfterMs: Math.max(
+ 800,
+ Math.min(10_000, Number(rawTask.poll_after_ms) || 1500)
+ ),
+ maxWaitSeconds: Math.max(
+ 1,
+ Math.min(25, Number(rawTask.max_wait_seconds) || 20)
+ ),
+ };
}
-function getMetadataString(
- metadata: Record | undefined,
- key: string
-) {
- const value = metadata?.[key];
- return typeof value === "string" ? value.trim() : "";
+function buildOpenClawTaskProgress(task: OpenClawBackgroundTask) {
+ const message =
+ task.displayText ||
+ task.phase ||
+ (task.state === "processing" ? "任务正在处理" : "任务状态已更新");
+ return {
+ step_code: task.stage || "background_task",
+ step_state:
+ task.state === "failed"
+ ? "failed"
+ : task.state === "processing"
+ ? "started"
+ : "completed",
+ message,
+ display: message,
+ completed: task.completed,
+ ...(task.total != null ? { total: task.total } : {}),
+ ...(task.progressPercent != null
+ ? { progress_percent: task.progressPercent }
+ : {}),
+ stage: task.stage,
+ state: task.state,
+ updated_at: task.updatedAt,
+ };
}
-function createCanceledResponse() {
- return NextResponse.json({ canceled: true }, { status: 499 });
+function buildOpenClawTaskStatusUrl(params: {
+ baseUrl: string;
+ task: OpenClawBackgroundTask;
+ userId: string;
+ conversationId: string;
+}) {
+ const base = new URL(params.baseUrl);
+ const endpoint = params.task.pollEndpoint.startsWith("/")
+ ? params.task.pollEndpoint
+ : "/api/chat/task-status";
+ const url = new URL(endpoint, base.origin);
+ url.searchParams.set("job_id", params.task.jobId);
+ url.searchParams.set("user_id", params.userId);
+ url.searchParams.set("conversation_id", params.conversationId);
+ url.searchParams.set("wait_seconds", String(params.task.maxWaitSeconds));
+ if (params.task.updatedAt) {
+ url.searchParams.set("known_updated_at", params.task.updatedAt);
+ }
+ return url;
+}
+
+function getAnswer(payload: unknown) {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return "";
+ }
+ return String((payload as Record).answer || "").trim();
+}
+
+function getRequestId(payload: unknown) {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return null;
+ }
+ const value = (payload as Record).request_id;
+ return value ? String(value) : null;
+}
+
+async function readOpenClawSse(params: {
+ response: Response;
+ chatId: string;
+ assistantMessageId: string;
+}) {
+ const reader = params.response.body?.getReader();
+ if (!reader) throw new Error("OpenClaw stream is unavailable");
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let finalPayload: Record | null = null;
+
+ const processBlock = async (block: string) => {
+ const event = parseSseBlock(block);
+ if (!event) return;
+ if (event.event === "progress") {
+ if (event.data && typeof event.data === "object") {
+ await appendPersistedClawdTaskProgress({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ progress: event.data as Record,
+ });
+ }
+ return;
+ }
+ if (
+ event.event === "final" &&
+ event.data &&
+ typeof event.data === "object" &&
+ !Array.isArray(event.data)
+ ) {
+ finalPayload = event.data as Record;
+ }
+ if (event.event === "error") {
+ throw new Error("OpenClaw stream returned an error");
+ }
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+ const blocks = buffer.split(/\r?\n\r?\n/);
+ buffer = blocks.pop() || "";
+ for (const block of blocks) {
+ await processBlock(block);
+ }
+ if (done) break;
+ }
+ if (buffer.trim()) await processBlock(buffer);
+
+ if (!finalPayload) throw new Error("OpenClaw stream ended without a result");
+ return finalPayload;
+}
+
+async function pollOpenClawBackgroundTask(params: {
+ config: ExecuteClawdTaskParams["config"];
+ initialPayload: Record;
+ initialTask: OpenClawBackgroundTask;
+ controller: AbortController;
+ chatId: string;
+ assistantMessageId: string;
+ userId: string;
+ conversationId: string;
+}) {
+ let task = params.initialTask;
+ let finalPayload = params.initialPayload;
+ let failureCount = 0;
+
+ await appendPersistedClawdTaskProgress({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ progress: buildOpenClawTaskProgress(task),
+ });
+
+ while (task.state === "processing") {
+ if (!task.pollEndpoint) {
+ throw new Error("OpenClaw background task has no status endpoint");
+ }
+ try {
+ const response = await fetch(
+ buildOpenClawTaskStatusUrl({
+ baseUrl: params.config.baseUrl,
+ task,
+ userId: params.userId,
+ conversationId: params.conversationId,
+ }),
+ {
+ headers: {
+ Authorization: `Bearer ${params.config.token}`,
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ signal: params.controller.signal,
+ }
+ );
+ if (!response.ok) {
+ throw new Error(`OpenClaw task status failed: ${response.status}`);
+ }
+ const payload = (await response.json().catch(() => null)) as
+ | Record
+ | null;
+ const nextTask = parseOpenClawBackgroundTask(payload);
+ if (!payload || !nextTask) {
+ throw new Error("OpenClaw task status payload is invalid");
+ }
+ failureCount = 0;
+ finalPayload = {
+ ...payload,
+ request_id:
+ payload.request_id || params.initialPayload.request_id || null,
+ };
+ task = nextTask;
+ await appendPersistedClawdTaskProgress({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ progress: buildOpenClawTaskProgress(task),
+ });
+ } catch (error) {
+ if (params.controller.signal.aborted) throw error;
+ failureCount += 1;
+ if (failureCount >= 5) throw error;
+ await appendPersistedClawdTaskProgress({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ progress: {
+ step_code: task.stage || "background_task",
+ step_state: "waiting",
+ message: "任务仍在运行,正在重新连接进度服务",
+ display: "任务仍在运行,正在重新连接进度服务",
+ },
+ });
+ await new Promise((resolve) => setTimeout(resolve, task.pollAfterMs));
+ }
+ }
+ return finalPayload;
+}
+
+async function executeClawdTask(params: ExecuteClawdTaskParams) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), params.config.timeoutMs);
+ registerActiveClawdRequest(
+ params.cancelKey,
+ controller,
+ Math.max(params.config.timeoutMs, 10 * 60 * 1000)
+ );
+
+ try {
+ if (isClawdRequestCanceled(params.cancelKey)) return;
+ await markClawdTaskRunning({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ });
+
+ const isSensitiveSubmission = Boolean(params.sensitiveInput);
+ const upstreamBody = JSON.stringify({
+ conversation_id: params.conversationId,
+ user_id: params.userId,
+ user_name: params.userName,
+ username: params.userName,
+ department: params.departmentName,
+ department_name: params.departmentName,
+ user_department: params.departmentName,
+ skill_tag_id: params.skillTagId || undefined,
+ skill_tag_name: params.skillTagName || undefined,
+ message: params.upstreamMessage,
+ attachments:
+ params.mediaAttachments.length > 0
+ ? params.mediaAttachments
+ : undefined,
+ metadata: {
+ source: "ai_chatbot_canvas",
+ page: "/canvas",
+ tenant: "default",
+ chat_id: params.chatId,
+ user_id: params.userId,
+ user_name: params.userName,
+ username: params.userName,
+ department: params.departmentName,
+ department_name: params.departmentName,
+ user_department: params.departmentName,
+ skill_tag_id: params.skillTagId || undefined,
+ skill_tag_name: params.skillTagName || undefined,
+ media_attachments:
+ params.mediaAttachments.length > 0
+ ? params.mediaAttachments
+ : undefined,
+ spreadsheet_files:
+ params.spreadsheetFiles.length > 0
+ ? params.spreadsheetFiles
+ : undefined,
+ spreadsheet_markdown_truncated:
+ params.spreadsheetMarkdownTruncated || undefined,
+ ...params.metadata,
+ },
+ stream_progress: !isSensitiveSubmission,
+ sensitive_input: params.sensitiveInput,
+ });
+ const response = await fetch(`${params.config.baseUrl}/api/chat`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${params.config.token}`,
+ "Content-Type": "application/json",
+ Accept: isSensitiveSubmission ? "application/json" : "text/event-stream",
+ },
+ body: upstreamBody,
+ signal: controller.signal,
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new Error(`OpenClaw request failed: ${response.status}`);
+ }
+
+ const contentType = response.headers.get("content-type") || "";
+ let payload = contentType.includes("text/event-stream")
+ ? await readOpenClawSse({
+ response,
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ })
+ : ((await response.json().catch(() => null)) as Record<
+ string,
+ unknown
+ > | null);
+ if (!payload) throw new Error("OpenClaw returned an invalid response");
+
+ const backgroundTask = parseOpenClawBackgroundTask(payload);
+ if (backgroundTask?.state === "processing") {
+ payload = await pollOpenClawBackgroundTask({
+ config: params.config,
+ initialPayload: payload,
+ initialTask: backgroundTask,
+ controller,
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ userId: params.userId,
+ conversationId: params.conversationId,
+ });
+ }
+
+ if (isClawdRequestCanceled(params.cancelKey)) return;
+ const answer = getAnswer(payload);
+ if (!answer) throw new Error("OpenClaw returned an empty answer");
+ await markClawdTaskCompleted({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ answer,
+ requestId: getRequestId(payload),
+ });
+ clearClawdRequestCancellation(params.cancelKey);
+ } catch (error) {
+ if (isClawdRequestCanceled(params.cancelKey)) return;
+ console.error("[ClawdChat API] Durable task failed:", error);
+ await markClawdTaskFailed({
+ chatId: params.chatId,
+ assistantMessageId: params.assistantMessageId,
+ error: getOpenClawTaskFailureMessage(error),
+ }).catch(() => null);
+ } finally {
+ if (params.sensitiveInput?.type === "taobao_credentials") {
+ params.sensitiveInput.username = "";
+ params.sensitiveInput.password = "";
+ } else if (params.sensitiveInput?.type === "taobao_verification") {
+ params.sensitiveInput.verification_code = "";
+ }
+ params.sensitiveInput = undefined;
+ clearTimeout(timeout);
+ unregisterActiveClawdRequest(params.cancelKey);
+ }
+}
+
+function buildTaskResponse(params: {
+ chatId: string;
+ userMessageId: string;
+ assistantMessageId: string;
+ status: string;
+ processingHint: string;
+}) {
+ const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
+ params.chatId
+ )}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
+ return {
+ accepted: true,
+ task: {
+ chatId: params.chatId,
+ userMessageId: params.userMessageId,
+ assistantMessageId: params.assistantMessageId,
+ status: params.status,
+ processingHint: params.processingHint,
+ statusUrl,
+ },
+ };
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
-
if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ return NextResponse.json(
+ {
+ error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
+ code: OPENCLAW_AUTH_REQUIRED_CODE,
+ },
+ { status: 401 }
+ );
}
const parsed = await parseClawdChatRequest(request);
if (!parsed) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
- if (!parsed.message && parsed.tableFiles.length === 0) {
- return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
+ if (
+ !parsed.message &&
+ parsed.tableFiles.length === 0 &&
+ parsed.attachments.length === 0
+ ) {
+ return NextResponse.json(
+ { error: "请输入问题或上传附件" },
+ { status: 400 }
+ );
}
const config = await readOpenClawConfig();
- if (!config.enabled) {
- return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
- }
- if (!config.baseUrl || !config.token) {
+ if (!config.enabled || !config.baseUrl || !config.token) {
return NextResponse.json(
- { error: CLAW_UPGRADE_RETRY_MESSAGE },
+ { error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
{ status: 503 }
);
}
@@ -185,13 +701,22 @@ export async function POST(request: Request) {
}
const userMessageId = parsed.userMessageId || generateUUID();
- const cancelKey = buildClawdCancelKey({
- userId: session.user.id,
+ const existingTask = await getPersistedClawdTask({
chatId: parsed.id,
userMessageId,
- });
- if (isClawdRequestCanceled(cancelKey)) {
- return createCanceledResponse();
+ }).catch(() => null);
+ if (existingTask) {
+ return NextResponse.json(
+ buildTaskResponse({
+ chatId: parsed.id,
+ userMessageId,
+ assistantMessageId: existingTask.metadata.assistantMessageId,
+ status: existingTask.metadata.status,
+ processingHint:
+ existingTask.metadata.processingHint || DEFAULT_PROCESSING_HINT,
+ }),
+ { status: 202, headers: { "Retry-After": "2" } }
+ );
}
let spreadsheetMarkdownResult: Awaited<
@@ -221,10 +746,23 @@ export async function POST(request: Request) {
message: parsed.message,
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
-
- if (isClawdRequestCanceled(cancelKey)) {
- return createCanceledResponse();
- }
+ const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
+ spreadsheetMarkdownResult?.files || []
+ ).map((summary, index) => {
+ const sourceFile = parsed.tableFiles[index];
+ return {
+ name: summary.fileName,
+ size: sourceFile?.size || 0,
+ contentType:
+ sourceFile?.type ||
+ (summary.fileName.toLowerCase().endsWith(".xlsx")
+ ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ : "text/csv"),
+ sheetCount: summary.sheetCount,
+ rowCount: summary.rowCount,
+ truncated: summary.truncated,
+ };
+ });
if (!existingChat) {
await saveChat({
@@ -236,220 +774,91 @@ export async function POST(request: Request) {
}
const assistantMessageId = generateUUID();
- const conversationId = createConversationId(session.user.id, parsed.id);
- const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
- const openClawUserName = getOpenClawUserName({
+ const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
+ const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
+ const processingHint =
+ getMetadataString(parsed.metadata, "processingHint") ||
+ DEFAULT_PROCESSING_HINT;
+ const messageAttachments = parsed.attachments.map((attachment) => ({
+ name:
+ attachment.name ||
+ (attachment.type === "video"
+ ? "视频附件"
+ : attachment.type === "file"
+ ? "文件夹压缩包.zip"
+ : "图片附件"),
+ url: attachment.url,
+ contentType:
+ attachment.content_type ||
+ (attachment.type === "video"
+ ? "video/mp4"
+ : attachment.type === "file"
+ ? "application/zip"
+ : "image/jpeg"),
+ }));
+
+ await persistPendingClawdTask({
+ chatId: parsed.id,
+ userMessageId,
+ assistantMessageId,
+ displayMessage,
+ attachments: messageAttachments,
+ spreadsheetAttachments,
+ skillTagId,
+ skillTagName,
+ processingHint,
+ });
+
+ const userProfile = await getUserById({ id: session.user.id }).catch(
+ () => null
+ );
+ const userName = getOpenClawUserName({
id: session.user.id,
name: userProfile?.name ?? session.user.name,
email: userProfile?.email ?? session.user.email,
});
- const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
- const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
- const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
- const endpoint = `${config.baseUrl}/api/chat`;
+ const cancelKey = buildClawdCancelKey({
+ userId: session.user.id,
+ chatId: parsed.id,
+ userMessageId,
+ });
- let upstreamPayload: unknown = null;
- let upstreamStatus = 502;
-
- if (isClawdRequestCanceled(cancelKey)) {
- return createCanceledResponse();
- }
-
- try {
- const controller = new AbortController();
- registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
- const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
- const handleRequestAbort = () => {
- cancelClawdRequest(cancelKey);
- };
- request.signal.addEventListener("abort", handleRequestAbort, { once: true });
- const response = await fetch(endpoint, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${config.token}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- conversation_id: conversationId,
- user_id: session.user.id,
- user_name: openClawUserName,
- username: openClawUserName,
- department: openClawDepartmentName,
- department_name: openClawDepartmentName,
- user_department: openClawDepartmentName,
- skill_tag_id: skillTagId || undefined,
- skill_tag_name: skillTagName || undefined,
- message: upstreamMessage,
- metadata: {
- source: "ai_chatbot_canvas",
- page: "/canvas",
- tenant: "default",
- chat_id: parsed.id,
- user_id: session.user.id,
- user_name: openClawUserName,
- username: openClawUserName,
- department: openClawDepartmentName,
- department_name: openClawDepartmentName,
- user_department: openClawDepartmentName,
- skill_tag_id: skillTagId || undefined,
- skill_tag_name: skillTagName || undefined,
- spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
- spreadsheet_markdown_truncated:
- spreadsheetMarkdownResult?.truncated || undefined,
- ...parsed.metadata,
- },
- }),
- signal: controller.signal,
- }).finally(() => {
- clearTimeout(timeout);
- request.signal.removeEventListener("abort", handleRequestAbort);
- unregisterActiveClawdRequest(cancelKey);
+ after(async () => {
+ await executeClawdTask({
+ assistantMessageId,
+ cancelKey,
+ chatId: parsed.id,
+ config,
+ conversationId: createConversationId(session.user.id, parsed.id),
+ departmentName: String(userProfile?.department || "").trim(),
+ metadata: parsed.metadata,
+ sensitiveInput: parsed.sensitiveInput,
+ mediaAttachments: parsed.attachments,
+ skillTagId,
+ skillTagName,
+ spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
+ spreadsheetMarkdownTruncated:
+ spreadsheetMarkdownResult?.truncated || false,
+ upstreamMessage,
+ userId: session.user.id,
+ userMessageId,
+ userName,
});
+ });
- upstreamStatus = response.status;
- upstreamPayload = await response.json().catch(() => null);
-
- if (!response.ok) {
- const errorCode = getOpenClawErrorCode(upstreamPayload);
- console.error("[ClawdChat API] OpenClaw upstream error:", {
- status: response.status,
- code: errorCode || undefined,
- payload: upstreamPayload,
- });
- return NextResponse.json(
- {
- error: CLAW_UPGRADE_RETRY_MESSAGE,
- code: errorCode || undefined,
- },
- { status: response.status }
- );
- }
- } catch (error) {
- unregisterActiveClawdRequest(cancelKey);
- const errorName =
- error && typeof error === "object" && "name" in error
- ? String((error as { name?: unknown }).name || "")
- : "";
- const isAbortError = errorName === "AbortError";
- if (isClawdRequestCanceled(cancelKey)) {
- console.info("[ClawdChat API] OpenClaw request canceled:", {
- chatId: parsed.id,
- userMessageId,
- });
- return createCanceledResponse();
- }
- console.error("[ClawdChat API] OpenClaw request failed:", error);
- return NextResponse.json(
- {
- error: CLAW_UPGRADE_RETRY_MESSAGE,
- },
- { status: isAbortError ? 504 : upstreamStatus }
- );
- }
-
- const answer =
- upstreamPayload && typeof upstreamPayload === "object"
- ? String((upstreamPayload as Record).answer || "").trim()
- : "";
-
- if (!answer) {
- console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload);
- return NextResponse.json(
- { error: CLAW_UPGRADE_RETRY_MESSAGE },
- { status: 502 }
- );
- }
-
- if (isClawdRequestCanceled(cancelKey)) {
- return createCanceledResponse();
- }
-
- const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
- () => []
- );
- const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
- const timestamp =
- item.createdAt instanceof Date
- ? item.createdAt.getTime()
- : Date.parse(String(item.createdAt || ""));
- return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
- }, 0);
- const userCreatedAt = new Date(
- Math.max(Date.now(), latestMessageTimestamp + 1)
- );
- const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1);
-
- const userMessage = {
- id: userMessageId,
+ const responseBody = buildTaskResponse({
chatId: parsed.id,
- role: "user",
- parts: [{ type: "text", text: displayMessage }],
- attachments: [],
- createdAt: userCreatedAt,
- videoStorageStatus: null,
- temporaryVideoUrl: null,
- permanentVideoUrl: null,
- archivedAt: null,
- videoMetadata: {
- composerMode: "clawd",
- source: "openclaw",
- skillTagId: skillTagId || null,
- skillTagName: skillTagName || null,
+ userMessageId,
+ assistantMessageId,
+ status: "queued",
+ processingHint,
+ });
+ return NextResponse.json(responseBody, {
+ status: 202,
+ headers: {
+ "Cache-Control": "no-store, max-age=0",
+ Location: responseBody.task.statusUrl,
+ "Retry-After": "2",
},
- };
- const assistantMessage = {
- id: assistantMessageId,
- chatId: parsed.id,
- role: "assistant",
- parts: [{ type: "text", text: answer }],
- attachments: [],
- createdAt: assistantCreatedAt,
- videoStorageStatus: null,
- temporaryVideoUrl: null,
- permanentVideoUrl: null,
- archivedAt: null,
- videoMetadata: {
- composerMode: "clawd",
- source: "openclaw",
- requestId:
- upstreamPayload && typeof upstreamPayload === "object"
- ? (upstreamPayload as Record).request_id || null
- : null,
- },
- };
-
- await saveMessages({ messages: [userMessage, assistantMessage] });
- clearClawdRequestCancellation(cancelKey);
-
- return NextResponse.json({
- conversationId,
- requestId:
- upstreamPayload && typeof upstreamPayload === "object"
- ? (upstreamPayload as Record).request_id || null
- : null,
- messages: [
- {
- id: userMessage.id,
- role: "user",
- parts: userMessage.parts,
- createdAt: userMessage.createdAt.toISOString(),
- metadata: {
- createdAt: userMessage.createdAt.toISOString(),
- composerMode: "clawd",
- },
- videoMetadata: userMessage.videoMetadata,
- },
- {
- id: assistantMessage.id,
- role: "assistant",
- parts: assistantMessage.parts,
- createdAt: assistantMessage.createdAt.toISOString(),
- metadata: {
- createdAt: assistantMessage.createdAt.toISOString(),
- composerMode: "clawd",
- },
- videoMetadata: assistantMessage.videoMetadata,
- },
- ],
});
}
diff --git a/app/api/canvas/clawd-chat/status/route.ts b/app/api/canvas/clawd-chat/status/route.ts
new file mode 100644
index 000000000..81be3d3f1
--- /dev/null
+++ b/app/api/canvas/clawd-chat/status/route.ts
@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { getChatById } from "@/lib/db/queries";
+import {
+ OPENCLAW_AUTH_REQUIRED_CODE,
+ OPENCLAW_AUTH_REQUIRED_MESSAGE,
+} from "@/lib/openclaw-chat/errors";
+import { getPersistedClawdTask } from "@/lib/openclaw-chat/task-store";
+
+const statusSchema = z.object({
+ id: z.string().uuid(),
+ userMessageId: z.string().uuid().optional(),
+});
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+export async function GET(request: Request) {
+ const session = await auth().catch(() => null);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ {
+ error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
+ code: OPENCLAW_AUTH_REQUIRED_CODE,
+ },
+ { status: 401 }
+ );
+ }
+
+ const url = new URL(request.url);
+ const parsed = statusSchema.safeParse({
+ id: url.searchParams.get("id"),
+ userMessageId: url.searchParams.get("userMessageId") || undefined,
+ });
+ if (!parsed.success) {
+ return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
+ }
+
+ const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
+ if (!chat) {
+ return NextResponse.json({ task: null }, { status: 404 });
+ }
+ if (chat.userId !== session.user.id) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const task = await getPersistedClawdTask({
+ chatId: parsed.data.id,
+ userMessageId: parsed.data.userMessageId,
+ });
+ if (!task) {
+ return NextResponse.json(
+ { task: null },
+ { headers: { "Cache-Control": "no-store, max-age=0" } }
+ );
+ }
+
+ const userTextPart = Array.isArray(task.userMessage?.parts)
+ ? task.userMessage.parts.find(
+ (part) =>
+ part &&
+ typeof part === "object" &&
+ !Array.isArray(part) &&
+ (part as { type?: unknown }).type === "text"
+ )
+ : null;
+ const userText =
+ userTextPart && typeof userTextPart === "object"
+ ? String((userTextPart as { text?: unknown }).text || "")
+ : "";
+
+ return NextResponse.json(
+ {
+ task: {
+ chatId: parsed.data.id,
+ userMessageId: task.metadata.userMessageId,
+ assistantMessageId: task.metadata.assistantMessageId,
+ status: task.metadata.status,
+ processingHint: task.metadata.processingHint,
+ progressEvents: task.metadata.progressEvents,
+ skillTagId: task.metadata.skillTagId || null,
+ skillTagName: task.metadata.skillTagName || null,
+ requestId: task.metadata.requestId || null,
+ error: task.metadata.error || null,
+ createdAt: task.metadata.createdAt,
+ updatedAt: task.metadata.updatedAt,
+ userMessage: {
+ text: userText,
+ attachments: task.userMessage?.attachments || [],
+ },
+ },
+ },
+ {
+ headers: {
+ "Cache-Control": "no-store, max-age=0",
+ "Retry-After": "2",
+ },
+ }
+ );
+}
diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts
index 4a3375b8a..1faf23c9a 100644
--- a/app/api/canvas/detail-plan/route.ts
+++ b/app/api/canvas/detail-plan/route.ts
@@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
- ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
+ ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&
diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts
index 66c5738dc..45ddde0af 100644
--- a/app/api/canvas/detail-review/route.ts
+++ b/app/api/canvas/detail-review/route.ts
@@ -204,7 +204,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
- ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
+ ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&
diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts
index af82054a9..a014043db 100644
--- a/app/api/canvas/text-edit/route.ts
+++ b/app/api/canvas/text-edit/route.ts
@@ -13,6 +13,8 @@ import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
+import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
+import { createVip98k } from "@/lib/ai/providers/vip98k";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
@@ -55,6 +57,8 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"openai-image",
"bananaimage",
"visionary",
+ "atlascloud",
+ "vip98k",
"stepfun-image",
"midjourney",
"runway",
@@ -123,6 +127,10 @@ function createTextEditProvider(
return createBananaImage({ baseURL, apiKey });
case "visionary":
return createVisionary({ baseURL, apiKey });
+ case "atlascloud":
+ return createAtlasCloud({ baseURL, apiKey });
+ case "vip98k":
+ return createVip98k({ baseURL, apiKey });
case "stepfun-image":
return createStepfunImage({ baseURL, apiKey });
case "midjourney":
@@ -475,7 +483,7 @@ async function generateTextEditImageWithFailover(params: {
const runtimeModelId = resolveModelRuntimeId(selected.model, {
providerType: selected.provider.type,
});
- const result = await provider.chat(runtimeModelId).doGenerate({
+ const generationOptions: any = {
messages: [
{
role: "user",
@@ -488,7 +496,18 @@ async function generateTextEditImageWithFailover(params: {
imageParams: {
imageCount: 1,
},
- });
+ };
+ if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
+ generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
+ }
+ const modelInstance =
+ selected.provider.type === "vip98k"
+ ? (provider as any).chat(runtimeModelId, {
+ capabilities: selected.model.capabilities || null,
+ imageGenConfig: selected.model.imageGenConfig || null,
+ })
+ : provider.chat(runtimeModelId);
+ const result = await modelInstance.doGenerate(generationOptions);
const resultText = String(result?.text || "").trim();
const finishReason = String(result?.finishReason || "").toLowerCase();
diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts
index 54c57d3a0..a96b2948f 100644
--- a/app/api/canvas/world/tasks/[taskId]/route.ts
+++ b/app/api/canvas/world/tasks/[taskId]/route.ts
@@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
-import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
+import {
+ isWorldLabsExternalTaskApi,
+ WORLDLABS_EXTERNAL_TASK_MODEL,
+ WorldLabsApiError,
+ worldLabsRequest,
+} from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type {
+ WorldLabsExternalTaskResponse,
+ WorldLabsExternalTaskResultData,
WorldLabsOperation,
WorldLabsWorld,
WorldLabsWorldAssets,
@@ -419,6 +426,214 @@ function buildAssetPayload(
};
}
+function unwrapExternalTaskData>(payload: unknown) {
+ const root = getObjectValue(payload);
+ const data = getObjectValue(root?.data);
+ return (data || root || {}) as T;
+}
+
+function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
+ const normalized = String(status || "").trim().toLowerCase();
+ if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
+ return "success" as const;
+ }
+ if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
+ return "failed" as const;
+ }
+ return "running" as const;
+}
+
+function readStringRecord(value: unknown) {
+ const record = getObjectValue(value);
+ if (!record) return null;
+ const entries = Object.entries(record).filter(
+ (entry): entry is [string, string] =>
+ Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
+ );
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
+}
+
+function findNestedString(
+ value: unknown,
+ keys: string[],
+ depth = 0
+): string | null {
+ if (depth > 8) return null;
+ const record = getObjectValue(value);
+ if (!record) return null;
+
+ for (const key of keys) {
+ const directValue = getStringValue(record[key]);
+ if (directValue) return directValue;
+ }
+
+ for (const child of Object.values(record)) {
+ const found = findNestedString(child, keys, depth + 1);
+ if (found) return found;
+ }
+ return null;
+}
+
+function findNestedObject(
+ value: unknown,
+ keys: string[],
+ depth = 0
+): Record | null {
+ if (depth > 8) return null;
+ const record = getObjectValue(value);
+ if (!record) return null;
+
+ for (const key of keys) {
+ const directValue = getObjectValue(record[key]);
+ if (directValue) return directValue;
+ }
+
+ for (const child of Object.values(record)) {
+ const found = findNestedObject(child, keys, depth + 1);
+ if (found) return found;
+ }
+ return null;
+}
+
+function findNestedStringRecord(
+ value: unknown,
+ keys: string[],
+ depth = 0
+): Record | null {
+ if (depth > 8) return null;
+ const record = getObjectValue(value);
+ if (!record) return null;
+
+ for (const key of keys) {
+ const directValue = readStringRecord(record[key]);
+ if (directValue) return directValue;
+ }
+
+ for (const child of Object.values(record)) {
+ const found = findNestedStringRecord(child, keys, depth + 1);
+ if (found) return found;
+ }
+ return null;
+}
+
+function extractExternalTaskErrorMessage(
+ data: WorldLabsExternalTaskResultData,
+ payload: WorldLabsExternalTaskResponse
+) {
+ const dataError = getObjectValue(data.error);
+ const payloadError = getObjectValue(payload.error);
+ return (
+ getStringValue(
+ data.errorMessage,
+ data.message,
+ dataError?.message,
+ payload.message,
+ payloadError?.message,
+ payload.error
+ ) || "World Labs 外部任务失败"
+ );
+}
+
+function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
+ const root = getObjectValue(data) || {};
+ const resultRoot =
+ getObjectValue(root.result) ||
+ getObjectValue(root.output) ||
+ getObjectValue(root.world) ||
+ root;
+ const assets = findNestedObject(resultRoot, ["assets"]);
+ const worldAssets = assets as Partial | null;
+ const splats = getObjectValue(worldAssets?.splats);
+
+ return {
+ worldId: getStringValue(
+ findNestedString(resultRoot, ["worldId", "world_id"]),
+ findNestedString(root, ["worldId", "world_id"])
+ ),
+ worldUrl: getStringValue(
+ findNestedString(resultRoot, [
+ "worldUrl",
+ "world_marble_url",
+ "sourceWorldUrl",
+ "viewerUrl",
+ "marbleUrl",
+ ]),
+ findNestedString(root, [
+ "worldUrl",
+ "world_marble_url",
+ "sourceWorldUrl",
+ "viewerUrl",
+ "marbleUrl",
+ ])
+ ),
+ displayName: getStringValue(
+ findNestedString(resultRoot, ["displayName", "display_name"]),
+ findNestedString(root, ["displayName", "display_name"])
+ ),
+ model:
+ getStringValue(
+ findNestedString(resultRoot, ["model", "modelId", "model_id"]),
+ findNestedString(root, ["model", "modelId", "model_id"])
+ ) || WORLDLABS_EXTERNAL_TASK_MODEL,
+ caption: getStringValue(
+ worldAssets?.caption,
+ findNestedString(resultRoot, ["caption"]),
+ findNestedString(root, ["caption"])
+ ),
+ thumbnailUrl: getStringValue(
+ worldAssets?.thumbnail_url,
+ findNestedString(resultRoot, [
+ "thumbnailUrl",
+ "thumbnail_url",
+ "previewImageUrl",
+ "preview_image_url",
+ ]),
+ findNestedString(root, [
+ "thumbnailUrl",
+ "thumbnail_url",
+ "previewImageUrl",
+ "preview_image_url",
+ ])
+ ),
+ panoUrl: getStringValue(
+ worldAssets?.imagery?.pano_url,
+ findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
+ findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
+ ),
+ meshUrl: getStringValue(
+ worldAssets?.mesh?.collider_mesh_url,
+ findNestedString(resultRoot, [
+ "meshUrl",
+ "mesh_url",
+ "modelUrl",
+ "model_url",
+ "glbUrl",
+ "glb_url",
+ "colliderMeshUrl",
+ "collider_mesh_url",
+ ]),
+ findNestedString(root, [
+ "meshUrl",
+ "mesh_url",
+ "modelUrl",
+ "model_url",
+ "glbUrl",
+ "glb_url",
+ "colliderMeshUrl",
+ "collider_mesh_url",
+ ])
+ ),
+ spzUrls:
+ readStringRecord(splats?.spz_urls) ||
+ findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
+ findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
+ semanticsMetadata:
+ getObjectValue(splats?.semantics_metadata) ||
+ findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
+ findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
+ };
+}
+
interface RouteParams {
params: Promise<{ taskId: string }>;
}
@@ -452,6 +667,187 @@ export async function GET(request: Request, { params }: RouteParams) {
});
providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
+ const persistedAssetPayload =
+ getObjectValue(persistedWorldAsset?.assetPayload) || {};
+ const shouldUseExternalTaskResultApi =
+ isWorldLabsExternalTaskApi(config.baseUrl) &&
+ persistedAssetPayload.externalTaskApi !== false &&
+ persistedAssetPayload.marbleApi !== true;
+ if (shouldUseExternalTaskResultApi) {
+ const externalPayload = await worldLabsRequest<
+ WorldLabsExternalTaskResponse
+ >(config, {
+ method: "GET",
+ path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
+ timeoutMs: resolveWorldLabsStatusTimeoutMs(),
+ });
+ const externalData =
+ unwrapExternalTaskData(externalPayload);
+ const externalStatusText = getStringValue(externalData.status) || "running";
+ const externalStatus = normalizeExternalTaskStatus(
+ externalStatusText,
+ externalData.done === true
+ );
+ const externalAssetPayloadBase = {
+ ...persistedAssetPayload,
+ providerId: config.providerId || null,
+ providerName: config.providerName || null,
+ externalTaskApi: true,
+ externalTaskStatus: externalStatusText,
+ requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
+ };
+
+ if (externalStatus === "running") {
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId: normalizedTaskId,
+ type: "world",
+ status: "running",
+ progress: null,
+ output: externalData,
+ assetPayload: externalAssetPayloadBase,
+ },
+ });
+ }
+
+ if (externalStatus === "failed") {
+ const errorMessage = extractExternalTaskErrorMessage(
+ externalData,
+ externalPayload
+ );
+ await updatePersistedWorldAsset({
+ id: persistedWorldAsset?.id,
+ status: "failed",
+ progress: null,
+ error: errorMessage,
+ assetPayload: externalAssetPayloadBase,
+ });
+ logger.warn("external task failed status", {
+ elapsedMs: Date.now() - startedAt,
+ taskIdLength: normalizedTaskId.length,
+ providerId: config.providerId || null,
+ error: errorMessage,
+ });
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId: normalizedTaskId,
+ type: "world",
+ status: "failed",
+ progress: null,
+ errorMessage,
+ output: externalData,
+ assetPayload: externalAssetPayloadBase,
+ },
+ });
+ }
+
+ const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
+ const rawMeshUrl = extractedAssetPayload.meshUrl;
+ const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
+ const rawPanoUrl = extractedAssetPayload.panoUrl;
+ const stableWorldUrl: string | null = null;
+ const stableModelUrl = await archiveRemoteAssetIfNeeded({
+ cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
+ sourceUrl: rawMeshUrl,
+ filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
+ fallbackExtension: "glb",
+ validateAs: "glb",
+ });
+ const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
+ cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
+ sourceUrl: rawThumbnailUrl,
+ filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
+ fallbackExtension: "jpg",
+ });
+ const stablePanoUrl = await archiveRemoteAssetIfNeeded({
+ cacheKey: `worldlabs:${normalizedTaskId}:pano`,
+ sourceUrl: rawPanoUrl,
+ filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
+ fallbackExtension: "jpg",
+ });
+ const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
+ cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
+ sourceMap: extractedAssetPayload.spzUrls,
+ filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
+ fallbackExtension: "spz",
+ validateAs: "spz",
+ });
+ const assetPayload = {
+ ...externalAssetPayloadBase,
+ worldId: extractedAssetPayload.worldId,
+ worldUrl: stableWorldUrl,
+ displayName: extractedAssetPayload.displayName,
+ model: resolveThreeDModelAliasId(
+ extractedAssetPayload.model,
+ modelAliasConfig.worldModels
+ ),
+ caption: extractedAssetPayload.caption,
+ thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
+ panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
+ meshUrl: stableModelUrl,
+ spzUrls: stableSpzUrls,
+ semanticsMetadata: extractedAssetPayload.semanticsMetadata,
+ storageArchivedAt: new Date().toISOString(),
+ sourceWorldUrl: extractedAssetPayload.worldUrl || null,
+ sourceMeshUrl:
+ rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
+ ? rawMeshUrl
+ : null,
+ sourceThumbnailUrl:
+ rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
+ ? rawThumbnailUrl
+ : null,
+ sourcePanoUrl:
+ rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
+ ? rawPanoUrl
+ : null,
+ sourceSpzUrls:
+ extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
+ ? extractedAssetPayload.spzUrls
+ : null,
+ };
+
+ await updatePersistedWorldAsset({
+ id: persistedWorldAsset?.id,
+ status: "success",
+ progress: 100,
+ modelUrl: stableModelUrl,
+ previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
+ error: null,
+ assetPayload,
+ });
+
+ logger.info("external task success status", {
+ elapsedMs: Date.now() - startedAt,
+ taskIdLength: normalizedTaskId.length,
+ providerId: config.providerId || null,
+ hasMesh: Boolean(stableModelUrl),
+ hasPano: Boolean(stablePanoUrl),
+ hasThumbnail: Boolean(stableThumbnailUrl),
+ hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ taskId: normalizedTaskId,
+ type: "world",
+ status: "success",
+ progress: 100,
+ output: externalData,
+ modelUrl: stableModelUrl,
+ previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
+ worldId: assetPayload.worldId,
+ worldUrl: stableWorldUrl,
+ panoUrl: stablePanoUrl,
+ caption: assetPayload.caption,
+ assetPayload,
+ },
+ });
+ }
+
const operation = await worldLabsRequest(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts
index 4a123b15e..ae3961682 100644
--- a/app/api/canvas/world/tasks/route.ts
+++ b/app/api/canvas/world/tasks/route.ts
@@ -12,13 +12,23 @@ import {
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger";
import { get3DWorldCreditCost } from "@/lib/points/config";
-import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
+import {
+ isWorldLabsExternalTaskApi,
+ WORLDLABS_EXTERNAL_TASK_MODEL,
+ WorldLabsApiError,
+ worldLabsRequest,
+} from "@/lib/worldlabs/client";
import {
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
reportWorldLabsProviderRuntimeFailure,
} from "@/lib/worldlabs/server";
-import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
+import type {
+ WorldLabsExternalTaskCreateData,
+ WorldLabsExternalTaskResponse,
+ WorldLabsModel,
+ WorldLabsOperation,
+} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 180;
@@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
makePublic?: boolean;
reconstructImages?: boolean;
mediaAssetId?: string;
+ sourceUrl?: string;
mediaAssets?: Array<{
mediaAssetId?: string;
+ sourceUrl?: string;
azimuth?: number | null;
}>;
};
@@ -58,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
return value || null;
}
+function readOptionalHttpUrl(input: unknown) {
+ const value = readOptionalTrimmedText(input);
+ if (!value) return null;
+ try {
+ const parsed = new URL(value);
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
+ ? parsed.toString()
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function getObjectValue(value: unknown) {
+ return value && typeof value === "object" ? (value as Record) : null;
+}
+
+function unwrapExternalTaskData>(payload: unknown) {
+ const root = getObjectValue(payload);
+ const data = getObjectValue(root?.data);
+ return (data || root || {}) as T;
+}
+
+function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
+ const normalized = String(status || "").trim().toLowerCase();
+ if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
+ return "success" as const;
+ }
+ if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
+ return "failed" as const;
+ }
+ return "running" as const;
+}
+
+function normalizeExternalTaskCreateOperation(
+ payload: WorldLabsExternalTaskResponse
+): WorldLabsOperation {
+ const data = unwrapExternalTaskData(payload);
+ const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
+ const status = readOptionalTrimmedText(data.status) || "accepted";
+ const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
+ return {
+ operation_id: taskId || undefined,
+ done: normalizedStatus === "success",
+ error:
+ normalizedStatus === "failed"
+ ? {
+ message:
+ readOptionalTrimmedText(
+ (payload as Record)?.message ||
+ (payload as Record)?.error
+ ) || "World Labs 外部任务创建失败",
+ }
+ : null,
+ metadata: {
+ externalTaskApi: true,
+ status,
+ resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
+ requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
+ },
+ response: data,
+ };
+}
+
function shouldCharge3DPoints(mode: WorldTaskMode) {
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
}
+function shouldUseExternalTaskCreateApi(
+ config: Awaited>,
+ mode: WorldTaskMode
+) {
+ return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
+}
+
+function resolveOfficialMarbleModel(model?: string | null) {
+ const normalized = readOptionalTrimmedText(model);
+ if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
+ return "marble-1.1";
+ }
+ return normalized;
+}
+
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt);
@@ -80,15 +171,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
if (mode === "image") {
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
- if (!mediaAssetId) {
- throw new Error("图生世界缺少 mediaAssetId");
+ const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
+ if (!mediaAssetId && !sourceUrl) {
+ throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
}
return {
type: "image",
- image_prompt: {
- source: "media_asset",
- media_asset_id: mediaAssetId,
- },
+ image_prompt: mediaAssetId
+ ? {
+ source: "media_asset",
+ media_asset_id: mediaAssetId,
+ }
+ : {
+ source: "uri",
+ uri: sourceUrl,
+ },
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
@@ -102,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const multiImagePrompt = mediaAssets
.map((item) => {
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
- if (!mediaAssetId) return null;
+ const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
+ if (!mediaAssetId && !sourceUrl) return null;
const azimuth =
item?.azimuth === null || item?.azimuth === undefined
? null
@@ -111,10 +209,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
: null;
return {
...(azimuth === null ? {} : { azimuth }),
- content: {
- source: "media_asset",
- media_asset_id: mediaAssetId,
- },
+ content: mediaAssetId
+ ? {
+ source: "media_asset",
+ media_asset_id: mediaAssetId,
+ }
+ : {
+ source: "uri",
+ uri: sourceUrl,
+ },
};
})
.filter(Boolean);
@@ -137,16 +240,22 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
}
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
- if (!mediaAssetId) {
- throw new Error("视频生成世界缺少 mediaAssetId");
+ const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
+ if (!mediaAssetId && !sourceUrl) {
+ throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
}
return {
type: "video",
- video_prompt: {
- source: "media_asset",
- media_asset_id: mediaAssetId,
- },
+ video_prompt: mediaAssetId
+ ? {
+ source: "media_asset",
+ media_asset_id: mediaAssetId,
+ }
+ : {
+ source: "uri",
+ uri: sourceUrl,
+ },
...(textPrompt ? { text_prompt: textPrompt } : {}),
};
}
@@ -187,12 +296,29 @@ export async function POST(request: Request) {
);
modelForLog = resolvedWorldModel || "marble-1.1";
+ const preferredProviderId = readOptionalTrimmedText(body.providerId);
+ const displayName =
+ readOptionalTrimmedText(body.displayName) ||
+ readOptionalTrimmedText(body.prompt) ||
+ `World ${new Date().toLocaleString("zh-CN")}`;
+ const worldPrompt = buildWorldPrompt(body, mode);
+
+ const candidates = preferredProviderId
+ ? [await getWorldLabsProviderConfig(preferredProviderId)]
+ : await getWorldLabsProviderConfigs();
+ if (candidates.length === 0) {
+ throw new Error("未找到可用的 World Labs Key");
+ }
+
+ const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
+ const predictedBillingModel = candidates.some((candidate) =>
+ shouldUseExternalTaskCreateApi(candidate, mode)
+ )
+ ? WORLDLABS_EXTERNAL_TASK_MODEL
+ : officialMarbleModel;
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode);
- const unitCost = get3DWorldCreditCost(
- pointsConfig,
- resolvedWorldModel || "marble-1.1",
- );
+ const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
if (
shouldChargePoints &&
@@ -220,42 +346,51 @@ export async function POST(request: Request) {
}
}
- const preferredProviderId = readOptionalTrimmedText(body.providerId);
- const displayName =
- readOptionalTrimmedText(body.displayName) ||
- readOptionalTrimmedText(body.prompt) ||
- `World ${new Date().toLocaleString("zh-CN")}`;
- const worldPrompt = buildWorldPrompt(body, mode);
-
- const candidates = preferredProviderId
- ? [await getWorldLabsProviderConfig(preferredProviderId)]
- : await getWorldLabsProviderConfigs();
- if (candidates.length === 0) {
- throw new Error("未找到可用的 World Labs Key");
- }
-
let result: WorldLabsOperation | null = null;
let usedProvider = null as Awaited<
ReturnType
> | null;
+ let usedExternalTaskApi = false;
let lastError: unknown = null;
for (const candidate of candidates) {
try {
- result = await worldLabsRequest(candidate, {
- method: "POST",
- path: "/marble/v1/worlds:generate",
- timeoutMs: resolveWorldLabsCreateTimeoutMs(),
- json: {
- display_name: displayName,
- model: resolvedWorldModel || "marble-1.1",
- permission: {
- public: body.makePublic === true,
+ const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
+ if (useExternalTaskApi) {
+ const prompt = readOptionalTrimmedText(body.prompt);
+ if (!prompt) {
+ throw new Error("请输入世界描述");
+ }
+ const externalResult = await worldLabsRequest<
+ WorldLabsExternalTaskResponse
+ >(candidate, {
+ method: "POST",
+ path: "/api/external/task/generate",
+ timeoutMs: resolveWorldLabsCreateTimeoutMs(),
+ json: {
+ model: WORLDLABS_EXTERNAL_TASK_MODEL,
+ displayName,
+ prompt,
},
- world_prompt: worldPrompt,
- },
- });
+ });
+ result = normalizeExternalTaskCreateOperation(externalResult);
+ } else {
+ result = await worldLabsRequest(candidate, {
+ method: "POST",
+ path: "/marble/v1/worlds:generate",
+ timeoutMs: resolveWorldLabsCreateTimeoutMs(),
+ json: {
+ display_name: displayName,
+ model: officialMarbleModel,
+ permission: {
+ public: body.makePublic === true,
+ },
+ world_prompt: worldPrompt,
+ },
+ });
+ }
usedProvider = candidate;
+ usedExternalTaskApi = useExternalTaskApi;
providerForLog = candidate.providerId || null;
break;
} catch (error) {
@@ -292,12 +427,18 @@ export async function POST(request: Request) {
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
+ const usedBillingModel = usedExternalTaskApi
+ ? WORLDLABS_EXTERNAL_TASK_MODEL
+ : officialMarbleModel;
+ const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
+ ? WORLDLABS_EXTERNAL_TASK_MODEL
+ : worldModelDisplayName;
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "world3d_generation",
- modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
- note: `3D世界生成消耗积分(${worldModelDisplayName})`,
+ modelId: `worldlabs-3d:${usedBillingModel}`,
+ note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
});
} catch (error) {
logger.error("points consume failed", error, {
@@ -313,10 +454,13 @@ export async function POST(request: Request) {
elapsedMs: Date.now() - startedAt,
taskIdLength: taskId.length,
mode,
- model: resolvedWorldModel || "marble-1.1",
+ model: usedExternalTaskApi
+ ? WORLDLABS_EXTERNAL_TASK_MODEL
+ : officialMarbleModel,
providerId: usedProvider.providerId || null,
status: result.done ? "success" : "running",
unitCost,
+ externalTaskApi: usedExternalTaskApi,
});
return NextResponse.json({
@@ -327,6 +471,8 @@ export async function POST(request: Request) {
assetPayload: {
providerId: usedProvider.providerId || null,
providerName: usedProvider.providerName || null,
+ externalTaskApi: usedExternalTaskApi,
+ marbleApi: !usedExternalTaskApi,
},
raw: result,
},
diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts
index 83bc3f287..e1342fc7a 100644
--- a/app/api/canvas/world/upload/route.ts
+++ b/app/api/canvas/world/upload/route.ts
@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createLogger } from "@/lib/observability/logger";
-import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
+import { uploadFile } from "@/lib/storage/service";
+import {
+ WorldLabsApiError,
+ worldLabsRequest,
+} from "@/lib/worldlabs/client";
import {
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
@@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
+function readOptionalString(value: unknown) {
+ const text = String(value || "").trim();
+ return text || null;
+}
+
+function extractUploadInfoFromCurlExample(curlExample: unknown) {
+ const curl = String(curlExample || "");
+ const uploadUrl =
+ [...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
+ .map((match) => match[0])
+ .find(Boolean) || null;
+ const requiredHeaders: Record = {};
+ const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
+ let headerMatch: RegExpExecArray | null;
+
+ while ((headerMatch = headerPattern.exec(curl))) {
+ const header = String(
+ headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
+ ).trim();
+ const colonIndex = header.indexOf(":");
+ if (colonIndex <= 0) continue;
+ const name = header.slice(0, colonIndex).trim();
+ const value = header.slice(colonIndex + 1).trim();
+ if (name && value) {
+ requiredHeaders[name] = value;
+ }
+ }
+
+ return { uploadUrl, requiredHeaders };
+}
+
+function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
+ const uploadInfo = data.upload_info || {};
+ const uploadInfoRecord = uploadInfo as Record;
+ const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
+ const explicitHeaders =
+ uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
+ ? uploadInfo.required_headers
+ : {};
+
+ return {
+ uploadUrl:
+ readOptionalString(uploadInfo.upload_url) ||
+ readOptionalString(uploadInfoRecord.uploadUrl) ||
+ readOptionalString(uploadInfoRecord.url) ||
+ curlDetails.uploadUrl,
+ uploadMethod:
+ readOptionalString(uploadInfo.upload_method) ||
+ readOptionalString(uploadInfoRecord.method) ||
+ "PUT",
+ requiredHeaders: {
+ ...curlDetails.requiredHeaders,
+ ...explicitHeaders,
+ },
+ };
+}
+
+function isSignedUploadFailure(error: unknown) {
+ const message = error instanceof Error ? error.message : String(error || "");
+ return (
+ message.includes("SignatureDoesNotMatch") ||
+ message.includes("Access denied") ||
+ message.includes("Google secret key") ||
+ message.includes("签名")
+ );
+}
+
+async function uploadWorldLabsInputToStorage(file: File, extension: string) {
+ const buffer = await file.arrayBuffer();
+ const safeExtension = extension && extension !== "bin" ? extension : "bin";
+ const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
+ const uploaded = await uploadFile(filename, buffer);
+ return uploaded.url;
+}
+
function isCredentialOrBillingProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
@@ -202,24 +281,24 @@ export async function POST(request: Request) {
);
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
- const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
+ const { uploadUrl, uploadMethod, requiredHeaders } =
+ resolvePrepareUploadDetails(prepareUpload);
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
}
const uploadHeaders = new Headers();
- uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
- const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue));
}
+ if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
+ uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
+ }
- const uploadMethod = String(
- prepareUpload.upload_info?.upload_method || "PUT"
- ).toUpperCase();
+ const normalizedUploadMethod = uploadMethod.toUpperCase();
const uploadResponse = await fetch(uploadUrl, {
- method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
+ method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
headers: uploadHeaders,
body: file,
cache: "no-store",
@@ -252,6 +331,28 @@ export async function POST(request: Request) {
});
} catch (error) {
lastError = error;
+ if (isSignedUploadFailure(error)) {
+ try {
+ const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
+ logger.warn("worldlabs signed upload failed; using storage source url", {
+ elapsedMs: Date.now() - startedAt,
+ providerId: config.providerId || null,
+ kind: isImage ? "image" : "video",
+ size: file.size,
+ });
+ return NextResponse.json({
+ success: true,
+ data: {
+ sourceUrl,
+ providerId: config.providerId || null,
+ providerName: config.providerName || null,
+ uploadFallback: "storage-source-url",
+ },
+ });
+ } catch (fallbackError) {
+ lastError = fallbackError;
+ }
+ }
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
config.providerId,
diff --git a/app/api/client-performance/agent-style/route.ts b/app/api/client-performance/agent-style/route.ts
index 798477532..37894f2a4 100644
--- a/app/api/client-performance/agent-style/route.ts
+++ b/app/api/client-performance/agent-style/route.ts
@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
+import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
import { db } from "@/lib/db";
import { clientPerformanceEvent } from "@/lib/db/schema";
@@ -167,9 +168,12 @@ export async function POST(request: Request) {
console.warn("[ClientPerformance] prune failed", error);
});
+ const persistedChatId =
+ event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
+
await db.insert(clientPerformanceEvent).values({
userId: session?.user?.id || null,
- chatId: event.chatId || null,
+ chatId: persistedChatId,
projectId: clampText(event.projectId, 64),
eventType: clampText(event.eventType, 64) || "unknown",
section: event.section || "unknown",
diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts
index 104cf60fe..2f998684b 100644
--- a/app/api/enhance/route.ts
+++ b/app/api/enhance/route.ts
@@ -9,9 +9,11 @@ import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
+import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
// 变清晰API配置类型
type EnhanceApiConfig = {
+ provider?: "vip98k" | "aoscdn";
apiUrl?: string;
apiKey?: string;
type?: string; // "clean" | "face"
@@ -317,6 +319,102 @@ async function processWithRealESRGANBase64(
};
}
+async function processWithVip98kEnhance(params: {
+ userId: string;
+ imageBase64: string;
+ prompt: string;
+ chatId: string | null;
+ messageId: string | null;
+ sourceImageUrl: string | null;
+ scaleFactor: number;
+}) {
+ const startedAt = Date.now();
+ const result = await executeConfiguredVip98kTask({
+ modelId: "enhance",
+ kind: "image-tool",
+ prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
+ imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
+ imageParams: { scaleFactor: params.scaleFactor },
+ });
+ const outputUrl = result.outputUrls[0];
+ if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
+
+ const response = await fetch(outputUrl, {
+ cache: "no-store",
+ signal: AbortSignal.timeout(120_000),
+ });
+ if (!response.ok) {
+ throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
+ }
+ const outputBuffer = Buffer.from(await response.arrayBuffer());
+ const outputBase64 = outputBuffer.toString("base64");
+ const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
+ const [savedImage] = await db
+ .insert(generatedImage)
+ .values({
+ userId: params.userId,
+ chatId: params.chatId,
+ messageId: params.messageId,
+ thumbnailUrl: uploaded.thumbnail,
+ originalUrl: uploaded.original,
+ imageSize: `${params.scaleFactor}x放大`,
+ prompt: params.prompt,
+ modelId: "enhance",
+ })
+ .returning();
+
+ try {
+ const sourceAssets = params.sourceImageUrl
+ ? await getMediaAssetsByUserAndUrls({
+ userId: params.userId,
+ urls: [params.sourceImageUrl],
+ })
+ : [];
+ const media = await upsertMediaAsset({
+ userId: params.userId,
+ chatId: params.chatId,
+ mediaType: "image",
+ sourceType: "edited",
+ status: "ready",
+ originalUrl: uploaded.original,
+ previewUrl: uploaded.original,
+ thumbnailUrl: uploaded.thumbnail,
+ displayName: params.prompt.slice(0, 80),
+ prompt: params.prompt,
+ modelId: "enhance",
+ sourceMessageId: params.messageId,
+ sourceTable: "GeneratedImage",
+ sourceRowId: savedImage?.id || null,
+ primaryParentAssetId: sourceAssets[0]?.id ?? null,
+ metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
+ });
+ if (media) {
+ for (const sourceAsset of sourceAssets) {
+ await createMediaAssetRelation({
+ userId: params.userId,
+ chatId: params.chatId,
+ fromAssetId: sourceAsset.id,
+ toAssetId: media.id,
+ relationType: "edited_from",
+ sourceMessageId: params.messageId,
+ metadata: { prompt: params.prompt, provider: "vip98k" },
+ });
+ }
+ }
+ } catch (error) {
+ console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
+ }
+
+ return {
+ success: true,
+ image_base64: outputBase64,
+ thumbnail_url: uploaded.thumbnail,
+ original_url: uploaded.original,
+ processing_time: (Date.now() - startedAt) / 1000,
+ api_type: "vip98k",
+ };
+}
+
/**
* 轮询获取任务结果(根据官方示例)
*/
@@ -563,6 +661,12 @@ export async function GET(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
+ if (config.provider === "vip98k") {
+ return NextResponse.json(
+ { error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
+ { status: 400 }
+ );
+ }
if (config.useBackupApi) {
return NextResponse.json(
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
@@ -651,12 +755,15 @@ export async function POST(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
+ const useVip98k = config.provider === "vip98k";
// 检查是否使用备用API
- const useBackupApi = config.useBackupApi === true;
+ const useBackupApi = !useVip98k && config.useBackupApi === true;
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
- if (useBackupApi) {
+ if (useVip98k) {
+ // VIP98K 密钥只从 AiProvider 读取。
+ } else if (useBackupApi) {
// 验证备用API配置
if (!config.backupApiUrl || !config.backupApiKey) {
return NextResponse.json(
@@ -757,6 +864,31 @@ export async function POST(request: NextRequest) {
);
}
+ if (useVip98k) {
+ try {
+ const scaleFactor = requestedScaleFactor || 4;
+ const payload = await processWithVip98kEnhance({
+ userId,
+ imageBase64,
+ prompt: prompt || "图片变清晰处理",
+ chatId,
+ messageId,
+ sourceImageUrl,
+ scaleFactor,
+ });
+ return NextResponse.json(payload);
+ } catch (error) {
+ console.error("[Enhance-VIP98K] Processing failed", error);
+ return NextResponse.json(
+ {
+ error: "VIP98K 变清晰处理失败",
+ detail: error instanceof Error ? error.message : "未知错误",
+ },
+ { status: 500 }
+ );
+ }
+ }
+
// ========== 备用API处理路径 (Real-ESRGAN) ==========
if (useBackupApi) {
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts
index 9473da466..946dd035a 100644
--- a/app/api/external/image/generate/route.ts
+++ b/app/api/external/image/generate/route.ts
@@ -21,103 +21,6 @@ import {
export const runtime = "nodejs";
export const maxDuration = 1200;
-const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
-const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
-const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
-const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
-
-class ExternalImageApiBusyError extends Error {
- readonly retryAfterSeconds: number;
-
- constructor(message: string, retryAfterSeconds = 15) {
- super(message);
- this.name = "ExternalImageApiBusyError";
- this.retryAfterSeconds = retryAfterSeconds;
- }
-}
-
-type ExternalImageApiWaiter = {
- resolve: (release: () => void) => void;
- reject: (error: Error) => void;
- timer: ReturnType;
-};
-
-type ExternalImageApiLimiterState = {
- running: number;
- queue: ExternalImageApiWaiter[];
-};
-
-function parsePositiveInteger(value: unknown, fallback: number) {
- const parsed = Number.parseInt(String(value || ""), 10);
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
-}
-
-function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
- const globalState = globalThis as typeof globalThis & {
- [EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
- };
-
- if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
- globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
- running: 0,
- queue: [],
- };
- }
-
- return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
-}
-
-function acquireExternalImageApiSlot(): Promise<() => void> {
- const state = getExternalImageApiLimiterState();
- const maxConcurrent = parsePositiveInteger(
- process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
- DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
- );
- const maxQueue = parsePositiveInteger(
- process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
- DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
- );
- const queueTimeoutMs = parsePositiveInteger(
- process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
- DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
- );
-
- const release = () => {
- state.running = Math.max(0, state.running - 1);
- const next = state.queue.shift();
- if (!next) return;
- clearTimeout(next.timer);
- state.running += 1;
- next.resolve(release);
- };
-
- if (state.running < maxConcurrent) {
- state.running += 1;
- return Promise.resolve(release);
- }
-
- if (state.queue.length >= maxQueue) {
- return Promise.reject(
- new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
- );
- }
-
- return new Promise((resolve, reject) => {
- const waiter: ExternalImageApiWaiter = {
- resolve,
- reject,
- timer: setTimeout(() => {
- const index = state.queue.indexOf(waiter);
- if (index >= 0) {
- state.queue.splice(index, 1);
- }
- reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
- }, queueTimeoutMs),
- };
- state.queue.push(waiter);
- });
-}
-
const requestSchema = z.object({
prompt: z.string().trim().min(1, "prompt 不能为空"),
negativePrompt: z.string().trim().optional(),
@@ -294,10 +197,8 @@ export async function POST(request: Request) {
const startedAt = Date.now();
let logId: string | null = null;
- let releaseExternalImageApiSlot: (() => void) | null = null;
try {
- releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
const payload = await parseRequestPayload(request);
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
const candidateModels = await buildExternalImageApiModelCandidates({
@@ -344,6 +245,7 @@ export async function POST(request: Request) {
const generationResult = await generateImagesWithFailover({
candidates: candidateModels,
+ ownerKey: `external-image-api:${authResult.accessKey.id}`,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt || null,
imageCount: payload.imageCount || 1,
@@ -467,21 +369,6 @@ export async function POST(request: Request) {
.where(eq(externalImageApiRequestLog.id, logId));
}
- if (error instanceof ExternalImageApiBusyError) {
- return NextResponse.json(
- {
- success: false,
- error: error.message,
- },
- {
- status: 429,
- headers: {
- "Retry-After": String(error.retryAfterSeconds),
- },
- },
- );
- }
-
return NextResponse.json(
{
success: false,
@@ -489,9 +376,5 @@ export async function POST(request: Request) {
},
{ status: 500 },
);
- } finally {
- if (releaseExternalImageApiSlot) {
- releaseExternalImageApiSlot();
- }
}
}
diff --git a/app/api/openapi/v1/access-keys/provision/route.ts b/app/api/openapi/v1/access-keys/provision/route.ts
new file mode 100644
index 000000000..1ed9c84da
--- /dev/null
+++ b/app/api/openapi/v1/access-keys/provision/route.ts
@@ -0,0 +1,148 @@
+import { and, eq, isNotNull } from "drizzle-orm";
+import { z } from "zod";
+import {
+ createUserAccessKey,
+ listUserAccessKeys,
+} from "@/lib/access-keys/service";
+import {
+ MIN_PROVISIONING_SECRET_LENGTH,
+ parseProvisioningBearerSecret,
+ verifyProvisioningSecret,
+} from "@/lib/access-keys/provisioning-secret";
+import { db } from "@/lib/db";
+import { user } from "@/lib/db/schema";
+
+const provisionAccessKeySchema = z.object({
+ dingTalkUsername: z.string().trim().min(1).max(100),
+ dingTalkUserId: z.string().trim().min(1).max(128).optional(),
+ keyName: z.string().trim().min(1).max(100).optional(),
+ scopes: z
+ .array(z.enum(["canvas:read", "canvas:write"]))
+ .min(1)
+ .optional(),
+ expiresInDays: z.number().int().min(1).max(365).optional(),
+});
+
+function jsonError(error: string, code: string, status: number) {
+ return Response.json(
+ { success: false, error, code },
+ { status, headers: { "Cache-Control": "no-store" } }
+ );
+}
+
+export async function POST(request: Request) {
+ const expectedSecret = String(
+ process.env.MCP_KEY_PROVISIONING_SECRET || ""
+ ).trim();
+ if (expectedSecret.length < MIN_PROVISIONING_SECRET_LENGTH) {
+ return jsonError(
+ "Access Key provisioning is not configured",
+ "PROVISIONING_NOT_CONFIGURED",
+ 503
+ );
+ }
+
+ const providedSecret = parseProvisioningBearerSecret(
+ request.headers.get("authorization")
+ );
+ if (!verifyProvisioningSecret(providedSecret, expectedSecret)) {
+ return jsonError(
+ "Missing or invalid provisioning credential",
+ "PROVISIONING_UNAUTHORIZED",
+ 401
+ );
+ }
+
+ const parsedBody = provisionAccessKeySchema.safeParse(
+ await request.json().catch(() => null)
+ );
+ if (!parsedBody.success) {
+ return jsonError(
+ "Invalid provisioning request",
+ "PROVISIONING_REQUEST_INVALID",
+ 400
+ );
+ }
+
+ const body = parsedBody.data;
+ const conditions = [
+ eq(user.name, body.dingTalkUsername),
+ eq(user.isActive, true),
+ isNotNull(user.dingtalkUserId),
+ ];
+ if (body.dingTalkUserId) {
+ conditions.push(eq(user.dingtalkUserId, body.dingTalkUserId));
+ }
+
+ const matches = await db
+ .select({
+ id: user.id,
+ name: user.name,
+ dingtalkUserId: user.dingtalkUserId,
+ })
+ .from(user)
+ .where(and(...conditions))
+ .limit(2);
+
+ if (matches.length === 0) {
+ return jsonError(
+ "No active DingTalk user matched the supplied identity",
+ "DINGTALK_USER_NOT_FOUND",
+ 404
+ );
+ }
+ if (matches.length > 1) {
+ return jsonError(
+ "DingTalk username is not unique; provide dingTalkUserId",
+ "DINGTALK_USERNAME_AMBIGUOUS",
+ 409
+ );
+ }
+
+ const matchedUser = matches[0];
+ const existingKeys = await listUserAccessKeys(matchedUser.id);
+ const activeKeyCount = existingKeys.filter(
+ (item) =>
+ !item.revokedAt &&
+ (!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
+ ).length;
+ if (activeKeyCount >= 10) {
+ return jsonError(
+ "The user already has 10 active Access Keys",
+ "ACCESS_KEY_LIMIT_REACHED",
+ 409
+ );
+ }
+
+ try {
+ const accessKey = await createUserAccessKey({
+ userId: matchedUser.id,
+ name: body.keyName || "第三方 OpenClaw",
+ scopes: body.scopes,
+ expiresInDays: body.expiresInDays,
+ });
+
+ return Response.json(
+ {
+ success: true,
+ data: {
+ user: matchedUser,
+ accessKey,
+ },
+ },
+ {
+ status: 201,
+ headers: {
+ "Cache-Control": "no-store",
+ Pragma: "no-cache",
+ },
+ }
+ );
+ } catch {
+ return jsonError(
+ "Failed to provision Access Key",
+ "ACCESS_KEY_PROVISION_FAILED",
+ 500
+ );
+ }
+}
diff --git a/app/api/openapi/v1/canvases/[id]/operations/route.ts b/app/api/openapi/v1/canvases/[id]/operations/route.ts
new file mode 100644
index 000000000..d6e74a751
--- /dev/null
+++ b/app/api/openapi/v1/canvases/[id]/operations/route.ts
@@ -0,0 +1,48 @@
+import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
+import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
+import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
+import { z } from "zod";
+import {
+ openApiErrorResponse,
+ requireOpenApiPrincipal,
+} from "@/lib/access-keys/openapi-auth";
+
+const canvasIdSchema = z.string().uuid();
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const principal = await requireOpenApiPrincipal(request, "canvas:write");
+ const { id: rawId } = await params;
+ const parsedId = canvasIdSchema.safeParse(rawId);
+ if (!parsedId.success) {
+ return Response.json(
+ {
+ success: false,
+ error: "Invalid canvas ID",
+ code: "INVALID_CANVAS_ID",
+ },
+ { status: 400 }
+ );
+ }
+
+ const id = parsedId.data;
+ const response = await handleCanvasAgentOperationRouteRequest({
+ userId: principal.userId,
+ projectId: id,
+ readBody: () => request.json(),
+ features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
+ applyOperation: applyCanvasOperationPlan,
+ logger: console,
+ });
+
+ return Response.json(response.body, {
+ status: response.status,
+ headers: { "Cache-Control": "no-store" },
+ });
+ } catch (error) {
+ return openApiErrorResponse(error);
+ }
+}
diff --git a/app/api/openapi/v1/canvases/[id]/route.ts b/app/api/openapi/v1/canvases/[id]/route.ts
new file mode 100644
index 000000000..28b4094c0
--- /dev/null
+++ b/app/api/openapi/v1/canvases/[id]/route.ts
@@ -0,0 +1,57 @@
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { canvasProject } from "@/lib/db/schema";
+import {
+ openApiErrorResponse,
+ requireOpenApiPrincipal,
+} from "@/lib/access-keys/openapi-auth";
+
+const canvasIdSchema = z.string().uuid();
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const principal = await requireOpenApiPrincipal(request, "canvas:read");
+ const { id: rawId } = await params;
+ const parsedId = canvasIdSchema.safeParse(rawId);
+ if (!parsedId.success) {
+ return Response.json(
+ {
+ success: false,
+ error: "Invalid canvas ID",
+ code: "INVALID_CANVAS_ID",
+ },
+ { status: 400 }
+ );
+ }
+
+ const id = parsedId.data;
+ const [project] = await db
+ .select()
+ .from(canvasProject)
+ .where(
+ and(
+ eq(canvasProject.id, id),
+ eq(canvasProject.userId, principal.userId)
+ )
+ )
+ .limit(1);
+
+ if (!project) {
+ return Response.json(
+ { success: false, error: "Canvas not found", code: "CANVAS_NOT_FOUND" },
+ { status: 404 }
+ );
+ }
+
+ return Response.json(
+ { success: true, data: project },
+ { headers: { "Cache-Control": "no-store" } }
+ );
+ } catch (error) {
+ return openApiErrorResponse(error);
+ }
+}
diff --git a/app/api/openapi/v1/canvases/route.ts b/app/api/openapi/v1/canvases/route.ts
new file mode 100644
index 000000000..a540ae4cf
--- /dev/null
+++ b/app/api/openapi/v1/canvases/route.ts
@@ -0,0 +1,52 @@
+import { and, desc, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { canvasProject } from "@/lib/db/schema";
+import {
+ openApiErrorResponse,
+ requireOpenApiPrincipal,
+} from "@/lib/access-keys/openapi-auth";
+
+function normalizeWorkspaceType(value: string | null) {
+ return value === "agent" || value === "video" ? value : "canvas";
+}
+
+export async function GET(request: Request) {
+ try {
+ const principal = await requireOpenApiPrincipal(request, "canvas:read");
+ const url = new URL(request.url);
+ const workspaceType = normalizeWorkspaceType(
+ url.searchParams.get("workspaceType")
+ );
+ const requestedLimit = Number(url.searchParams.get("limit") || 24);
+ const limit = Number.isFinite(requestedLimit)
+ ? Math.min(50, Math.max(1, Math.round(requestedLimit)))
+ : 24;
+
+ const projects = await db
+ .select({
+ id: canvasProject.id,
+ title: canvasProject.title,
+ description: canvasProject.description,
+ thumbnail: canvasProject.thumbnail,
+ workspaceType: canvasProject.workspaceType,
+ createdAt: canvasProject.createdAt,
+ updatedAt: canvasProject.updatedAt,
+ })
+ .from(canvasProject)
+ .where(
+ and(
+ eq(canvasProject.userId, principal.userId),
+ eq(canvasProject.workspaceType, workspaceType)
+ )
+ )
+ .orderBy(desc(canvasProject.updatedAt))
+ .limit(limit);
+
+ return Response.json(
+ { success: true, data: projects },
+ { headers: { "Cache-Control": "no-store" } }
+ );
+ } catch (error) {
+ return openApiErrorResponse(error);
+ }
+}
diff --git a/app/api/openapi/v1/me/route.ts b/app/api/openapi/v1/me/route.ts
new file mode 100644
index 000000000..680adbedc
--- /dev/null
+++ b/app/api/openapi/v1/me/route.ts
@@ -0,0 +1,41 @@
+import { eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { user } from "@/lib/db/schema";
+import {
+ openApiErrorResponse,
+ requireOpenApiPrincipal,
+} from "@/lib/access-keys/openapi-auth";
+
+export async function GET(request: Request) {
+ try {
+ const principal = await requireOpenApiPrincipal(request, "canvas:read");
+ const [currentUser] = await db
+ .select({
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ department: user.department,
+ role: user.role,
+ })
+ .from(user)
+ .where(eq(user.id, principal.userId))
+ .limit(1);
+
+ return Response.json(
+ {
+ success: true,
+ data: {
+ user: currentUser,
+ accessKey: {
+ id: principal.accessKeyId,
+ name: principal.accessKeyName,
+ scopes: principal.scopes,
+ },
+ },
+ },
+ { headers: { "Cache-Control": "no-store" } }
+ );
+ } catch (error) {
+ return openApiErrorResponse(error);
+ }
+}
diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts
index 2b6c61687..063c6eb96 100644
--- a/app/api/scene-fusion/route.ts
+++ b/app/api/scene-fusion/route.ts
@@ -27,6 +27,8 @@ import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
+import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
+import { createVip98k } from "@/lib/ai/providers/vip98k";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import {
canProcessSceneFusionProviderType,
@@ -1477,7 +1479,8 @@ async function processSceneWithProvider(
modelId: string,
runtimeModelId: string,
aspectRatio?: string,
- imageSize?: string
+ imageSize?: string,
+ imageGenConfig?: Record | null
): Promise<{ thumbnail: string; original: string; prompt: string }> {
// 构建智能提示词
const prompt = buildPrompt(
@@ -1551,6 +1554,30 @@ async function processSceneWithProvider(
imageSize,
);
+ case "atlascloud":
+ return processWithAtlasCloud(
+ productImageUrl,
+ sceneImageUrl,
+ prompt,
+ provider,
+ runtimeModelId || modelId,
+ aspectRatio,
+ imageSize,
+ imageGenConfig,
+ );
+
+ case "vip98k":
+ return processWithVip98k(
+ productImageUrl,
+ sceneImageUrl,
+ prompt,
+ provider,
+ runtimeModelId || modelId,
+ aspectRatio,
+ imageSize,
+ imageGenConfig,
+ );
+
case "gemini":
case "gemini-image":
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
@@ -1880,6 +1907,84 @@ async function processWithVisionary(
return { thumbnail, original, prompt };
}
+async function processWithAtlasCloud(
+ productImageUrl: string,
+ sceneImageUrl: string,
+ prompt: string,
+ provider: { baseUrl: string; apiKey: string },
+ modelId: string,
+ aspectRatio?: string,
+ imageSize?: string,
+ imageGenConfig?: Record | null
+): Promise<{ thumbnail: string; original: string; prompt: string }> {
+ logger.info("provider_processing", { providerType: "atlascloud", modelId });
+
+ const atlasCloudProvider = createAtlasCloud({
+ baseURL: provider.baseUrl,
+ apiKey: provider.apiKey,
+ });
+ const result = await atlasCloudProvider.chat(modelId).doGenerate({
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "image_url", image_url: { url: productImageUrl } },
+ { type: "image_url", image_url: { url: sceneImageUrl } },
+ { type: "text", text: prompt },
+ ],
+ },
+ ],
+ imageParams: {
+ aspectRatio: aspectRatio || "auto",
+ imageSize: imageSize || "2K",
+ imageCount: 1,
+ },
+ imageGenConfig,
+ });
+
+ const { thumbnail, original } = parseImageFromMarkdown(result.text);
+ return { thumbnail, original, prompt };
+}
+
+async function processWithVip98k(
+ productImageUrl: string,
+ sceneImageUrl: string,
+ prompt: string,
+ provider: { baseUrl: string; apiKey: string },
+ modelId: string,
+ aspectRatio?: string,
+ imageSize?: string,
+ imageGenConfig?: Record | null
+): Promise<{ thumbnail: string; original: string; prompt: string }> {
+ logger.info("provider_processing", { providerType: "vip98k", modelId });
+
+ const vip98kProvider = createVip98k({
+ baseURL: provider.baseUrl,
+ apiKey: provider.apiKey,
+ });
+ const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "image_url", image_url: { url: productImageUrl } },
+ { type: "image_url", image_url: { url: sceneImageUrl } },
+ { type: "text", text: prompt },
+ ],
+ },
+ ],
+ imageParams: {
+ aspectRatio: aspectRatio || "auto",
+ imageSize: imageSize || "2K",
+ imageCount: 1,
+ },
+ imageGenConfig,
+ });
+
+ const { thumbnail, original } = parseImageFromMarkdown(result.text);
+ return { thumbnail, original, prompt };
+}
+
/**
* 使用既梦/火山模型处理
*/
@@ -2135,7 +2240,8 @@ async function processSceneWithDiversity(
candidate.model.modelId,
runtimeModelId,
normalizedOutputParams.aspectRatio,
- normalizedOutputParams.imageSize
+ normalizedOutputParams.imageSize,
+ (candidate.model.imageGenConfig as Record | null) || null
);
} finally {
await modelSlot.release();
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts
index 0323829a5..36a5256f0 100644
--- a/app/api/site-settings/route.ts
+++ b/app/api/site-settings/route.ts
@@ -579,6 +579,10 @@ const HOME_SITE_SETTINGS_FALLBACK: Record = {
favicon: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
+ bannerEnabled: true,
+ bannerText: "限时特惠:升级会员最高立享 45% OFF!",
+ bannerLink: null,
+ bannerActionText: "立即查看",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
surpriseCategories: ["全部", "精选"],
@@ -897,6 +901,7 @@ async function getScopedPublicSiteSettings(
bannerText:
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: typedSettings?.bannerLink || null,
+ bannerActionText: typedSettings?.bannerActionText || "立即查看",
lovartComposerPrefix: normalizeLovartComposerPrefix(
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
),
@@ -939,6 +944,11 @@ async function getScopedPublicSiteSettings(
return createScopedSiteSettingsResponse(scope, {
...createBrandingSettings(typedSettings),
+ bannerEnabled: typedSettings?.bannerEnabled ?? true,
+ bannerText:
+ typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
+ bannerLink: typedSettings?.bannerLink || null,
+ bannerActionText: typedSettings?.bannerActionText || "立即查看",
lovartPromoText:
typedSettings?.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
@@ -1470,6 +1480,7 @@ export async function GET(request: NextRequest) {
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
+ bannerActionText: "立即查看",
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
@@ -1651,6 +1662,7 @@ const updateSchema = z.object({
bannerEnabled: z.boolean().optional(),
bannerText: z.string().optional(),
bannerLink: z.string().nullable().optional(),
+ bannerActionText: z.string().max(32).optional(),
toolbox1Name: z.string().max(64).optional(),
toolbox1Enabled: z.boolean().optional(),
toolbox2Name: z.string().max(64).optional(),
@@ -3111,6 +3123,7 @@ export async function PUT(request: NextRequest) {
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsPayload.bannerLink,
+ bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看",
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
diff --git a/app/api/user/access-keys/[id]/route.ts b/app/api/user/access-keys/[id]/route.ts
new file mode 100644
index 000000000..8efe7fac6
--- /dev/null
+++ b/app/api/user/access-keys/[id]/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { revokeUserAccessKey } from "@/lib/access-keys/service";
+
+const idSchema = z.string().uuid();
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const session = await auth();
+ if (!session?.user?.id || session.user.type === "guest") {
+ return NextResponse.json(
+ { success: false, error: "请先登录正式账号" },
+ { status: 401 }
+ );
+ }
+
+ const parsedId = idSchema.safeParse((await params).id);
+ if (!parsedId.success) {
+ return NextResponse.json(
+ { success: false, error: "Access Key ID 无效" },
+ { status: 400 }
+ );
+ }
+
+ const revoked = await revokeUserAccessKey({
+ userId: session.user.id,
+ accessKeyId: parsedId.data,
+ });
+ if (!revoked) {
+ return NextResponse.json(
+ { success: false, error: "Access Key 不存在" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ success: true });
+}
diff --git a/app/api/user/access-keys/route.ts b/app/api/user/access-keys/route.ts
new file mode 100644
index 000000000..08e2f5ff5
--- /dev/null
+++ b/app/api/user/access-keys/route.ts
@@ -0,0 +1,76 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import {
+ createUserAccessKey,
+ listUserAccessKeys,
+} from "@/lib/access-keys/service";
+
+const createAccessKeySchema = z.object({
+ name: z.string().trim().min(1).max(100).optional(),
+ scopes: z
+ .array(z.enum(["canvas:read", "canvas:write"]))
+ .min(1)
+ .optional(),
+ expiresInDays: z.number().int().min(1).max(365).optional(),
+});
+
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id || session.user.type === "guest") {
+ return NextResponse.json(
+ { success: false, error: "请先登录正式账号" },
+ { status: 401 }
+ );
+ }
+
+ const keys = await listUserAccessKeys(session.user.id);
+ return NextResponse.json(
+ { success: true, data: keys },
+ { headers: { "Cache-Control": "no-store" } }
+ );
+}
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id || session.user.type === "guest") {
+ return NextResponse.json(
+ { success: false, error: "请先登录正式账号" },
+ { status: 401 }
+ );
+ }
+
+ const existingKeys = await listUserAccessKeys(session.user.id);
+ const activeKeyCount = existingKeys.filter(
+ (item) =>
+ !item.revokedAt &&
+ (!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
+ ).length;
+ if (activeKeyCount >= 10) {
+ return NextResponse.json(
+ { success: false, error: "最多保留 10 个有效 Access Key" },
+ { status: 409 }
+ );
+ }
+
+ try {
+ const body = createAccessKeySchema.parse(
+ await request.json().catch(() => ({}))
+ );
+ const created = await createUserAccessKey({
+ userId: session.user.id,
+ name: body?.name,
+ scopes: body?.scopes,
+ expiresInDays: body?.expiresInDays,
+ });
+ return NextResponse.json({ success: true, data: created }, { status: 201 });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : "创建 Access Key 失败",
+ },
+ { status: 400 }
+ );
+ }
+}
diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
index a9bbcb039..ff4ec3457 100644
--- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
+++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
@@ -141,6 +141,7 @@ import {
createEmptyAgentGenerationProjectWorkspaceState,
createEmptyAgentWorkflowPersistedState,
normalizeAgentGenerationProjectPersistedState,
+ normalizeAgentGenerationProjectReplacementResultState,
} from "@/lib/agents/generation-project-state";
import {
createEmptyAgentModeWorkspaceState,
@@ -192,11 +193,6 @@ import {
sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config";
import { cn, fetcher } from "@/lib/utils";
-import {
- loadCanvasChatPanelModule,
- preloadCanvasChatPanel,
-} from "@/components/canvas/canvas-chat-panel-loader";
-import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
import {
buildVideoWorkflowCanvasNode,
getVideoWorkflowNodeDefinition,
@@ -232,6 +228,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
import type {
AgentGenerationProject,
AgentGenerationProjectPersistedState,
+ AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentGenerationProjectWorkspaceAttachment,
AgentGenerationProjectWorkspaceState,
@@ -316,7 +313,7 @@ const CanvasWorkspace = dynamic(
const CanvasChatPanel = dynamic(
() =>
- loadCanvasChatPanelModule().then(
+ import("@/components/canvas/CanvasChatPanel").then(
(module) => module.CanvasChatPanel
),
{
@@ -1140,7 +1137,7 @@ function normalizeImportedCanvasState(payload: unknown): {
};
}
-const FLOATING_PANEL_WIDTH = 380;
+const FLOATING_PANEL_WIDTH = 400;
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
const FLOATING_PANEL_INSET = 16;
@@ -1545,6 +1542,57 @@ function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
);
}
+function areReplacementResultStatesEqual(
+ left: AgentGenerationProjectReplacementResultState | undefined | null,
+ right: AgentGenerationProjectReplacementResultState | undefined | null
+) {
+ return (
+ JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
+ JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
+ );
+}
+
+function getReplacementResultUrlCount(
+ state: AgentGenerationProjectPersistedState | undefined | null
+) {
+ const normalized = normalizeAgentGenerationProjectReplacementResultState(
+ state?.replacementResultState
+ );
+ return (
+ Object.values(normalized.detail_replacement || {}).filter((record) =>
+ Boolean(record.url)
+ ).length +
+ Object.values(normalized.sku_replacement || {}).filter((record) =>
+ Boolean(record.url)
+ ).length
+ );
+}
+
+function hasReplacementResultCollectionMismatch(
+ state: AgentGenerationProjectPersistedState | undefined | null
+) {
+ const normalized = normalizeAgentGenerationProjectReplacementResultState(
+ state?.replacementResultState
+ );
+ const resultCollections = state?.resultCollections || {};
+ const countModeRecords = (
+ modeType: "detail_replacement" | "sku_replacement"
+ ) =>
+ Object.values(normalized[modeType] || {}).filter((record) =>
+ Boolean(record.url)
+ ).length;
+ const countModeCollection = (
+ modeType: "detail_replacement" | "sku_replacement"
+ ) => normalizeResultCollectionUrls(resultCollections[modeType]).length;
+
+ return (["detail_replacement", "sku_replacement"] as const).some(
+ (modeType) => {
+ const recordCount = countModeRecords(modeType);
+ return recordCount > 0 && countModeCollection(modeType) !== recordCount;
+ }
+ );
+}
+
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
function getAgentProjectDraftStorageKey(projectId: string) {
@@ -1990,7 +2038,8 @@ export default function CanvasEditorLegacyPage({
] = useState(false);
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
useState(false);
- const areSecondaryPanelsReady = true;
+ const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
+ useState(false);
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
useState(false);
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
@@ -2013,6 +2062,7 @@ export default function CanvasEditorLegacyPage({
useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
+ const chatPanelCanvasIdRef = useRef(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
useState(0);
@@ -2036,6 +2086,16 @@ export default function CanvasEditorLegacyPage({
Partial>
>({});
+ useLayoutEffect(() => {
+ if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
+ return;
+ }
+
+ chatPanelCanvasIdRef.current = id;
+ setPanelMode("chat");
+ setIsPanelOpen(true);
+ }, [id, isVideoMode]);
+
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);
@@ -2081,6 +2141,10 @@ export default function CanvasEditorLegacyPage({
agentProjectDeletedResultCollections,
setAgentProjectDeletedResultCollections,
] = useState({});
+ const [
+ agentProjectReplacementResultState,
+ setAgentProjectReplacementResultState,
+ ] = useState({});
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
string | null
@@ -2171,6 +2235,9 @@ export default function CanvasEditorLegacyPage({
const [aspectRatio, setAspectRatio] = useState("auto");
const [imageCount, setImageCount] = useState(1);
const previousNodeIdsRef = useRef>(new Set());
+ const lastLocalCanvasMutationAtRef = useRef(0);
+ const externalCanvasConflictRef = useRef(false);
+ const externalCanvasConflictNotifiedRef = useRef(false);
const previousBrandForModelRef = useRef("");
const autoSelectedBrandModelBrandRef = useRef("");
const autoTitleAttemptedRef = useRef>(new Set());
@@ -3284,16 +3351,16 @@ export default function CanvasEditorLegacyPage({
}, [checkCanvasAuthSession]);
useEffect(() => {
- preloadCanvasChatPanel();
- preloadCanvasChatData(id);
-
- return scheduleCanvasIdleTask(() => {
- void import("@/components/canvas/CanvasFileListPanel");
- }, 700);
- }, [id]);
+ const timeoutId = window.setTimeout(() => {
+ setAreSecondaryPanelsReady(true);
+ }, 800);
+ return () => window.clearTimeout(timeoutId);
+ }, []);
const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => {
+ externalCanvasConflictRef.current = false;
+ externalCanvasConflictNotifiedRef.current = false;
useCanvasStore.getState().setProject(id, project.title);
const projectUpdatedAt = String(project.updatedAt || "");
useCanvasStore
@@ -3455,6 +3522,7 @@ export default function CanvasEditorLegacyPage({
setAgentProjectThumbnail(null);
setAgentProjectResultCollections({});
setAgentProjectDeletedResultCollections({});
+ setAgentProjectReplacementResultState({});
setLoadedAgentProjectId(null);
}
@@ -3946,8 +4014,15 @@ export default function CanvasEditorLegacyPage({
...resultCollections.sku_replacement,
])
),
+ replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
+ agentProjectReplacementResultState
+ ),
};
- }, [agentProjectDeletedResultCollections, agentProjectResultCollections]);
+ }, [
+ agentProjectDeletedResultCollections,
+ agentProjectReplacementResultState,
+ agentProjectResultCollections,
+ ]);
const buildPersistedAgentProjectState = useCallback(() => {
const persistedMaterials = appearanceRecognitionEnabled
@@ -4132,6 +4207,9 @@ export default function CanvasEditorLegacyPage({
setAgentProjectDeletedResultCollections(
persistedState.deletedResultCollections || {}
);
+ setAgentProjectReplacementResultState(
+ persistedState.replacementResultState || {}
+ );
setLoadedAgentProjectId(params.projectId);
},
[]
@@ -4306,7 +4384,9 @@ export default function CanvasEditorLegacyPage({
const result = await agentProjectService.updateProject(agentProjectId, {
title: nextTitle,
thumbnail: agentProjectThumbnail,
- projectState: buildPersistedAgentProjectState(),
+ projectState:
+ persistedAgentProjectStateRef.current ||
+ buildPersistedAgentProjectState(),
});
if (!result.success) {
@@ -4372,9 +4452,27 @@ export default function CanvasEditorLegacyPage({
const serverUpdatedAt =
Number(new Date(result.data.updatedAt).getTime()) || 0;
+ const serverReplacementResultCount = getReplacementResultUrlCount(
+ result.data.projectState
+ );
+ const localReplacementResultCount = getReplacementResultUrlCount(
+ localDraft?.projectState
+ );
+ const localDraftMissesServerReplacementResults =
+ serverReplacementResultCount > 0 && localReplacementResultCount === 0;
+ const localDraftReplacementResultsDifferFromServer =
+ serverReplacementResultCount > 0 &&
+ localReplacementResultCount > 0 &&
+ localReplacementResultCount !== serverReplacementResultCount;
+ const localDraftHasStaleReplacementCollections =
+ serverReplacementResultCount > 0 &&
+ hasReplacementResultCollectionMismatch(localDraft?.projectState);
const shouldUseLocalDraft =
Boolean(localDraft?.projectState) &&
- Number(localDraft?.savedAt || 0) > serverUpdatedAt;
+ Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
+ !localDraftMissesServerReplacementResults &&
+ !localDraftReplacementResultsDifferFromServer &&
+ !localDraftHasStaleReplacementCollections;
if (shouldUseLocalDraft && localDraft?.projectState) {
hydrateAgentProjectSnapshot({
@@ -4476,6 +4574,7 @@ export default function CanvasEditorLegacyPage({
}, [
agentModeState,
agentProjectId,
+ agentProjectReplacementResultState,
agentProjectResultCollections,
agentProjectThumbnail,
agentProjectTitle,
@@ -4766,36 +4865,70 @@ export default function CanvasEditorLegacyPage({
const handleAgentResultPreviewUrlsChange = useCallback(
(modeType: AgentGenerationResultModeType, urls: string[]) => {
const normalizedUrls = normalizeResultCollectionUrls(urls);
- setAgentProjectResultCollections((current) => {
- const nextCollections: AgentGenerationProjectResultCollections = {
- ...current,
- [modeType]: normalizedUrls,
- };
-
- if (areResultCollectionsEqual(current, nextCollections)) {
- return current;
- }
+ const currentProjectState =
+ persistedAgentProjectStateRef.current ||
+ createEmptyAgentGenerationProjectPersistedState();
+ const currentCollections = currentProjectState.resultCollections || {};
+ const nextCollections: AgentGenerationProjectResultCollections = {
+ ...currentCollections,
+ [modeType]: normalizedUrls,
+ };
+ if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
const nextProjectState: AgentGenerationProjectPersistedState = {
- ...persistedAgentProjectStateRef.current,
+ ...currentProjectState,
resultCollections: nextCollections,
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
};
persistedAgentProjectStateRef.current = nextProjectState;
writeAgentProjectDraftSnapshot(nextProjectState);
+ }
- setAgentProjectThumbnail((currentThumbnail) => {
- const nextThumbnail =
- normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
- return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
- });
-
- return nextCollections;
+ setAgentProjectResultCollections((current) =>
+ areResultCollectionsEqual(current, nextCollections)
+ ? current
+ : nextCollections
+ );
+ setAgentProjectThumbnail((currentThumbnail) => {
+ const nextThumbnail =
+ normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
+ return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
});
},
[writeAgentProjectDraftSnapshot]
);
+ const handleAgentReplacementResultStateChange = useCallback(
+ (nextState: AgentGenerationProjectReplacementResultState) => {
+ const normalizedState =
+ normalizeAgentGenerationProjectReplacementResultState(nextState);
+
+ const currentProjectState =
+ persistedAgentProjectStateRef.current ||
+ createEmptyAgentGenerationProjectPersistedState();
+ if (
+ !areReplacementResultStatesEqual(
+ currentProjectState.replacementResultState,
+ normalizedState
+ )
+ ) {
+ const nextProjectState: AgentGenerationProjectPersistedState = {
+ ...currentProjectState,
+ replacementResultState: normalizedState,
+ };
+ persistedAgentProjectStateRef.current = nextProjectState;
+ writeAgentProjectDraftSnapshot(nextProjectState);
+ }
+
+ setAgentProjectReplacementResultState((current) =>
+ areReplacementResultStatesEqual(current, normalizedState)
+ ? current
+ : normalizedState
+ );
+ },
+ [writeAgentProjectDraftSnapshot]
+ );
+
const handleAgentResultImageDelete = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
@@ -4829,6 +4962,33 @@ export default function CanvasEditorLegacyPage({
? current
: nextDeletedCollections
);
+ if (modeType === "detail_replacement" || modeType === "sku_replacement") {
+ setAgentProjectReplacementResultState((current) => {
+ const normalizedState =
+ normalizeAgentGenerationProjectReplacementResultState(current);
+ const currentModeState = normalizedState[modeType] || {};
+ const nextModeState = Object.fromEntries(
+ Object.entries(currentModeState).filter(
+ ([, record]) => record.url !== normalizedUrl
+ )
+ );
+ const nextState: AgentGenerationProjectReplacementResultState = {
+ ...normalizedState,
+ [modeType]: nextModeState,
+ };
+ if (areReplacementResultStatesEqual(normalizedState, nextState)) {
+ return current;
+ }
+
+ const nextProjectState: AgentGenerationProjectPersistedState = {
+ ...persistedAgentProjectStateRef.current,
+ replacementResultState: nextState,
+ };
+ persistedAgentProjectStateRef.current = nextProjectState;
+ writeAgentProjectDraftSnapshot(nextProjectState);
+ return nextState;
+ });
+ }
setAgentProjectThumbnail((current) => {
const nextThumbnail =
current === normalizedUrl
@@ -4837,7 +4997,11 @@ export default function CanvasEditorLegacyPage({
return current === nextThumbnail ? current : nextThumbnail;
});
},
- [agentProjectDeletedResultCollections, agentProjectResultCollections]
+ [
+ agentProjectDeletedResultCollections,
+ agentProjectResultCollections,
+ writeAgentProjectDraftSnapshot,
+ ]
);
useEffect(() => {
@@ -4910,12 +5074,19 @@ export default function CanvasEditorLegacyPage({
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
+ externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
+ if (!externalCanvasConflictNotifiedRef.current) {
+ externalCanvasConflictNotifiedRef.current = true;
+ toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
+ }
}
} else if (result.data?.updatedAt) {
+ externalCanvasConflictRef.current = false;
+ externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
@@ -4942,6 +5113,70 @@ export default function CanvasEditorLegacyPage({
return () => clearTimeout(saveTimer);
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
+ useEffect(() => {
+ if (isLoading || isAgentOperationRefreshActive()) return;
+ lastLocalCanvasMutationAtRef.current = Date.now();
+ }, [isLoading, isAgentOperationRefreshActive, nodes]);
+
+ useEffect(() => {
+ if (isLoading || isAgentMode) return;
+
+ let cancelled = false;
+ const refreshIfExternallyUpdated = async () => {
+ if (
+ cancelled ||
+ document.visibilityState !== "visible" ||
+ isSaving ||
+ externalCanvasConflictRef.current ||
+ Date.now() - lastLocalCanvasMutationAtRef.current < 5000
+ ) {
+ return;
+ }
+
+ try {
+ const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
+ cache: "no-store",
+ });
+ const revisionPayload = await revisionResponse.json().catch(() => ({}));
+ const remoteRevision = String(
+ revisionPayload?.data?.updatedAt || ""
+ ).trim();
+ const localRevision = String(
+ useCanvasStore.getState().canvasRevision || ""
+ ).trim();
+ if (
+ cancelled ||
+ !revisionResponse.ok ||
+ !remoteRevision ||
+ remoteRevision === localRevision
+ ) {
+ return;
+ }
+
+ const result = await canvasService.getProject(id);
+ if (cancelled || !result.success || !result.data) return;
+ useCanvasStore.getState().markAgentOperationRefresh();
+ hydrateCanvasProject(result.data);
+ lastLocalCanvasMutationAtRef.current = Date.now();
+ toast.success("已同步 OpenClaw 的画布操作");
+ } catch (error) {
+ console.error("[Canvas] external operation refresh failed:", error);
+ }
+ };
+
+ const intervalId = window.setInterval(() => {
+ void refreshIfExternallyUpdated();
+ }, 4000);
+ const handleFocus = () => void refreshIfExternallyUpdated();
+ window.addEventListener("focus", handleFocus);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(intervalId);
+ window.removeEventListener("focus", handleFocus);
+ };
+ }, [hydrateCanvasProject, id, isAgentMode, isLoading, isSaving]);
+
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
useEffect(() => {
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
@@ -6740,6 +6975,7 @@ export default function CanvasEditorLegacyPage({
deletedResultCollections:
existingPersistedState.deletedResultCollections || {},
resultImageUrls: existingPersistedState.resultImageUrls || [],
+ replacementResultState: existingPersistedState.replacementResultState || {},
};
const payload = {
@@ -10026,10 +10262,13 @@ export default function CanvasEditorLegacyPage({
mode: null,
});
}}
+ onPersistProject={() => persistAgentProject("manual")}
+ onReplacementResultStateChange={handleAgentReplacementResultStateChange}
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
onWorkspaceStateChange={setAgentWorkspaceState}
projectId={agentProjectId}
key={agentProjectId}
+ replacementResultState={agentProjectReplacementResultState}
resultCollections={agentProjectResultCollections}
selectedAgentPresetId={
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
@@ -10782,7 +11021,7 @@ export default function CanvasEditorLegacyPage({
>
@@ -10803,7 +11042,7 @@ export default function CanvasEditorLegacyPage({
"absolute z-10 ease-in-out",
isFloatingPanelResizing
? "transition-none"
- : "transition-all duration-150",
+ : "transition-all duration-300",
isPanelOpen
? "translate-x-0 opacity-100"
: "pointer-events-none translate-x-[120%] opacity-0"
@@ -10842,7 +11081,7 @@ export default function CanvasEditorLegacyPage({