Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge
This commit is contained in:
commit
2398930ecb
@ -501,11 +501,6 @@ export default function Page() {
|
||||
const displayLogo = mounted
|
||||
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||||
: siteLogo;
|
||||
const shouldInvertLogo =
|
||||
mounted &&
|
||||
resolvedTheme === "dark" &&
|
||||
!siteLogoDark?.trim() &&
|
||||
Boolean(siteLogo?.trim());
|
||||
const hasThirdPartyLogin =
|
||||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||
(isGoogleEnabled && isGoogleConfigured);
|
||||
@ -545,7 +540,7 @@ export default function Page() {
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||||
className="mb-2 h-12 w-auto object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
||||
|
||||
@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string {
|
||||
.trim();
|
||||
|
||||
const knownTitles = [
|
||||
"意图分析",
|
||||
"任务规划",
|
||||
"需求理解",
|
||||
"图片分析",
|
||||
"执行计划",
|
||||
|
||||
@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
|
||||
: "基于当前上传图片处理。";
|
||||
|
||||
return [
|
||||
"### 需求理解",
|
||||
"### 意图分析",
|
||||
`- ${demand}`,
|
||||
"### 图片分析",
|
||||
params.imageSummary
|
||||
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
||||
: `- ${sourceLine}`,
|
||||
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
||||
"### 执行计划",
|
||||
"### 任务规划",
|
||||
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
||||
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
||||
"### 输出设置",
|
||||
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
|
||||
}
|
||||
|
||||
return [
|
||||
"### 需求理解",
|
||||
"### 意图分析",
|
||||
`- ${demand}`,
|
||||
"### 执行计划",
|
||||
"### 任务规划",
|
||||
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
||||
"### 输出设置",
|
||||
"- 默认单图输出,优先稳定命中。",
|
||||
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
|
||||
|
||||
export function hasStructuredAgentSections(message?: string): boolean {
|
||||
if (!message) return false;
|
||||
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
|
||||
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (fallback.includes("### 任务规划\n")) {
|
||||
return fallback.replace(
|
||||
"### 任务规划\n",
|
||||
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
return fallback.replace(
|
||||
"### 执行计划\n",
|
||||
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
||||
|
||||
@ -239,6 +239,8 @@ import {
|
||||
safePersistDirectAgentVideoFailedTurn,
|
||||
} from "@/lib/ai/runtime/direct-agent-working-state";
|
||||
import {
|
||||
getDirectAgentToolDisplayName,
|
||||
getDirectAgentToolPrefaceFallback,
|
||||
getDirectAgentToolErrorMessage,
|
||||
getDirectAgentToolLabel,
|
||||
getDirectAgentToolTargetMissingMessage,
|
||||
@ -545,7 +547,7 @@ function writeCanvasOperationShadowChunk(params: {
|
||||
}
|
||||
|
||||
const AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN =
|
||||
/(?:^|\n)\s*(?:#{1,6}\s*)?(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/;
|
||||
/(?:^|\n)\s*(?:#{1,6}\s*)?(?:意图分析|任务规划|需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:意图分析|任务规划|需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/;
|
||||
|
||||
function buildAgentExecutionPrefaceText(params: {
|
||||
friendlyMessage?: string | null;
|
||||
@ -8296,6 +8298,9 @@ export async function POST(request: Request) {
|
||||
|
||||
const assistantMessageId = generateUUID();
|
||||
const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability);
|
||||
const directToolDisplayName = getDirectAgentToolDisplayName(
|
||||
directAgentToolCapability,
|
||||
);
|
||||
const directToolStream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
await writeAgentExecutionPrefaceMessage({
|
||||
@ -8303,7 +8308,7 @@ export async function POST(request: Request) {
|
||||
chatId: id,
|
||||
label: `agent:${directAgentToolCapability}:preface`,
|
||||
friendlyMessage: smartAutoFriendlyMessage,
|
||||
fallback: `好的,我来帮你${directToolLabel},稍等我处理一下。`,
|
||||
fallback: getDirectAgentToolPrefaceFallback(directAgentToolCapability),
|
||||
});
|
||||
writeAgentProgressChunk(
|
||||
writer,
|
||||
@ -8318,7 +8323,7 @@ export async function POST(request: Request) {
|
||||
buildDirectAgentImageEditSelectProgress({
|
||||
toolName: directAgentExecution.toolName,
|
||||
capability: directAgentToolCapability,
|
||||
label: directToolLabel,
|
||||
label: directToolDisplayName,
|
||||
}),
|
||||
`agent:${directAgentToolCapability}:progress-select-tool`,
|
||||
);
|
||||
@ -8331,7 +8336,7 @@ export async function POST(request: Request) {
|
||||
buildDirectAgentImageEditExecutingProgress({
|
||||
toolName: directAgentExecution.toolName,
|
||||
capability: directAgentToolCapability,
|
||||
label: directToolLabel,
|
||||
label: directToolDisplayName,
|
||||
}),
|
||||
`agent:${directAgentToolCapability}:progress-executing`,
|
||||
);
|
||||
@ -8480,7 +8485,7 @@ export async function POST(request: Request) {
|
||||
buildDirectAgentImageEditCompletedProgress({
|
||||
toolName: directAgentExecution.toolName,
|
||||
capability: directAgentToolCapability,
|
||||
label: directToolLabel,
|
||||
label: directToolDisplayName,
|
||||
}),
|
||||
`agent:${directAgentToolCapability}:progress-completed`,
|
||||
);
|
||||
|
||||
@ -29,6 +29,10 @@ const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
||||
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
return Date.now() - startedAt;
|
||||
}
|
||||
|
||||
const allowedDirectUploadTypes = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
@ -141,6 +145,7 @@ async function createPartUrls(params: {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestStartedAt = Date.now();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@ -159,6 +164,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("direct_upload_request_start", {
|
||||
action: payload.action,
|
||||
size: "size" in payload ? payload.size : null,
|
||||
contentType: "contentType" in payload ? payload.contentType : null,
|
||||
purpose: "purpose" in payload ? payload.purpose || null : null,
|
||||
clientUploadId:
|
||||
"clientUploadId" in payload ? payload.clientUploadId || null : null,
|
||||
});
|
||||
|
||||
const resolveStartedAt = Date.now();
|
||||
const target = await resolveS3UploadTarget().catch((error) => {
|
||||
logger.error("resolve_storage_target_failed", error);
|
||||
return null;
|
||||
@ -170,9 +185,17 @@ export async function POST(request: Request) {
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
logger.info("direct_upload_storage_target_resolved", {
|
||||
action: payload.action,
|
||||
elapsedMs: elapsedMs(resolveStartedAt),
|
||||
provider: target.config.provider || "s3",
|
||||
bucket: target.config.bucket,
|
||||
hasPublicUrl: Boolean(target.config.publicUrl),
|
||||
});
|
||||
|
||||
const client = createS3Client(target.config);
|
||||
const bucket = target.config.bucket;
|
||||
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
|
||||
|
||||
try {
|
||||
if (payload.action === "initiate") {
|
||||
@ -202,38 +225,51 @@ export async function POST(request: Request) {
|
||||
);
|
||||
|
||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
||||
const presignStartedAt = Date.now();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
ACL: "public-read",
|
||||
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||
});
|
||||
const url = await getSignedUrl(client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
});
|
||||
logger.info("direct_upload_single_presigned", {
|
||||
elapsedMs: elapsedMs(presignStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
};
|
||||
if (usePublicReadAcl) {
|
||||
headers["x-amz-acl"] = "public-read";
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
mode: "single",
|
||||
key,
|
||||
url,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"x-amz-acl": "public-read",
|
||||
},
|
||||
headers,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
const multipartStartedAt = Date.now();
|
||||
const createResult = await client.send(
|
||||
new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
ACL: "public-read",
|
||||
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
@ -251,6 +287,15 @@ export async function POST(request: Request) {
|
||||
uploadId,
|
||||
partCount,
|
||||
});
|
||||
logger.info("direct_upload_multipart_prepared", {
|
||||
elapsedMs: elapsedMs(multipartStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
partCount,
|
||||
partSize,
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
mode: "multipart",
|
||||
@ -266,6 +311,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (payload.action === "abort") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const abortStartedAt = Date.now();
|
||||
await client.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
@ -273,6 +319,11 @@ export async function POST(request: Request) {
|
||||
UploadId: payload.uploadId,
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_multipart_aborted", {
|
||||
elapsedMs: elapsedMs(abortStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@ -286,6 +337,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (payload.uploadId) {
|
||||
const completeMultipartStartedAt = Date.now();
|
||||
const parts = [...(payload.parts || [])].sort(
|
||||
(a, b) => a.PartNumber - b.PartNumber
|
||||
);
|
||||
@ -306,14 +358,25 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_multipart_completed", {
|
||||
elapsedMs: elapsedMs(completeMultipartStartedAt),
|
||||
partCount: parts.length,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
const headStartedAt = Date.now();
|
||||
const head = await client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
logger.info("direct_upload_head_object_completed", {
|
||||
elapsedMs: elapsedMs(headStartedAt),
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
const actualSize =
|
||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||
@ -324,6 +387,7 @@ export async function POST(request: Request) {
|
||||
const url = buildS3PublicUrl(target.config, key);
|
||||
const resolvedContentType =
|
||||
head.ContentType || contentType || getContentType(payload.filename);
|
||||
const assetStartedAt = Date.now();
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
@ -344,6 +408,13 @@ export async function POST(request: Request) {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
},
|
||||
});
|
||||
logger.info("direct_upload_asset_registered", {
|
||||
elapsedMs: elapsedMs(assetStartedAt),
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
assetId: asset?.id ?? null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
await maybeEnqueueStorageMigrationTask({
|
||||
uploaded: { url, pathname: key },
|
||||
|
||||
40
app/api/canvas/clawd-chat/cancel/route.ts
Normal file
40
app/api/canvas/clawd-chat/cancel/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById } from "@/lib/db/queries";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
cancelClawdRequest,
|
||||
} from "@/lib/openclaw-chat/cancel-registry";
|
||||
|
||||
const cancelSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userMessageId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||
if (existingChat && existingChat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const active = cancelClawdRequest(
|
||||
buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ canceled: true, active });
|
||||
}
|
||||
@ -13,6 +13,14 @@ 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 {
|
||||
buildOpenClawDisplayMessage,
|
||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||
@ -141,6 +149,10 @@ function getMetadataString(
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function createCanceledResponse() {
|
||||
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
@ -172,6 +184,16 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const userMessageId = parsed.userMessageId || generateUUID();
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
let spreadsheetMarkdownResult: Awaited<
|
||||
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
||||
> | null = null;
|
||||
@ -200,6 +222,10 @@ export async function POST(request: Request) {
|
||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||
});
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
if (!existingChat) {
|
||||
await saveChat({
|
||||
id: parsed.id,
|
||||
@ -209,7 +235,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const userMessageId = parsed.userMessageId || generateUUID();
|
||||
const assistantMessageId = generateUUID();
|
||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
||||
@ -226,9 +251,18 @@ export async function POST(request: Request) {
|
||||
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: {
|
||||
@ -266,7 +300,11 @@ export async function POST(request: Request) {
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
});
|
||||
|
||||
upstreamStatus = response.status;
|
||||
upstreamPayload = await response.json().catch(() => null);
|
||||
@ -287,8 +325,19 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const isAbortError =
|
||||
error instanceof Error && error.name === "AbortError";
|
||||
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(
|
||||
{
|
||||
@ -311,6 +360,10 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||
() => []
|
||||
);
|
||||
@ -366,6 +419,7 @@ export async function POST(request: Request) {
|
||||
};
|
||||
|
||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||
clearClawdRequestCancellation(cancelKey);
|
||||
|
||||
return NextResponse.json({
|
||||
conversationId,
|
||||
|
||||
@ -8,7 +8,6 @@ export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const DEFAULT_CONTENT_TYPE = "image/x-icon";
|
||||
const DARK_FAVICON_CONTENT_TYPE = "image/png";
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
function noStoreHeaders(contentType: string) {
|
||||
@ -71,86 +70,16 @@ function uniqueNonEmptyValues(values: string[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readLocalFallbackFavicon(preferDark = false) {
|
||||
async function readLocalFallbackFavicon() {
|
||||
const file = await readFile(
|
||||
join(process.cwd(), "public", "default-favicon.ico")
|
||||
);
|
||||
if (preferDark) {
|
||||
const transformed = await transformFaviconForDark(file).catch(() => null);
|
||||
const fallback =
|
||||
transformed || (await renderDarkFallbackFavicon().catch(() => null));
|
||||
if (fallback) {
|
||||
return new NextResponse(fallback, {
|
||||
status: 200,
|
||||
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
|
||||
});
|
||||
}
|
||||
}
|
||||
return new NextResponse(file, {
|
||||
status: 200,
|
||||
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
|
||||
});
|
||||
}
|
||||
|
||||
async function renderDarkFallbackFavicon() {
|
||||
const sharp = (await import("sharp")).default;
|
||||
const svg = `\
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" fill="none"/>
|
||||
<path d="M15 9h34v12H29v13h20v12H29v9l-14-11V9z" fill="#fff"/>
|
||||
<path d="M37 25h12v30L37 45V25z" fill="#fff" opacity=".92"/>
|
||||
</svg>`;
|
||||
return await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
async function transformFaviconForDark(buffer: Buffer | ArrayBuffer) {
|
||||
const sharp = (await import("sharp")).default;
|
||||
const inputBuffer = Buffer.isBuffer(buffer)
|
||||
? buffer
|
||||
: Buffer.from(new Uint8Array(buffer));
|
||||
const createBaseImage = () =>
|
||||
sharp(inputBuffer, {
|
||||
animated: false,
|
||||
failOn: "none",
|
||||
}).resize({
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: "contain",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
});
|
||||
|
||||
const { data, info } = await createBaseImage()
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let visiblePixelCount = 0;
|
||||
let luminanceTotal = 0;
|
||||
|
||||
for (let index = 0; index < data.length; index += info.channels) {
|
||||
const alpha = data[index + 3] ?? 255;
|
||||
if (alpha < 24) continue;
|
||||
|
||||
const red = data[index] ?? 0;
|
||||
const green = data[index + 1] ?? 0;
|
||||
const blue = data[index + 2] ?? 0;
|
||||
luminanceTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
visiblePixelCount += 1;
|
||||
}
|
||||
|
||||
const averageLuminance =
|
||||
visiblePixelCount > 0 ? luminanceTotal / visiblePixelCount : 0;
|
||||
const needsInvertForDark = averageLuminance < 150;
|
||||
const output = createBaseImage();
|
||||
|
||||
if (needsInvertForDark) {
|
||||
output.negate({ alpha: false });
|
||||
}
|
||||
|
||||
return await output
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
@ -159,20 +88,14 @@ export async function GET(request: Request) {
|
||||
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||
const configuredLightLogo = String(settings?.siteLogo || "").trim();
|
||||
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
|
||||
const rawFaviconCandidates = preferDark
|
||||
? uniqueNonEmptyValues([
|
||||
configuredFavicon,
|
||||
configuredDarkLogo,
|
||||
configuredLightLogo,
|
||||
])
|
||||
: uniqueNonEmptyValues([
|
||||
configuredFavicon,
|
||||
configuredLightLogo,
|
||||
configuredDarkLogo,
|
||||
]);
|
||||
const rawFaviconCandidates = configuredFavicon
|
||||
? [configuredFavicon]
|
||||
: preferDark
|
||||
? uniqueNonEmptyValues([configuredDarkLogo, configuredLightLogo])
|
||||
: uniqueNonEmptyValues([configuredLightLogo, configuredDarkLogo]);
|
||||
|
||||
if (rawFaviconCandidates.length === 0) {
|
||||
return await readLocalFallbackFavicon(preferDark);
|
||||
return await readLocalFallbackFavicon();
|
||||
}
|
||||
|
||||
for (const rawFaviconUrl of rawFaviconCandidates) {
|
||||
@ -220,42 +143,15 @@ export async function GET(request: Request) {
|
||||
const contentType =
|
||||
upstream.headers.get("content-type") || inferContentType(faviconUrl);
|
||||
|
||||
if (preferDark) {
|
||||
const transformed = await transformFaviconForDark(buffer).catch((error) => {
|
||||
console.error("[favicon] Failed to transform dark favicon candidate:", {
|
||||
targetUrl,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!transformed) continue;
|
||||
|
||||
return new NextResponse(transformed, {
|
||||
status: 200,
|
||||
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: noStoreHeaders(contentType),
|
||||
});
|
||||
}
|
||||
|
||||
if (preferDark) {
|
||||
const fallback = await renderDarkFallbackFavicon().catch(() => null);
|
||||
if (fallback) {
|
||||
return new NextResponse(fallback, {
|
||||
status: 200,
|
||||
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await readLocalFallbackFavicon(preferDark);
|
||||
return await readLocalFallbackFavicon();
|
||||
} catch (error) {
|
||||
console.error("[favicon] Failed to proxy favicon:", error);
|
||||
const preferDark = new URL(request.url).searchParams.get("theme") === "dark";
|
||||
return await readLocalFallbackFavicon(preferDark);
|
||||
return await readLocalFallbackFavicon();
|
||||
}
|
||||
}
|
||||
|
||||
@ -9640,10 +9640,6 @@ export default function CanvasEditorLegacyPage({
|
||||
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
||||
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
||||
).trim();
|
||||
const shouldInvertCanvasBrandLogo =
|
||||
isDarkCanvasTheme &&
|
||||
Boolean(canvasBrandLogo) &&
|
||||
!hasSeparateDarkCanvasBrandLogo;
|
||||
|
||||
const brandEntry = (
|
||||
<div
|
||||
@ -9658,11 +9654,7 @@ export default function CanvasEditorLegacyPage({
|
||||
{canvasBrandLogo ? (
|
||||
<img
|
||||
alt={siteSettingsData?.siteName || "返回首页"}
|
||||
className={cn(
|
||||
"h-7 w-7 object-contain",
|
||||
shouldInvertCanvasBrandLogo && "brightness-0 invert",
|
||||
!hasSeparateDarkCanvasBrandLogo && "dark:brightness-0 dark:invert"
|
||||
)}
|
||||
className="h-7 w-7 object-contain"
|
||||
draggable={false}
|
||||
src={canvasBrandLogo}
|
||||
/>
|
||||
|
||||
@ -24,19 +24,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
"AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: "/api/favicon?theme=dark",
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
sizes: "any",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/api/favicon",
|
||||
media: "(prefers-color-scheme: light)",
|
||||
sizes: "any",
|
||||
},
|
||||
{ url: "/api/favicon?theme=dark", sizes: "any", type: "image/png" },
|
||||
{ url: "/api/favicon", type: "image/x-icon" },
|
||||
],
|
||||
apple: "/api/favicon",
|
||||
shortcut: "/api/favicon",
|
||||
|
||||
@ -53,7 +53,6 @@ export function AgentCanvasTopNavDark({
|
||||
}: AgentCanvasTopNavDarkProps) {
|
||||
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
|
||||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||||
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
|
||||
@ -80,10 +79,7 @@ export function AgentCanvasTopNavDark({
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 object-contain",
|
||||
shouldInvertLogo && "brightness-0 invert"
|
||||
)}
|
||||
className="h-8 w-8 shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6695,17 +6695,13 @@ function StylizedBrandLogo({
|
||||
}) {
|
||||
const resolvedSiteName = resolveSiteName(siteName);
|
||||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||||
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{displayLogo ? (
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 object-contain",
|
||||
shouldInvertLogo && "brightness-0 invert"
|
||||
)}
|
||||
className="h-8 w-8 shrink-0 object-contain"
|
||||
src={displayLogo}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -33,9 +33,7 @@ export function CanvasLogoLoadingView({
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-[34px] w-[34px] object-contain",
|
||||
hasSeparateDarkLogo
|
||||
? "dark:hidden"
|
||||
: "dark:brightness-0 dark:invert"
|
||||
hasSeparateDarkLogo && "dark:hidden"
|
||||
)}
|
||||
src={lightLogo}
|
||||
/>
|
||||
|
||||
@ -10193,7 +10193,7 @@ export function ImageNode({
|
||||
createPortal(
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-slate-200 bg-white px-2 py-1.5 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_16px_44px_rgba(0,0,0,0.45)]"
|
||||
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
|
||||
@ -47,6 +47,8 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||
|
||||
const IMAGE_ANALYSIS_TITLE = "图片分析";
|
||||
const AGENT_SECTION_TITLES = new Set([
|
||||
"意图分析",
|
||||
"任务规划",
|
||||
"需求理解",
|
||||
IMAGE_ANALYSIS_TITLE,
|
||||
"执行计划",
|
||||
@ -903,11 +905,17 @@ export const Response = ({
|
||||
),
|
||||
};
|
||||
|
||||
const renderImagePreview = (src: string, alt: string | undefined, key: string) => (
|
||||
const renderImagePreview = (
|
||||
src: string,
|
||||
alt: string | undefined,
|
||||
key: string,
|
||||
compactGrid = false
|
||||
) => (
|
||||
<ImagePreview
|
||||
key={key}
|
||||
src={src}
|
||||
alt={alt}
|
||||
compactGrid={compactGrid}
|
||||
onImageEdit={onImageEdit}
|
||||
onDrawingComplete={onDrawingComplete}
|
||||
onImageEnhance={onImageEnhance}
|
||||
@ -923,6 +931,49 @@ export const Response = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const renderImageGrid = (
|
||||
images: Array<{ alt?: string; src: string }>,
|
||||
keyPrefix: string
|
||||
) => {
|
||||
if (images.length === 0) return null;
|
||||
if (images.length === 1) {
|
||||
const image = images[0];
|
||||
return renderImagePreview(
|
||||
image.src,
|
||||
image.alt,
|
||||
`${image.src}-${processingKey}-${keyPrefix}-single`
|
||||
);
|
||||
}
|
||||
|
||||
const gridClassName =
|
||||
images.length === 2
|
||||
? "grid-cols-2 sm:max-w-[640px]"
|
||||
: images.length === 3
|
||||
? "grid-cols-2 sm:grid-cols-3 sm:max-w-[780px]"
|
||||
: "grid-cols-2 sm:grid-cols-4 sm:max-w-[920px]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose my-3 grid w-full gap-1.5 overflow-hidden rounded-lg",
|
||||
gridClassName
|
||||
)}
|
||||
key={`${keyPrefix}-image-grid`}
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<div className="min-w-0" key={`${image.src}-${index}`}>
|
||||
{renderImagePreview(
|
||||
image.src,
|
||||
image.alt,
|
||||
`${image.src}-${processingKey}-${keyPrefix}-grid-${index}`,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
|
||||
if (!text.trim()) return null;
|
||||
|
||||
@ -935,20 +986,31 @@ export const Response = ({
|
||||
const hasImageSegments = segments.some((segment) => segment.type === "image");
|
||||
|
||||
if (hasImageSegments) {
|
||||
return segments.map((segment, index) => {
|
||||
const nodes: ReactNode[] = [];
|
||||
let pendingImages: Array<{ alt?: string; src: string }> = [];
|
||||
const flushImages = (index: number) => {
|
||||
if (pendingImages.length === 0) return;
|
||||
nodes.push(
|
||||
renderImageGrid(pendingImages, `${keyPrefix}-images-${index}`)
|
||||
);
|
||||
pendingImages = [];
|
||||
};
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment.type === "image") {
|
||||
return renderImagePreview(
|
||||
segment.src,
|
||||
segment.alt,
|
||||
`${segment.src}-${processingKey}-${keyPrefix}-${index}`
|
||||
);
|
||||
pendingImages.push({
|
||||
alt: segment.alt,
|
||||
src: segment.src,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!segment.value.trim()) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
flushImages(index);
|
||||
nodes.push(
|
||||
<Streamdown
|
||||
className={streamdownClassName}
|
||||
components={streamdownComponents}
|
||||
@ -958,6 +1020,8 @@ export const Response = ({
|
||||
</Streamdown>
|
||||
);
|
||||
});
|
||||
flushImages(segments.length);
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,24 +5,12 @@ import { useEffect } from "react";
|
||||
export function FaviconUpdater() {
|
||||
useEffect(() => {
|
||||
let isDisposed = false;
|
||||
const mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
|
||||
const getFaviconUrl = () => {
|
||||
const html = document.documentElement;
|
||||
const hasDarkClass = html.classList.contains("dark");
|
||||
const hasLightClass = html.classList.contains("light");
|
||||
const prefersDark = Boolean(mediaQuery?.matches);
|
||||
const isDark = hasDarkClass || (!hasLightClass && prefersDark);
|
||||
return isDark ? "/api/favicon?theme=dark" : "/api/favicon";
|
||||
};
|
||||
const getFaviconUrl = () => "/api/favicon";
|
||||
|
||||
const updateFavicon = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
const urlWithTheme = getFaviconUrl();
|
||||
const iconType = urlWithTheme.includes("theme=dark")
|
||||
? "image/png"
|
||||
: "image/x-icon";
|
||||
const href =
|
||||
`${urlWithTheme}${urlWithTheme.includes("?") ? "&" : "?"}v=${Date.now()}`;
|
||||
const managedSelector =
|
||||
@ -35,12 +23,12 @@ export function FaviconUpdater() {
|
||||
link.href = href;
|
||||
link.setAttribute("data-managed-favicon", "true");
|
||||
if (link.rel === "icon" || link.rel === "shortcut icon") {
|
||||
link.type = iconType;
|
||||
link.removeAttribute("type");
|
||||
link.setAttribute("sizes", "any");
|
||||
}
|
||||
});
|
||||
|
||||
const ensureLink = (rel: string, type?: string) => {
|
||||
const ensureLink = (rel: string) => {
|
||||
let link = document.querySelector(
|
||||
`link[rel="${rel}"]`
|
||||
) as HTMLLinkElement | null;
|
||||
@ -51,17 +39,15 @@ export function FaviconUpdater() {
|
||||
}
|
||||
link.href = href;
|
||||
link.setAttribute("data-managed-favicon", "true");
|
||||
if (type) {
|
||||
link.type = type;
|
||||
}
|
||||
link.removeAttribute("type");
|
||||
if (rel === "icon") {
|
||||
link.setAttribute("sizes", "any");
|
||||
}
|
||||
};
|
||||
|
||||
ensureLink("icon", iconType);
|
||||
ensureLink("icon");
|
||||
ensureLink("apple-touch-icon");
|
||||
ensureLink("shortcut icon", iconType);
|
||||
ensureLink("shortcut icon");
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(updateFavicon);
|
||||
@ -69,8 +55,6 @@ export function FaviconUpdater() {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
mediaQuery?.addEventListener?.("change", updateFavicon);
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
updateFavicon();
|
||||
});
|
||||
@ -78,7 +62,6 @@ export function FaviconUpdater() {
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
observer.disconnect();
|
||||
mediaQuery?.removeEventListener?.("change", updateFavicon);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -677,8 +677,6 @@ export function LovartHome({
|
||||
resolvedTheme === "dark"
|
||||
? siteLogoDark || siteLogo || ""
|
||||
: siteLogo || siteLogoDark || "";
|
||||
const shouldInvertLogo =
|
||||
resolvedTheme === "dark" && !siteLogoDark && Boolean(siteLogo);
|
||||
|
||||
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
|
||||
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
|
||||
@ -1564,7 +1562,6 @@ export function LovartHome({
|
||||
contentClassName="pl-[98px]"
|
||||
logo={displayLogo}
|
||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
shouldInvertLogo={shouldInvertLogo}
|
||||
showBrand={false}
|
||||
siteName={siteName || "Lovart"}
|
||||
>
|
||||
@ -1814,7 +1811,6 @@ export function LovartHome({
|
||||
? "/settings/agent-workflow"
|
||||
: null
|
||||
}
|
||||
shouldInvertLogo={shouldInvertLogo}
|
||||
siteLogo={displayLogo}
|
||||
siteName={siteName || "Lovart"}
|
||||
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
||||
@ -1852,7 +1848,6 @@ export function LovartHome({
|
||||
prompt={prompt}
|
||||
removeFile={removeFile}
|
||||
setPrompt={setPrompt}
|
||||
shouldInvertLogo={shouldInvertLogo}
|
||||
siteName={siteName}
|
||||
selectedModelId={selectedComposerModel.id}
|
||||
/>
|
||||
@ -1990,7 +1985,7 @@ export function LovartHome({
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className={`h-7 w-7 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||||
className="h-7 w-7 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||||
|
||||
@ -47,7 +47,6 @@ type LovartHeroComposerProps = {
|
||||
prompt: string;
|
||||
removeFile: (id: string) => void;
|
||||
setPrompt: (value: string) => void;
|
||||
shouldInvertLogo: boolean;
|
||||
siteName: string;
|
||||
modelOptions: ModelOption[];
|
||||
onModelChange: (modelId: string) => void;
|
||||
@ -73,7 +72,6 @@ export function LovartHeroComposer({
|
||||
prompt,
|
||||
removeFile,
|
||||
setPrompt,
|
||||
shouldInvertLogo,
|
||||
siteName,
|
||||
modelOptions,
|
||||
onModelChange,
|
||||
@ -104,7 +102,7 @@ export function LovartHeroComposer({
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className={`h-10 w-10 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||||
|
||||
@ -42,7 +42,6 @@ type LovartSidebarProps = {
|
||||
}
|
||||
) => ReactNode;
|
||||
settingsHref: string | null;
|
||||
shouldInvertLogo?: boolean;
|
||||
siteLogo?: string | null;
|
||||
siteName: string;
|
||||
toolbox1Enabled: boolean;
|
||||
@ -71,7 +70,6 @@ export function LovartSidebar({
|
||||
productPlanningEnabled,
|
||||
renderIcon,
|
||||
settingsHref,
|
||||
shouldInvertLogo = false,
|
||||
siteLogo,
|
||||
siteName,
|
||||
toolbox1Enabled,
|
||||
@ -184,12 +182,7 @@ export function LovartSidebar({
|
||||
<img
|
||||
src={siteLogo}
|
||||
alt={siteName}
|
||||
className={[
|
||||
"h-8 w-8 rounded-full object-cover",
|
||||
shouldInvertLogo ? "brightness-0 invert" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-[13px] font-semibold text-white dark:bg-white dark:text-black">
|
||||
|
||||
@ -42,7 +42,8 @@ export function ImagePreview({
|
||||
isProcessingExtend,
|
||||
isProcessingAngleEdit,
|
||||
isProcessingWatermarkRemove,
|
||||
imageIndex
|
||||
imageIndex,
|
||||
compactGrid = false
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
@ -64,6 +65,7 @@ export function ImagePreview({
|
||||
isProcessingAngleEdit?: boolean;
|
||||
isProcessingWatermarkRemove?: boolean;
|
||||
imageIndex?: number;
|
||||
compactGrid?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||||
@ -293,16 +295,26 @@ export function ImagePreview({
|
||||
return (
|
||||
<>
|
||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||
<div className="group relative inline-block max-w-full mr-3 mb-3">
|
||||
<div
|
||||
className={
|
||||
compactGrid
|
||||
? "group relative block aspect-[3/4] w-full overflow-hidden rounded-lg"
|
||||
: "group relative inline-block max-w-full mr-3 mb-3"
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="Generated Image"
|
||||
className="cursor-pointer rounded-lg transition-opacity hover:opacity-90"
|
||||
className={
|
||||
compactGrid
|
||||
? "h-full w-full cursor-pointer rounded-lg object-cover transition-opacity hover:opacity-90"
|
||||
: "cursor-pointer rounded-lg transition-opacity hover:opacity-90"
|
||||
}
|
||||
style={{
|
||||
maxWidth: `${maxWidth}px`,
|
||||
height: "auto",
|
||||
maxWidth: compactGrid ? "none" : `${maxWidth}px`,
|
||||
height: compactGrid ? "100%" : "auto",
|
||||
width: "100%",
|
||||
objectFit: "contain"
|
||||
objectFit: compactGrid ? "cover" : "contain"
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@ -12,7 +12,6 @@ type BrandTopBarProps = {
|
||||
logo?: string | null;
|
||||
onLogoClick?: () => void;
|
||||
position?: "fixed" | "sticky" | "static";
|
||||
shouldInvertLogo?: boolean;
|
||||
showBrand?: boolean;
|
||||
siteName: string;
|
||||
};
|
||||
@ -25,7 +24,6 @@ export function BrandTopBar({
|
||||
logo,
|
||||
onLogoClick,
|
||||
position = "fixed",
|
||||
shouldInvertLogo = false,
|
||||
showBrand = true,
|
||||
siteName,
|
||||
}: BrandTopBarProps) {
|
||||
@ -61,10 +59,7 @@ export function BrandTopBar({
|
||||
{logo ? (
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full object-cover",
|
||||
shouldInvertLogo && "brightness-0 invert"
|
||||
)}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
src={logo}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -174,7 +174,7 @@ function filterAgentRoutingNoise(text: string): string {
|
||||
|
||||
const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
|
||||
const ASSISTANT_TYPEWRITER_SECTION_REGEX =
|
||||
/(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】|###\s*输出设置|【\s*输出设置\s*】)/;
|
||||
/(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】|###\s*输出设置|【\s*输出设置\s*】)/;
|
||||
const ASSISTANT_TYPEWRITER_STORAGE_KEY = "assistant-typewriter-completed-keys-v1";
|
||||
const MODEL_BADGE_LINE_REGEX =
|
||||
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im;
|
||||
|
||||
@ -69,6 +69,48 @@ type PointsSummaryResponse = {
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
type UploadQueueStatus = "preparing" | "uploading" | "finalizing";
|
||||
|
||||
type UploadQueueItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: UploadQueueStatus;
|
||||
};
|
||||
|
||||
const UPLOAD_PROGRESS_HOLD_MS = 700;
|
||||
|
||||
function createUploadQueueId(file: File) {
|
||||
return [
|
||||
file.name || "image",
|
||||
file.size,
|
||||
file.lastModified,
|
||||
Date.now(),
|
||||
Math.random().toString(36).slice(2, 8),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function createUploadQueueItem(file: File, fallbackName?: string): UploadQueueItem {
|
||||
return {
|
||||
id: createUploadQueueId(file),
|
||||
name: file.name || fallbackName || "图片",
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
};
|
||||
}
|
||||
|
||||
function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus {
|
||||
if (phase === "preparing") return "preparing";
|
||||
if (phase === "finalizing" || phase === "complete") return "finalizing";
|
||||
return "uploading";
|
||||
}
|
||||
|
||||
function getUploadQueueStatusText(status: UploadQueueStatus) {
|
||||
if (status === "preparing") return "正在处理图片";
|
||||
if (status === "finalizing") return "正在保存图片";
|
||||
return "正在上传图片";
|
||||
}
|
||||
|
||||
function isVideoProviderType(model?: Partial<DynamicModel> | null) {
|
||||
return isVideoModelDescriptor({
|
||||
providerType: model?.providerType,
|
||||
@ -117,7 +159,10 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import {
|
||||
fetchUploadWithRetry,
|
||||
type UploadProgressPhase,
|
||||
} from "@/lib/client/upload-with-retry";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -446,10 +491,71 @@ function PureMultimodalInput({
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
|
||||
const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState<Record<number, string>>({});
|
||||
|
||||
const updateUploadQueueItem = useCallback(
|
||||
(
|
||||
id: string | undefined,
|
||||
patch: Partial<Pick<UploadQueueItem, "progress" | "status">>
|
||||
) => {
|
||||
if (!id) return;
|
||||
setUploadQueue((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
...patch,
|
||||
progress:
|
||||
typeof patch.progress === "number"
|
||||
? Math.max(0, Math.min(100, Math.round(patch.progress)))
|
||||
: item.progress,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeUploadQueueItems = useCallback((ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
const idSet = new Set(ids);
|
||||
setUploadQueue((current) => current.filter((item) => !idSet.has(item.id)));
|
||||
}, []);
|
||||
|
||||
const holdAndRemoveUploadQueueItems = useCallback(
|
||||
async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
globalThis.setTimeout(resolve, UPLOAD_PROGRESS_HOLD_MS);
|
||||
});
|
||||
removeUploadQueueItems(ids);
|
||||
},
|
||||
[removeUploadQueueItems]
|
||||
);
|
||||
|
||||
const uploadProgressSummary = useMemo(() => {
|
||||
if (uploadQueue.length === 0) return null;
|
||||
|
||||
const totalProgress = Math.round(
|
||||
uploadQueue.reduce((sum, item) => sum + item.progress, 0) /
|
||||
uploadQueue.length
|
||||
);
|
||||
const activeItem =
|
||||
uploadQueue.find((item) => item.progress < 100) ||
|
||||
uploadQueue[uploadQueue.length - 1];
|
||||
|
||||
return {
|
||||
count: uploadQueue.length,
|
||||
fileName: activeItem?.name || "图片",
|
||||
percent: Math.max(0, Math.min(100, totalProgress)),
|
||||
statusText: getUploadQueueStatusText(activeItem?.status || "uploading"),
|
||||
};
|
||||
}, [uploadQueue]);
|
||||
|
||||
const ensureImagePointsBeforeSend = useCallback(async () => {
|
||||
if (!isImageModel || isVideoModel) return true;
|
||||
@ -678,18 +784,40 @@ function PureMultimodalInput({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploadFile = useCallback(async (file: File): Promise<Attachment | null> => {
|
||||
const uploadFile = useCallback(async (
|
||||
file: File,
|
||||
uploadQueueItemId?: string
|
||||
): Promise<Attachment | null> => {
|
||||
try {
|
||||
// 先压缩图片
|
||||
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
|
||||
updateUploadQueueItem(uploadQueueItemId, {
|
||||
progress: 2,
|
||||
status: "preparing",
|
||||
});
|
||||
const processedFile = await compressImage(file);
|
||||
updateUploadQueueItem(uploadQueueItemId, {
|
||||
progress: 6,
|
||||
status: "uploading",
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", processedFile);
|
||||
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const response = await fetchUploadWithRetry(formData, {
|
||||
onProgress: (progress) => {
|
||||
updateUploadQueueItem(uploadQueueItemId, {
|
||||
progress: progress.percent,
|
||||
status: getUploadQueueStatus(progress.phase),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
updateUploadQueueItem(uploadQueueItemId, {
|
||||
progress: 100,
|
||||
status: "finalizing",
|
||||
});
|
||||
const data = await response.json();
|
||||
const { url, pathname, contentType } = data;
|
||||
|
||||
@ -712,7 +840,7 @@ function PureMultimodalInput({
|
||||
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
|
||||
return null;
|
||||
}
|
||||
}, [compressImage]);
|
||||
}, [compressImage, updateUploadQueueItem]);
|
||||
|
||||
const contextProps = useMemo(
|
||||
() => ({
|
||||
@ -759,20 +887,30 @@ function PureMultimodalInput({
|
||||
event.currentTarget.value = "";
|
||||
return;
|
||||
}
|
||||
const queueItem = createUploadQueueItem(file);
|
||||
setVideoSlotUploading(slotIndex);
|
||||
setUploadQueue((prev) => [...prev, file.name]);
|
||||
setVideoSlotUploadItemIds((current) => ({
|
||||
...current,
|
||||
[slotIndex]: queueItem.id,
|
||||
}));
|
||||
setUploadQueue((prev) => [...prev, queueItem]);
|
||||
try {
|
||||
const uploaded = await uploadFile(file);
|
||||
const uploaded = await uploadFile(file, queueItem.id);
|
||||
if (uploaded) {
|
||||
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
|
||||
}
|
||||
} finally {
|
||||
setVideoSlotUploading(null);
|
||||
setUploadQueue((prev) => prev.filter((name) => name !== file.name));
|
||||
setVideoSlotUploadItemIds((current) => {
|
||||
const next = { ...current };
|
||||
delete next[slotIndex];
|
||||
return next;
|
||||
});
|
||||
await holdAndRemoveUploadQueueItems([queueItem.id]);
|
||||
event.currentTarget.value = "";
|
||||
}
|
||||
},
|
||||
[updateAttachmentAtSlot, uploadFile]
|
||||
[holdAndRemoveUploadQueueItems, updateAttachmentAtSlot, uploadFile]
|
||||
);
|
||||
|
||||
// 🆕 视频模式下的图片数量限制
|
||||
@ -879,11 +1017,14 @@ function PureMultimodalInput({
|
||||
return;
|
||||
}
|
||||
const filesToUpload = files.slice(0, allowedCount);
|
||||
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
|
||||
|
||||
setUploadQueue(filesToUpload.map((file) => file.name));
|
||||
setUploadQueue((prev) => [...prev, ...queueItems]);
|
||||
|
||||
try {
|
||||
const uploadPromises = filesToUpload.map((file) => uploadFile(file));
|
||||
const uploadPromises = filesToUpload.map((file, index) =>
|
||||
uploadFile(file, queueItems[index]?.id)
|
||||
);
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
(attachment): attachment is Attachment => attachment !== null
|
||||
@ -896,13 +1037,13 @@ function PureMultimodalInput({
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
[enforceVideoImageLimit, setAttachments, uploadFile]
|
||||
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
@ -923,19 +1064,24 @@ function PureMultimodalInput({
|
||||
// Prevent default paste behavior for images
|
||||
event.preventDefault();
|
||||
|
||||
setUploadQueue((prev) => [...prev, "已粘贴图片"]);
|
||||
|
||||
let queueItems: UploadQueueItem[] = [];
|
||||
try {
|
||||
const allowedCount = enforceVideoImageLimit(imageItems.length);
|
||||
if (allowedCount <= 0) {
|
||||
setUploadQueue([]);
|
||||
return;
|
||||
}
|
||||
const uploadPromises = imageItems
|
||||
const filesToUpload = imageItems
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
.slice(0, allowedCount)
|
||||
.map((file) => uploadFile(file));
|
||||
.slice(0, allowedCount);
|
||||
queueItems = filesToUpload.map((file, index) =>
|
||||
createUploadQueueItem(file, `已粘贴图片 ${index + 1}`)
|
||||
);
|
||||
setUploadQueue((prev) => [...prev, ...queueItems]);
|
||||
|
||||
const uploadPromises = filesToUpload.map((file, index) =>
|
||||
uploadFile(file, queueItems[index]?.id)
|
||||
);
|
||||
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
@ -953,10 +1099,10 @@ function PureMultimodalInput({
|
||||
console.error("Error uploading pasted images:", error);
|
||||
toast.error("粘贴的图片上传失败");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
|
||||
}
|
||||
},
|
||||
[enforceVideoImageLimit, setAttachments, uploadFile]
|
||||
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
// Add paste event listener to textarea
|
||||
@ -1022,20 +1168,20 @@ function PureMultimodalInput({
|
||||
|
||||
const allowedCount = enforceVideoImageLimit(imageFiles.length);
|
||||
if (allowedCount <= 0) {
|
||||
setUploadQueue([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToUpload = imageFiles.slice(0, allowedCount);
|
||||
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
|
||||
|
||||
console.log("[拖拽] 开始处理文件:", imageFiles.length, "个");
|
||||
|
||||
// 添加到上传队列
|
||||
setUploadQueue((prev) => [...prev, ...filesToUpload.map((f) => f.name)]);
|
||||
setUploadQueue((prev) => [...prev, ...queueItems]);
|
||||
|
||||
try {
|
||||
const uploadedAttachments = await Promise.all(
|
||||
filesToUpload.map((file) => uploadFile(file))
|
||||
filesToUpload.map((file, index) => uploadFile(file, queueItems[index]?.id))
|
||||
);
|
||||
|
||||
const validAttachments = uploadedAttachments.filter(
|
||||
@ -1050,10 +1196,10 @@ function PureMultimodalInput({
|
||||
console.error("拖拽上传失败:", error);
|
||||
toast.error("拖拽的图片上传失败");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
|
||||
}
|
||||
},
|
||||
[uploadFile]
|
||||
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
// 🆕 拖拽排序处理函数
|
||||
@ -1155,12 +1301,44 @@ function PureMultimodalInput({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{uploadProgressSummary && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mb-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2"
|
||||
data-testid="upload-progress-banner"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="min-w-0 truncate font-medium text-foreground">
|
||||
{uploadProgressSummary.count > 1
|
||||
? `正在上传 ${uploadProgressSummary.count} 张图片`
|
||||
: uploadProgressSummary.statusText}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-primary">
|
||||
{uploadProgressSummary.percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-primary/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-200"
|
||||
style={{ width: `${uploadProgressSummary.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-muted-foreground">
|
||||
{uploadProgressSummary.fileName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoFrameSlots.length > 0 && (
|
||||
<div className="mb-2 flex flex-row gap-2">
|
||||
{videoFrameSlots.map((label, index) => {
|
||||
const slotAttachment = attachments[index];
|
||||
const disabled = index > 0 && !attachments[index - 1];
|
||||
const isUploading = videoSlotUploading === index;
|
||||
const uploadItem = isUploading
|
||||
? uploadQueue.find((item) => item.id === videoSlotUploadItemIds[index])
|
||||
: null;
|
||||
const uploadProgress = uploadItem?.progress ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={`video-slot-${label}-${index}`}
|
||||
@ -1196,10 +1374,22 @@ function PureMultimodalInput({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xl leading-none">+</span>
|
||||
<span className="text-sm">{isUploading ? "上传中" : label}</span>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xl leading-none">+</span>
|
||||
<span className="text-sm">
|
||||
{isUploading ? `${uploadProgress}%` : label}
|
||||
</span>
|
||||
</div>
|
||||
{isUploading && (
|
||||
<div className="absolute inset-x-2 bottom-2 h-1 overflow-hidden rounded-full bg-black/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-200"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@ -1284,15 +1474,17 @@ function PureMultimodalInput({
|
||||
/>
|
||||
))}
|
||||
|
||||
{uploadQueue.map((filename) => (
|
||||
{uploadQueue.map((item) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
url: "",
|
||||
name: filename,
|
||||
name: item.name,
|
||||
contentType: "",
|
||||
}}
|
||||
isUploading={true}
|
||||
key={filename}
|
||||
key={item.id}
|
||||
uploadProgress={item.progress}
|
||||
uploadStatus={item.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -59,8 +59,6 @@ export function PascalEditorFrame({
|
||||
const lightLogo = String(siteLogo || "").trim();
|
||||
return darkLogo || lightLogo;
|
||||
}, [siteLogo, siteLogoDark]);
|
||||
const shouldInvertLogo =
|
||||
!String(siteLogoDark || "").trim() && Boolean(String(siteLogo || "").trim());
|
||||
const userEmail = String(user?.email || "").trim();
|
||||
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
|
||||
const isGuest = guestRegex.test(userEmail);
|
||||
@ -91,10 +89,7 @@ export function PascalEditorFrame({
|
||||
{displayLogo ? (
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-7 w-7 shrink-0 object-contain",
|
||||
shouldInvertLogo && "brightness-0 invert"
|
||||
)}
|
||||
className="h-7 w-7 shrink-0 object-contain"
|
||||
src={displayLogo}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -22,9 +22,13 @@ export const PreviewAttachment = ({
|
||||
onAnnotate,
|
||||
onDrawingComplete,
|
||||
imageIndex,
|
||||
uploadProgress,
|
||||
uploadStatus,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
isUploading?: boolean;
|
||||
uploadProgress?: number;
|
||||
uploadStatus?: "preparing" | "uploading" | "finalizing";
|
||||
onRemove?: () => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
@ -40,6 +44,16 @@ export const PreviewAttachment = ({
|
||||
imageIndex?: number;
|
||||
}) => {
|
||||
const { name, url, contentType } = attachment;
|
||||
const normalizedUploadProgress =
|
||||
typeof uploadProgress === "number" && Number.isFinite(uploadProgress)
|
||||
? Math.max(0, Math.min(100, Math.round(uploadProgress)))
|
||||
: null;
|
||||
const uploadStatusLabel =
|
||||
uploadStatus === "preparing"
|
||||
? "处理中"
|
||||
: uploadStatus === "finalizing"
|
||||
? "保存中"
|
||||
: "上传中";
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||||
@ -164,7 +178,7 @@ export const PreviewAttachment = ({
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{contentType?.startsWith("image") ? (
|
||||
{!isUploading && contentType?.startsWith("image") ? (
|
||||
<Image
|
||||
alt={name ?? "图片附件"}
|
||||
className="size-full object-cover"
|
||||
@ -180,10 +194,21 @@ export const PreviewAttachment = ({
|
||||
|
||||
{isUploading && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 px-2 text-white"
|
||||
data-testid="input-attachment-loader"
|
||||
>
|
||||
<Loader size={16} />
|
||||
<span className="text-[10px] font-medium leading-none">
|
||||
{normalizedUploadProgress === null
|
||||
? uploadStatusLabel
|
||||
: `${normalizedUploadProgress}%`}
|
||||
</span>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="h-full rounded-full bg-white transition-[width] duration-200"
|
||||
style={{ width: `${normalizedUploadProgress ?? 18}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -150,7 +150,7 @@ export function TopNavbar({
|
||||
}
|
||||
};
|
||||
|
||||
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
|
||||
// 彩色 logo 不能用滤镜自动反白;暗色主题只在配置了独立 dark logo 时切换。
|
||||
const displayLogo =
|
||||
resolvedTheme === "dark"
|
||||
? siteLogoDark && siteLogoDark.trim() !== ""
|
||||
@ -159,10 +159,6 @@ export function TopNavbar({
|
||||
: siteLogo && siteLogo.trim() !== ""
|
||||
? siteLogo
|
||||
: siteLogoDark;
|
||||
const shouldInvertLogo =
|
||||
resolvedTheme === "dark" &&
|
||||
(!siteLogoDark || siteLogoDark.trim() === "") &&
|
||||
Boolean(siteLogo && siteLogo.trim() !== "");
|
||||
|
||||
const getShortLabel = (label: string) => {
|
||||
const trimmed = label.trim();
|
||||
@ -287,7 +283,7 @@ export function TopNavbar({
|
||||
{displayLogo && (
|
||||
<img
|
||||
alt={siteName}
|
||||
className={shouldInvertLogo ? "h-7 w-7 object-contain brightness-0 invert" : "h-7 w-7 object-contain"}
|
||||
className="h-7 w-7 object-contain"
|
||||
src={displayLogo}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -19,6 +19,10 @@ import {
|
||||
normalizeAgentThreadState,
|
||||
type AgentThreadState,
|
||||
} from "@/lib/ai/runtime/agent-thread-state";
|
||||
import {
|
||||
buildAgentPlanningBriefMarkdown,
|
||||
isAgentPlanningBriefMarkdown,
|
||||
} from "@/lib/ai/runtime/agent-planning-brief";
|
||||
import {
|
||||
getAiModelAgentCodingModelId,
|
||||
isAiModelMultimodal,
|
||||
@ -1153,17 +1157,59 @@ function buildStrategicFriendlyMessage(
|
||||
|
||||
if (existingReply) {
|
||||
const isStructuredReply =
|
||||
isAgentPlanningBriefMarkdown(existingReply) ||
|
||||
/###\s*意图分析/.test(existingReply) ||
|
||||
/###\s*任务规划/.test(existingReply) ||
|
||||
/###\s*需求理解/.test(existingReply) ||
|
||||
/###\s*图片分析/.test(existingReply) ||
|
||||
/###\s*执行计划/.test(existingReply) ||
|
||||
/【\s*意图分析\s*】/.test(existingReply) ||
|
||||
/【\s*任务规划\s*】/.test(existingReply) ||
|
||||
/【\s*需求理解\s*】/.test(existingReply) ||
|
||||
/【\s*图片分析\s*】/.test(existingReply) ||
|
||||
/【\s*执行计划\s*】/.test(existingReply);
|
||||
if (isStructuredReply || existingReply.length >= 24) {
|
||||
if (isStructuredReply) {
|
||||
return Promise.resolve(existingReply);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
analysis.intent === "image_edit" ||
|
||||
analysis.intent === "image_gen" ||
|
||||
analysis.intent === "video_gen"
|
||||
) {
|
||||
if (existingReply) {
|
||||
return Promise.resolve(existingReply);
|
||||
}
|
||||
|
||||
const imageParams = heuristicImageParams(state.text, analysis);
|
||||
const planningBrief = buildAgentPlanningBriefMarkdown({
|
||||
userText: state.text,
|
||||
intent: analysis.intent,
|
||||
subIntent: analysis.subIntent,
|
||||
nextAction: state.plan?.nextAction,
|
||||
preferredCapability: state.plan?.preferredCapability,
|
||||
targetSource: state.plan?.targetSource || state.targetResolutionMode,
|
||||
hasImage: state.hasImage,
|
||||
imageFromHistory: state.imageFromHistory,
|
||||
selectedImageCount: state.selectedImageCount,
|
||||
currentUploadImageCount: state.currentUploadImageCount,
|
||||
historyImageCount: state.historyImageCount,
|
||||
imageCount: imageParams?.imageCount,
|
||||
aspectRatio: imageParams?.aspectRatio,
|
||||
imageSize: imageParams?.imageSize,
|
||||
planGoal: state.plan?.goal,
|
||||
});
|
||||
if (process.env.AGENT_LLM_PLANNING_BRIEF_ENABLED !== "true") {
|
||||
return Promise.resolve(planningBrief);
|
||||
}
|
||||
return withTimeoutFallback(
|
||||
generateAgentConversation(analysis, state),
|
||||
AGENT_OPERATION_TIMEOUT_MS,
|
||||
planningBrief,
|
||||
);
|
||||
}
|
||||
|
||||
return withTimeoutFallback(
|
||||
generateAgentConversation(analysis, state),
|
||||
AGENT_OPERATION_TIMEOUT_MS,
|
||||
|
||||
302
lib/ai/runtime/agent-planning-brief.ts
Normal file
302
lib/ai/runtime/agent-planning-brief.ts
Normal file
@ -0,0 +1,302 @@
|
||||
export type AgentPlanningBriefIntent =
|
||||
| "image_edit"
|
||||
| "vision"
|
||||
| "image_gen"
|
||||
| "video_gen"
|
||||
| "chat";
|
||||
|
||||
export type AgentPlanningBriefAction =
|
||||
| "respond"
|
||||
| "clarify"
|
||||
| "analyze_image"
|
||||
| "generate_image"
|
||||
| "edit_image"
|
||||
| "generate_video";
|
||||
|
||||
export type AgentPlanningBriefTargetSource =
|
||||
| "explicit_selection"
|
||||
| "current_upload"
|
||||
| "text_reference"
|
||||
| "working_memory"
|
||||
| "latest_result"
|
||||
| "clarification"
|
||||
| "none";
|
||||
|
||||
export type AgentPlanningBriefCapability =
|
||||
| "respond"
|
||||
| "analyze"
|
||||
| "generic_edit"
|
||||
| "remove_background"
|
||||
| "remove_watermark"
|
||||
| "enhance"
|
||||
| "extend"
|
||||
| "resize"
|
||||
| "angle_variant"
|
||||
| "local_edit"
|
||||
| "box_cutout"
|
||||
| "generate_image"
|
||||
| "generate_video"
|
||||
| "publish_canvas";
|
||||
|
||||
export interface AgentPlanningBriefInput {
|
||||
userText: string;
|
||||
intent: AgentPlanningBriefIntent;
|
||||
subIntent?: string;
|
||||
nextAction?: AgentPlanningBriefAction;
|
||||
preferredCapability?: AgentPlanningBriefCapability;
|
||||
targetSource?: AgentPlanningBriefTargetSource;
|
||||
hasImage?: boolean;
|
||||
imageFromHistory?: boolean;
|
||||
selectedImageCount?: number;
|
||||
currentUploadImageCount?: number;
|
||||
historyImageCount?: number;
|
||||
imageCount?: number;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
planGoal?: string;
|
||||
}
|
||||
|
||||
export interface AgentTaskGroup {
|
||||
label: string;
|
||||
count?: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHINESE_NUMBER_VALUES: Record<string, number> = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10,
|
||||
};
|
||||
|
||||
function compactLine(value: string, maxLength: number) {
|
||||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
function parseCountToken(token?: string | null) {
|
||||
const value = String(token || "").trim();
|
||||
if (!value) return undefined;
|
||||
if (/^\d+$/.test(value)) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : undefined;
|
||||
}
|
||||
return CHINESE_NUMBER_VALUES[value];
|
||||
}
|
||||
|
||||
function countFromText(text: string) {
|
||||
const match = String(text || "").match(/([1-9]\d?|一|二|两|三|四|五|六|七|八|九|十)\s*张/);
|
||||
return parseCountToken(match?.[1]);
|
||||
}
|
||||
|
||||
function normalizeGroupDescription(description: string) {
|
||||
return String(description || "")
|
||||
.replace(/^[为是::,,\s]+/, "")
|
||||
.replace(/[。;;,,]\s*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractAgentTaskGroups(text: string): AgentTaskGroup[] {
|
||||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return [];
|
||||
|
||||
const groups: AgentTaskGroup[] = [];
|
||||
const groupPattern =
|
||||
/第([一二两三四五六七八九十]|\d{1,2})组(?:为|是|:|:)?([\s\S]*?)(?=(?:第[一二两三四五六七八九十\d]{1,2}组)|[。;;]|$)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = groupPattern.exec(normalized)) !== null) {
|
||||
const labelToken = match[1];
|
||||
const description = normalizeGroupDescription(match[2] || "");
|
||||
if (!description) continue;
|
||||
const labelNumber = parseCountToken(labelToken);
|
||||
groups.push({
|
||||
label: `第${labelNumber || labelToken}组`,
|
||||
count: countFromText(description),
|
||||
description: compactLine(description, 96),
|
||||
});
|
||||
}
|
||||
|
||||
return groups.slice(0, 8);
|
||||
}
|
||||
|
||||
function getTargetSourceLabel(source?: AgentPlanningBriefTargetSource) {
|
||||
switch (source) {
|
||||
case "explicit_selection":
|
||||
return "画布选中图片";
|
||||
case "current_upload":
|
||||
return "当前上传图片";
|
||||
case "text_reference":
|
||||
return "文本指定图片";
|
||||
case "working_memory":
|
||||
return "会话工作记忆图片";
|
||||
case "latest_result":
|
||||
return "最近生成结果";
|
||||
case "clarification":
|
||||
return "待确认目标";
|
||||
case "none":
|
||||
default:
|
||||
return "无需现有图片";
|
||||
}
|
||||
}
|
||||
|
||||
function getCapabilityLabel(capability?: AgentPlanningBriefCapability, subIntent?: string) {
|
||||
switch (capability || subIntent) {
|
||||
case "remove_background":
|
||||
return "主体抠图/去背景";
|
||||
case "remove_watermark":
|
||||
return "去水印";
|
||||
case "enhance":
|
||||
return "清晰度增强";
|
||||
case "extend":
|
||||
return "扩图/画幅延展";
|
||||
case "resize":
|
||||
return "尺寸调整";
|
||||
case "angle_variant":
|
||||
case "generate_angle_variant":
|
||||
return "多视角变体";
|
||||
case "local_edit":
|
||||
case "inpaint_region":
|
||||
return "局部编辑";
|
||||
case "box_cutout":
|
||||
return "框选抠图";
|
||||
case "generate_video":
|
||||
return "视频生成";
|
||||
case "generate_image":
|
||||
return "图片生成";
|
||||
case "analyze":
|
||||
return "图片分析";
|
||||
default:
|
||||
return "图片编辑";
|
||||
}
|
||||
}
|
||||
|
||||
function inferTotalCount(params: AgentPlanningBriefInput, groups: AgentTaskGroup[]) {
|
||||
if (typeof params.imageCount === "number" && Number.isFinite(params.imageCount)) {
|
||||
return Math.max(1, Math.round(params.imageCount));
|
||||
}
|
||||
const groupedTotal = groups.reduce((sum, group) => sum + (group.count || 0), 0);
|
||||
return groupedTotal > 0 ? groupedTotal : undefined;
|
||||
}
|
||||
|
||||
function buildIntentLine(params: AgentPlanningBriefInput, groups: AgentTaskGroup[]) {
|
||||
const target = getTargetSourceLabel(params.targetSource);
|
||||
const capability = getCapabilityLabel(params.preferredCapability, params.subIntent);
|
||||
const totalCount = inferTotalCount(params, groups);
|
||||
const demand = compactLine(params.planGoal || params.userText || "", 120);
|
||||
|
||||
if (params.intent === "image_gen") {
|
||||
const countText = totalCount ? `,共 ${totalCount} 张` : "";
|
||||
const groupText = groups.length > 0 ? `,拆成 ${groups.length} 组` : "";
|
||||
const referenceText = params.hasImage ? ",并保持参考图主体核心特征一致" : "";
|
||||
return `用户需要生成新图片${groupText}${countText}${referenceText};核心目标是:${demand || "完成本轮生图任务"}。`;
|
||||
}
|
||||
|
||||
if (params.intent === "image_edit") {
|
||||
return `用户需要基于${target}执行${capability};核心目标是:${demand || "完成本轮图片处理"}。`;
|
||||
}
|
||||
|
||||
if (params.intent === "video_gen") {
|
||||
return `用户需要生成视频;先拆解镜头、主体连续性和运动节奏,再提交视频生成。`;
|
||||
}
|
||||
|
||||
if (params.intent === "vision") {
|
||||
return `用户需要理解或分析图片内容;先观察主体、场景、构图和潜在问题,再给出结论。`;
|
||||
}
|
||||
|
||||
return `用户当前主要是在对话或咨询;先回答问题,不主动触发生图或修图工具。`;
|
||||
}
|
||||
|
||||
function buildImageGenerationPlan(params: AgentPlanningBriefInput, groups: AgentTaskGroup[]) {
|
||||
if (groups.length > 0) {
|
||||
return groups.map((group, index) => {
|
||||
const countText = group.count ? `${group.count}张` : "一组";
|
||||
return `${index + 1}. ${group.label}:生成${countText}${group.description.replace(/^\d+\s*张/, "")},保持主体识别一致。`;
|
||||
});
|
||||
}
|
||||
|
||||
const count = inferTotalCount(params, groups) || 1;
|
||||
return [
|
||||
`1. 锁定主体、场景、风格和构图约束,整理成可执行提示词。`,
|
||||
`2. 生成 ${count} 张候选图,优先保证主体一致性和画面完成度。`,
|
||||
`3. 输出结果并保留可继续追改的上下文。`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildImageEditPlan(params: AgentPlanningBriefInput) {
|
||||
const target = getTargetSourceLabel(params.targetSource);
|
||||
const capability = getCapabilityLabel(params.preferredCapability, params.subIntent);
|
||||
return [
|
||||
`1. 先锁定${target}作为本轮处理目标,避免误处理其他图片。`,
|
||||
`2. 调用${capability}能力执行核心改动,同时尽量保留主体结构和画质。`,
|
||||
`3. 输出处理结果,并保留原图用于对比或继续微调。`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildGenericPlan(params: AgentPlanningBriefInput) {
|
||||
if (params.intent === "video_gen") {
|
||||
return [
|
||||
"1. 拆解视频主体、场景、动作节奏和镜头关系。",
|
||||
"2. 生成脚本与分镜,并整理可直接提交的视频提示词。",
|
||||
"3. 按规划提交视频生成,返回结果后支持继续调整。",
|
||||
];
|
||||
}
|
||||
if (params.intent === "vision") {
|
||||
return [
|
||||
"1. 先识别图片主体、背景、构图和关键信息。",
|
||||
"2. 结合用户问题输出判断、建议或可执行修改方向。",
|
||||
];
|
||||
}
|
||||
return ["1. 先直接回答用户问题;如果需要执行工具,再根据明确指令进入下一步。"];
|
||||
}
|
||||
|
||||
function buildOutputSettings(params: AgentPlanningBriefInput, groups: AgentTaskGroup[]) {
|
||||
const lines: string[] = [];
|
||||
const totalCount = inferTotalCount(params, groups);
|
||||
if (params.intent === "image_gen" || params.intent === "image_edit") {
|
||||
lines.push(`- 输出数量:${params.intent === "image_edit" ? "1 张处理结果" : `${totalCount || 1} 张`}`);
|
||||
lines.push(`- 图片比例:${params.aspectRatio && params.aspectRatio !== "auto" ? params.aspectRatio : "按需求自适应"}`);
|
||||
lines.push(`- 清晰度:${params.imageSize || (params.intent === "image_edit" ? "4K优先" : "2K优先")}`);
|
||||
if (params.intent === "image_edit") {
|
||||
lines.push(`- 目标来源:${getTargetSourceLabel(params.targetSource)}`);
|
||||
}
|
||||
} else if (params.intent === "video_gen") {
|
||||
lines.push("- 输出形式:视频结果 + 可继续追改的分镜上下文");
|
||||
} else {
|
||||
lines.push("- 输出形式:直接回答,不主动触发生成工具");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildAgentPlanningBriefMarkdown(params: AgentPlanningBriefInput) {
|
||||
const groups = extractAgentTaskGroups(params.userText);
|
||||
const planLines =
|
||||
params.intent === "image_gen"
|
||||
? buildImageGenerationPlan(params, groups)
|
||||
: params.intent === "image_edit"
|
||||
? buildImageEditPlan(params)
|
||||
: buildGenericPlan(params);
|
||||
const outputSettings = buildOutputSettings(params, groups);
|
||||
|
||||
return [
|
||||
"### 意图分析",
|
||||
`- ${buildIntentLine(params, groups)}`,
|
||||
"### 任务规划",
|
||||
...planLines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)),
|
||||
"### 输出设置",
|
||||
...outputSettings,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function isAgentPlanningBriefMarkdown(text: string) {
|
||||
const normalized = String(text || "");
|
||||
return /###\s*意图分析|【\s*意图分析\s*】/.test(normalized) &&
|
||||
/###\s*任务规划|【\s*任务规划\s*】/.test(normalized);
|
||||
}
|
||||
@ -503,6 +503,72 @@ export function getDirectAgentToolLabel(capability: DirectAgentToolCapability):
|
||||
}
|
||||
}
|
||||
|
||||
export function getDirectAgentToolDisplayName(
|
||||
capability: DirectAgentToolCapability,
|
||||
): string {
|
||||
switch (capability) {
|
||||
case "analyze":
|
||||
return "图片分析";
|
||||
case "generic_edit":
|
||||
return "图片编辑";
|
||||
case "remove_background":
|
||||
return "抠图";
|
||||
case "remove_watermark":
|
||||
return "去水印";
|
||||
case "enhance":
|
||||
return "图像增强";
|
||||
case "resize":
|
||||
return "尺寸调整";
|
||||
case "extend":
|
||||
return "图像拓展";
|
||||
case "angle_variant":
|
||||
return "多视角";
|
||||
case "local_edit":
|
||||
return "局部编辑";
|
||||
case "box_cutout":
|
||||
return "框选抠图";
|
||||
case "generate_image":
|
||||
return "图片生成";
|
||||
case "generate_video":
|
||||
return "视频生成";
|
||||
default:
|
||||
return "图像处理";
|
||||
}
|
||||
}
|
||||
|
||||
export function getDirectAgentToolPrefaceFallback(
|
||||
capability: DirectAgentToolCapability,
|
||||
): string {
|
||||
switch (capability) {
|
||||
case "analyze":
|
||||
return "好的,我先帮你看看这张图,稍等我一下。";
|
||||
case "generic_edit":
|
||||
return "好的,我来帮你修改这张图,稍等我一下。";
|
||||
case "remove_background":
|
||||
return "好的,我来帮你抠出主体,稍等我一下。";
|
||||
case "remove_watermark":
|
||||
return "好的,我来帮你去掉水印,稍等我一下。";
|
||||
case "enhance":
|
||||
return "好的,我来帮你增强这张图,稍等我一下。";
|
||||
case "resize":
|
||||
return "好的,我来帮你调整图片尺寸,稍等我一下。";
|
||||
case "extend":
|
||||
return "好的,我来帮你扩展这张图,稍等我一下。";
|
||||
case "angle_variant":
|
||||
return "好的,我来帮你生成新的视角,稍等我一下。";
|
||||
case "local_edit":
|
||||
return "好的,我来帮你修改这个局部区域,稍等我一下。";
|
||||
case "box_cutout":
|
||||
return "好的,我来帮你把框选区域抠出来,稍等我一下。";
|
||||
case "generate_image":
|
||||
return "好的,我按你的描述来做一版画面,稍等我处理一下。";
|
||||
case "generate_video":
|
||||
return "好的,我按你的需求来做这段视频,稍等我处理一下。";
|
||||
default:
|
||||
return "好的,我来继续处理,稍等我一下。";
|
||||
}
|
||||
}
|
||||
|
||||
export function findAgentToolCallArgs(
|
||||
toolName: AgentPlannedToolCall["name"],
|
||||
execution?: AgentPreparedExecution,
|
||||
|
||||
@ -169,15 +169,23 @@ export async function executeDirectAgentImageGeneration(
|
||||
params.imageParams?.imageSize || params.defaultImageQualityParams?.imageSize;
|
||||
const imageCount =
|
||||
params.imageParams?.imageCount || params.defaultImageQualityParams?.imageCount;
|
||||
const shouldIgnoreModelMaxImages =
|
||||
typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount > 1;
|
||||
const result = await generateCanvasImage({
|
||||
prompt,
|
||||
negativePrompt: params.imageParams?.negativePrompt,
|
||||
model: params.finalModelId,
|
||||
ownerKey: params.userId,
|
||||
debugLabel: "direct-agent-image-generation",
|
||||
debugMeta: {
|
||||
agentIgnoreModelMaxImages: shouldIgnoreModelMaxImages,
|
||||
requestedAgentImageCount: imageCount ?? null,
|
||||
},
|
||||
aspectRatio:
|
||||
params.imageParams?.aspectRatio || params.defaultImageQualityParams?.aspectRatio,
|
||||
imageSize,
|
||||
imageCount,
|
||||
ignoreModelMaxImages: shouldIgnoreModelMaxImages,
|
||||
referenceImages: params.referenceImageUrls,
|
||||
});
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
prependImageModelBadge,
|
||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||
import {
|
||||
getDirectAgentToolLabel,
|
||||
getDirectAgentToolDisplayName,
|
||||
getDirectAgentToolSuccessMessage,
|
||||
type DirectAgentToolCapability,
|
||||
} from "@/lib/ai/runtime/direct-agent-execution";
|
||||
@ -73,7 +73,7 @@ export function buildDirectAgentAnalysisAssistantText(params: {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
getDirectAgentToolLabel("analyze"),
|
||||
getDirectAgentToolDisplayName("analyze"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ export function buildDirectAgentImageEditAssistantText(params: {
|
||||
}),
|
||||
followUp: getDirectAgentFollowUpMessage(params.capability),
|
||||
}),
|
||||
getDirectAgentToolLabel(params.capability),
|
||||
getDirectAgentToolDisplayName(params.capability),
|
||||
);
|
||||
}
|
||||
|
||||
@ -139,6 +139,6 @@ export function buildDirectAgentBatchImageEditAssistantText(params: {
|
||||
mediaMarkdown: buildAgentToolImagesMarkdown(params.results),
|
||||
followUp: getDirectAgentFollowUpMessage(params.capability),
|
||||
}),
|
||||
getDirectAgentToolLabel(params.capability),
|
||||
getDirectAgentToolDisplayName(params.capability),
|
||||
);
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ export interface CanvasGenerateImageRequest {
|
||||
ownerKey?: string;
|
||||
debugLabel?: string;
|
||||
debugMeta?: Record<string, unknown>;
|
||||
ignoreModelMaxImages?: boolean;
|
||||
localEditComposite?: CanvasLocalEditCompositeConfig;
|
||||
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
|
||||
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
|
||||
@ -3949,12 +3950,14 @@ function normalizeCanvasImageParamsByModel(params: {
|
||||
imageCount: number;
|
||||
imageSize: string;
|
||||
imageGenConfig: CanvasImageGenConfig | null | undefined;
|
||||
ignoreModelMaxImages?: boolean;
|
||||
}) {
|
||||
const maxImages =
|
||||
const configuredMaxImages =
|
||||
typeof params.imageGenConfig?.maxImages === "number" &&
|
||||
Number.isFinite(params.imageGenConfig.maxImages)
|
||||
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
|
||||
: 12;
|
||||
const maxImages = params.ignoreModelMaxImages ? 12 : configuredMaxImages;
|
||||
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
|
||||
|
||||
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
|
||||
@ -4470,6 +4473,7 @@ export async function generateCanvasImage(
|
||||
imageCount: requestedImageCount,
|
||||
imageSize: requestedImageSize,
|
||||
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
|
||||
ignoreModelMaxImages: request.ignoreModelMaxImages === true,
|
||||
});
|
||||
const imageSize = normalizedImageParams.imageSize;
|
||||
const imageCount =
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const MAX_BACKOFF_MS = 8000;
|
||||
const MAX_CONCURRENT_UPLOADS = 3;
|
||||
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_MIN_BYTES = 1;
|
||||
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
|
||||
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
|
||||
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
|
||||
const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 3 * 1000;
|
||||
const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 15 * 1000;
|
||||
const DIRECT_UPLOAD_ABORT_TIMEOUT_MS = 8 * 1000;
|
||||
const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 10 * 1000;
|
||||
const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000;
|
||||
const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
const RETRYABLE_UPLOAD_STATUSES = new Set([
|
||||
408,
|
||||
@ -36,6 +42,23 @@ let activeUploadCount = 0;
|
||||
const uploadQueue: Array<() => void> = [];
|
||||
let directUploadSuppressedUntil = 0;
|
||||
|
||||
export type UploadProgressPhase =
|
||||
| "preparing"
|
||||
| "direct"
|
||||
| "multipart"
|
||||
| "server"
|
||||
| "finalizing"
|
||||
| "complete";
|
||||
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
phase: UploadProgressPhase;
|
||||
};
|
||||
|
||||
type UploadProgressHandler = (progress: UploadProgress) => void;
|
||||
|
||||
type UploadFormFile = {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
@ -71,6 +94,12 @@ function createAbortError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
function createTimeoutError(message: string) {
|
||||
const error = new Error(message);
|
||||
error.name = "TimeoutError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal) {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAbortError());
|
||||
@ -146,6 +175,38 @@ function isAbortError(error: unknown, signal?: AbortSignal) {
|
||||
);
|
||||
}
|
||||
|
||||
function clampPercent(value: number, max = 99) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
function emitUploadProgress(
|
||||
onProgress: UploadProgressHandler | undefined,
|
||||
loaded: number,
|
||||
total: number,
|
||||
phase: UploadProgressPhase,
|
||||
options?: { percent?: number; maxPercent?: number }
|
||||
) {
|
||||
if (!onProgress) return;
|
||||
|
||||
const safeLoaded = Math.max(0, Math.round(loaded || 0));
|
||||
const safeTotal = Math.max(0, Math.round(total || 0));
|
||||
const computedPercent =
|
||||
typeof options?.percent === "number"
|
||||
? options.percent
|
||||
: safeTotal > 0
|
||||
? (safeLoaded / safeTotal) * 100
|
||||
: 0;
|
||||
const maxPercent = options?.maxPercent ?? (phase === "complete" ? 100 : 99);
|
||||
|
||||
onProgress({
|
||||
loaded: safeLoaded,
|
||||
total: safeTotal,
|
||||
percent: clampPercent(computedPercent, maxPercent),
|
||||
phase,
|
||||
});
|
||||
}
|
||||
|
||||
function cloneFormData(formData: FormData) {
|
||||
const copy = new FormData();
|
||||
|
||||
@ -263,18 +324,90 @@ async function readErrorPayload(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithControlTimeout(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
|
||||
const onAbort = () => {
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const request = fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}).catch((error) => {
|
||||
if (signal?.aborted) {
|
||||
throw createAbortError();
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
throw createTimeoutError(
|
||||
`Direct upload control request timed out after ${Math.round(
|
||||
timeoutMs / 1000
|
||||
)}s`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
if (!settled) {
|
||||
reject(
|
||||
createTimeoutError(
|
||||
`Direct upload control request timed out after ${Math.round(
|
||||
timeoutMs / 1000
|
||||
)}s`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return await Promise.race([request, timeoutPromise]);
|
||||
} finally {
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function postDirectUploadAction<T>(
|
||||
payload: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
timeoutMs = DIRECT_UPLOAD_ACTION_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
const response = await fetch("/api/files/direct-upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetchWithControlTimeout(
|
||||
"/api/files/direct-upload",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
timeoutMs,
|
||||
signal
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(String(await readErrorPayload(response)));
|
||||
@ -283,18 +416,167 @@ async function postDirectUploadAction<T>(
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function createResponseFromXhr(xhr: XMLHttpRequest) {
|
||||
const headers = new Headers();
|
||||
const rawHeaders = xhr.getAllResponseHeaders();
|
||||
|
||||
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
|
||||
if (!line) continue;
|
||||
const separatorIndex = line.indexOf(":");
|
||||
if (separatorIndex <= 0) continue;
|
||||
headers.append(
|
||||
line.slice(0, separatorIndex).trim(),
|
||||
line.slice(separatorIndex + 1).trim()
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(xhr.responseText || null, {
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function createJsonResponse(payload: unknown, init?: ResponseInit) {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(payload), {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeXhrHeaders(headers?: HeadersInit) {
|
||||
if (!headers) return [] as Array<[string, string]>;
|
||||
if (headers instanceof Headers) {
|
||||
return Array.from(headers.entries());
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
return headers.map(([key, value]) => [key, String(value)] as [string, string]);
|
||||
}
|
||||
return Object.entries(headers).map(
|
||||
([key, value]) => [key, String(value)] as [string, string]
|
||||
);
|
||||
}
|
||||
|
||||
function xhrUpload(
|
||||
url: string,
|
||||
init: {
|
||||
method: string;
|
||||
headers?: HeadersInit;
|
||||
body: XMLHttpRequestBodyInit;
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
timeoutMs?: number;
|
||||
stallTimeoutMs?: number;
|
||||
}
|
||||
) {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (!stallTimer) return;
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearStallTimer();
|
||||
options?.signal?.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
xhr.abort();
|
||||
settle(() => reject(createAbortError()));
|
||||
};
|
||||
|
||||
xhr.open(init.method, url, true);
|
||||
xhr.timeout = options?.timeoutMs ?? XHR_UPLOAD_TOTAL_TIMEOUT_MS;
|
||||
|
||||
for (const [key, value] of normalizeXhrHeaders(init.headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer();
|
||||
if (settled) return;
|
||||
|
||||
const stallTimeoutMs =
|
||||
options?.stallTimeoutMs ?? XHR_UPLOAD_STALL_TIMEOUT_MS;
|
||||
if (stallTimeoutMs <= 0) return;
|
||||
|
||||
stallTimer = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Upload stalled for ${Math.round(stallTimeoutMs / 1000)}s`
|
||||
);
|
||||
error.name = "TimeoutError";
|
||||
settle(() => reject(error));
|
||||
xhr.abort();
|
||||
}, stallTimeoutMs);
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
resetStallTimer();
|
||||
const total = event.lengthComputable ? event.total : 0;
|
||||
options?.onProgress?.(event.loaded, total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
settle(() => resolve(createResponseFromXhr(xhr)));
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
settle(() => reject(new TypeError("Network request failed")));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
settle(() => reject(new Error("Upload request timed out")));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
settle(() => reject(createAbortError()));
|
||||
};
|
||||
|
||||
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
resetStallTimer();
|
||||
xhr.send(init.body);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDirectPartWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
init: {
|
||||
method: string;
|
||||
headers?: HeadersInit;
|
||||
body: XMLHttpRequestBodyInit;
|
||||
},
|
||||
maxRetries: number,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
options?: { stallTimeoutMs?: number }
|
||||
) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal,
|
||||
});
|
||||
const response = await xhrUpload(
|
||||
url,
|
||||
init,
|
||||
{
|
||||
onProgress,
|
||||
signal,
|
||||
stallTimeoutMs: options?.stallTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
||||
return response;
|
||||
@ -311,15 +593,79 @@ async function fetchDirectPartWithRetry(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServerUploadWithRetry(
|
||||
formData: FormData,
|
||||
maxRetries: number,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: UploadProgressHandler
|
||||
) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const response = await xhrUpload(
|
||||
"/api/files/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body: cloneFormData(formData),
|
||||
},
|
||||
{
|
||||
signal,
|
||||
onProgress: (loaded, total) => {
|
||||
emitUploadProgress(onProgress, loaded, total, "server");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
||||
if (!retryAfterMs) {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const retryAfterSec = Number(payload?.retryAfter || 0);
|
||||
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
||||
retryAfterMs = Math.round(retryAfterSec * 1000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await sleep(computeBackoffMs(attempt, retryAfterMs), signal);
|
||||
} catch (error) {
|
||||
if (isAbortError(error, signal) || attempt >= maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sleep(computeBackoffMs(attempt, null), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMultipartParts(params: {
|
||||
file: UploadFormFile;
|
||||
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
|
||||
maxRetries: number;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: UploadProgressHandler;
|
||||
}) {
|
||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
|
||||
const partLoadedBytes = new Map<number, number>();
|
||||
let nextIndex = 0;
|
||||
|
||||
const reportPartProgress = (partNumber: number, loaded: number, total: number) => {
|
||||
partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded));
|
||||
const uploadedBytes = Array.from(partLoadedBytes.values()).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
);
|
||||
emitUploadProgress(
|
||||
params.onProgress,
|
||||
uploadedBytes,
|
||||
params.file.blob.size,
|
||||
"multipart"
|
||||
);
|
||||
};
|
||||
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const part = params.upload.parts[nextIndex];
|
||||
@ -336,13 +682,19 @@ async function uploadMultipartParts(params: {
|
||||
body,
|
||||
},
|
||||
params.maxRetries,
|
||||
params.signal
|
||||
params.signal,
|
||||
(loaded, total) => {
|
||||
reportPartProgress(part.partNumber, loaded, total || body.size);
|
||||
},
|
||||
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Direct multipart upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
reportPartProgress(part.partNumber, body.size, body.size);
|
||||
|
||||
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
||||
if (!etag) {
|
||||
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
|
||||
@ -368,7 +720,8 @@ async function uploadMultipartParts(params: {
|
||||
async function fetchDirectUpload(
|
||||
formData: FormData,
|
||||
maxRetries: number,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onProgress?: UploadProgressHandler
|
||||
): Promise<Response | null> {
|
||||
const file = getUploadFormFile(formData);
|
||||
if (!file || !shouldTryDirectUpload(file)) {
|
||||
@ -380,6 +733,9 @@ async function fetchDirectUpload(
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
|
||||
percent: 1,
|
||||
});
|
||||
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
|
||||
{
|
||||
action: "initiate",
|
||||
@ -389,10 +745,14 @@ async function fetchDirectUpload(
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
signal,
|
||||
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (upload.mode === "single") {
|
||||
emitUploadProgress(onProgress, 0, file.blob.size, "direct", {
|
||||
percent: 2,
|
||||
});
|
||||
const response = await fetchDirectPartWithRetry(
|
||||
upload.url,
|
||||
{
|
||||
@ -403,13 +763,20 @@ async function fetchDirectUpload(
|
||||
body: file.blob,
|
||||
},
|
||||
maxRetries,
|
||||
signal
|
||||
signal,
|
||||
(loaded, total) => {
|
||||
emitUploadProgress(onProgress, loaded, total || file.blob.size, "direct");
|
||||
},
|
||||
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Direct upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
|
||||
percent: 99,
|
||||
});
|
||||
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
||||
{
|
||||
action: "complete",
|
||||
@ -423,15 +790,25 @@ async function fetchDirectUpload(
|
||||
signal
|
||||
);
|
||||
|
||||
return Response.json(payload);
|
||||
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
|
||||
percent: 100,
|
||||
});
|
||||
return createJsonResponse(payload);
|
||||
}
|
||||
|
||||
multipartUpload = upload;
|
||||
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
|
||||
percent: 2,
|
||||
});
|
||||
const parts = await uploadMultipartParts({
|
||||
file,
|
||||
upload,
|
||||
maxRetries,
|
||||
signal,
|
||||
onProgress,
|
||||
});
|
||||
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
|
||||
percent: 99,
|
||||
});
|
||||
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
||||
{
|
||||
@ -448,7 +825,10 @@ async function fetchDirectUpload(
|
||||
signal
|
||||
);
|
||||
|
||||
return Response.json(payload);
|
||||
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
|
||||
percent: 100,
|
||||
});
|
||||
return createJsonResponse(payload);
|
||||
} catch (error) {
|
||||
if (multipartUpload && !isAbortError(error, signal)) {
|
||||
postDirectUploadAction(
|
||||
@ -458,13 +838,14 @@ async function fetchDirectUpload(
|
||||
uploadId: multipartUpload.uploadId,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
signal,
|
||||
DIRECT_UPLOAD_ABORT_TIMEOUT_MS
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isAbortError(error, signal)) {
|
||||
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
|
||||
return Response.json(
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
|
||||
@ -489,6 +870,7 @@ export async function fetchUploadWithRetry(
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: UploadProgressHandler;
|
||||
}
|
||||
) {
|
||||
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||
@ -497,45 +879,27 @@ export async function fetchUploadWithRetry(
|
||||
const directResponse = await fetchDirectUpload(
|
||||
formData,
|
||||
maxRetries,
|
||||
options?.signal
|
||||
options?.signal,
|
||||
options?.onProgress
|
||||
);
|
||||
if (directResponse) {
|
||||
return directResponse;
|
||||
}
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: cloneFormData(formData),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
||||
if (!retryAfterMs) {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const retryAfterSec = Number(payload?.retryAfter || 0);
|
||||
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
||||
retryAfterMs = Math.round(retryAfterSec * 1000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const delayMs = computeBackoffMs(attempt, retryAfterMs);
|
||||
await sleep(delayMs, options?.signal);
|
||||
} catch (error) {
|
||||
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = computeBackoffMs(attempt, null);
|
||||
await sleep(delayMs, options?.signal);
|
||||
}
|
||||
const response = await fetchServerUploadWithRetry(
|
||||
formData,
|
||||
maxRetries,
|
||||
options?.signal,
|
||||
options?.onProgress
|
||||
);
|
||||
if (response.ok) {
|
||||
options?.onProgress?.({
|
||||
loaded: 1,
|
||||
total: 1,
|
||||
percent: 100,
|
||||
phase: "complete",
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@ -42,6 +42,28 @@ function isAliyunOssImageHost(hostname: string): boolean {
|
||||
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
|
||||
}
|
||||
|
||||
function isTrustedDirectStorageImageHost(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "yybb.top" ||
|
||||
normalized.endsWith(".qiniucs.com") ||
|
||||
normalized.endsWith(".qiniudn.com") ||
|
||||
normalized.endsWith(".clouddn.com") ||
|
||||
normalized.endsWith(".qbox.me")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedDirectStorageDisplayUrl(url: string): string | null {
|
||||
try {
|
||||
const target = new URL(url);
|
||||
return isTrustedDirectStorageImageHost(target.hostname)
|
||||
? target.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAliyunOssDisplayUrl(
|
||||
url: string,
|
||||
options: Pick<ImageProxyOptions, "width" | "quality" | "format">
|
||||
@ -94,6 +116,11 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
||||
return source;
|
||||
}
|
||||
|
||||
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(source);
|
||||
if (trustedDirectStorageUrl) {
|
||||
return trustedDirectStorageUrl;
|
||||
}
|
||||
|
||||
if (options.delivery !== "proxy") {
|
||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||
if (directDisplayUrl) {
|
||||
|
||||
76
lib/openclaw-chat/cancel-registry.ts
Normal file
76
lib/openclaw-chat/cancel-registry.ts
Normal file
@ -0,0 +1,76 @@
|
||||
const CANCELED_REQUEST_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
type ActiveClawdRequest = {
|
||||
controller: AbortController;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const activeRequests = new Map<string, ActiveClawdRequest>();
|
||||
const canceledRequests = new Map<string, number>();
|
||||
|
||||
function cleanupExpiredClawdRequests(now = Date.now()) {
|
||||
for (const [key, request] of activeRequests.entries()) {
|
||||
if (request.expiresAt <= now) {
|
||||
activeRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, expiresAt] of canceledRequests.entries()) {
|
||||
if (expiresAt <= now) {
|
||||
canceledRequests.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClawdCancelKey({
|
||||
userId,
|
||||
chatId,
|
||||
userMessageId,
|
||||
}: {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
userMessageId: string;
|
||||
}) {
|
||||
return `${userId}:${chatId}:${userMessageId}`;
|
||||
}
|
||||
|
||||
export function registerActiveClawdRequest(
|
||||
key: string,
|
||||
controller: AbortController,
|
||||
timeoutMs: number
|
||||
) {
|
||||
cleanupExpiredClawdRequests();
|
||||
activeRequests.set(key, {
|
||||
controller,
|
||||
expiresAt: Date.now() + Math.max(timeoutMs, CANCELED_REQUEST_TTL_MS),
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterActiveClawdRequest(key: string) {
|
||||
activeRequests.delete(key);
|
||||
}
|
||||
|
||||
export function cancelClawdRequest(key: string) {
|
||||
cleanupExpiredClawdRequests();
|
||||
canceledRequests.set(key, Date.now() + CANCELED_REQUEST_TTL_MS);
|
||||
const activeRequest = activeRequests.get(key);
|
||||
if (activeRequest && !activeRequest.controller.signal.aborted) {
|
||||
activeRequest.controller.abort();
|
||||
}
|
||||
return Boolean(activeRequest);
|
||||
}
|
||||
|
||||
export function isClawdRequestCanceled(key: string) {
|
||||
cleanupExpiredClawdRequests();
|
||||
const expiresAt = canceledRequests.get(key);
|
||||
if (!expiresAt) return false;
|
||||
if (expiresAt <= Date.now()) {
|
||||
canceledRequests.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearClawdRequestCancellation(key: string) {
|
||||
canceledRequests.delete(key);
|
||||
}
|
||||
@ -253,8 +253,7 @@ async function uploadToS3(
|
||||
Body: Buffer.from(buffer),
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
// 设置为公开可读(如果您的 MinIO 桶支持)
|
||||
ACL: "public-read",
|
||||
...(config.provider === "qiniu-kodo" ? {} : { ACL: "public-read" as const }),
|
||||
});
|
||||
|
||||
await withStorageOperationTimeout(
|
||||
|
||||
@ -6,23 +6,26 @@ import {
|
||||
S3Client,
|
||||
type CORSRule,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
loadEnv({ path: ".env.local" });
|
||||
loadEnv();
|
||||
|
||||
type S3UploadConfig = {
|
||||
name?: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
provider?: "s3" | "qiniu-kodo";
|
||||
};
|
||||
|
||||
const DEFAULT_ORIGINS = [
|
||||
"https://ai.yydd.ai",
|
||||
"https://yydd.ai",
|
||||
"https://bbyy.ai",
|
||||
"https://yybb.top",
|
||||
"https://goyingup.chat",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
@ -54,7 +57,7 @@ function createS3Client(config: S3UploadConfig) {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: !isCloudS3,
|
||||
forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
|
||||
});
|
||||
}
|
||||
|
||||
@ -69,7 +72,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
||||
|
||||
if (
|
||||
!raw ||
|
||||
raw.type !== "s3" ||
|
||||
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
|
||||
raw.isEnabled !== true ||
|
||||
!endpoint ||
|
||||
!bucket ||
|
||||
@ -80,11 +83,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
||||
}
|
||||
|
||||
return {
|
||||
name: raw.name ? String(raw.name) : undefined,
|
||||
endpoint,
|
||||
region: String(raw.region || "auto").trim() || "auto",
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,6 +106,7 @@ function readEnvS3Config(): S3UploadConfig | null {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Environment S3",
|
||||
endpoint,
|
||||
region: process.env.S3_REGION?.trim() || "auto",
|
||||
bucket,
|
||||
@ -109,24 +115,31 @@ function readEnvS3Config(): S3UploadConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveS3Config() {
|
||||
async function resolveS3Configs() {
|
||||
const [{ db }, { storageConfig }] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("@/lib/db/schema"),
|
||||
]);
|
||||
const [defaultConfig] = await db
|
||||
const storageConfigs = await db
|
||||
.select()
|
||||
.from(storageConfig)
|
||||
.where(
|
||||
and(
|
||||
eq(storageConfig.type, "s3"),
|
||||
eq(storageConfig.isDefault, true),
|
||||
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
|
||||
eq(storageConfig.isEnabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
return toS3Config(defaultConfig) || readEnvS3Config();
|
||||
const configs = storageConfigs
|
||||
.map(toS3Config)
|
||||
.filter((config): config is S3UploadConfig => Boolean(config));
|
||||
const envConfig = readEnvS3Config();
|
||||
|
||||
if (configs.length === 0 && envConfig) {
|
||||
return [envConfig];
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
function readOrigins() {
|
||||
@ -159,15 +172,21 @@ function isDirectUploadRule(rule: CORSRule, origins: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function isCorsApiUnsupported(error: unknown) {
|
||||
const value = error as { Code?: string; name?: string; message?: string } | null;
|
||||
const code = String(value?.Code || value?.name || "");
|
||||
const message = String(value?.message || "");
|
||||
return code === "NotImplemented" || /not implemented/i.test(message);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await resolveS3Config();
|
||||
if (!config) {
|
||||
console.log("[direct-upload-cors] skip: no S3-compatible upload target");
|
||||
const configs = await resolveS3Configs();
|
||||
if (configs.length === 0) {
|
||||
console.log("[direct-upload-cors] skip: no S3-compatible upload targets");
|
||||
return;
|
||||
}
|
||||
|
||||
const origins = readOrigins();
|
||||
const client = createS3Client(config);
|
||||
const directRule: CORSRule = {
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
|
||||
@ -175,41 +194,77 @@ async function main() {
|
||||
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
|
||||
MaxAgeSeconds: 3600,
|
||||
};
|
||||
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
|
||||
|
||||
let existingRules: CORSRule[] = [];
|
||||
try {
|
||||
const current = await client.send(
|
||||
new GetBucketCorsCommand({ Bucket: config.bucket })
|
||||
);
|
||||
existingRules = current.CORSRules || [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
for (const config of configs) {
|
||||
const client = createS3Client(config);
|
||||
let existingRules: CORSRule[] = [];
|
||||
try {
|
||||
const current = await client.send(
|
||||
new GetBucketCorsCommand({ Bucket: config.bucket })
|
||||
);
|
||||
existingRules = current.CORSRules || [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
|
||||
{
|
||||
name: config.name,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mergedRules = [
|
||||
directRule,
|
||||
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
|
||||
]
|
||||
.slice(0, 100)
|
||||
.map(normalizeRule);
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: config.bucket,
|
||||
CORSConfiguration: {
|
||||
CORSRules: mergedRules,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log("[direct-upload-cors] configured", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
origins,
|
||||
ruleCount: mergedRules.length,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCorsApiUnsupported(error)) {
|
||||
console.warn("[direct-upload-cors] bucket CORS API unsupported; leaving provider/global CORS in place", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
failures.push({ config, error });
|
||||
console.error("[direct-upload-cors] configure bucket failed", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedRules = [
|
||||
directRule,
|
||||
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
|
||||
]
|
||||
.slice(0, 100)
|
||||
.map(normalizeRule);
|
||||
|
||||
await client.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: config.bucket,
|
||||
CORSConfiguration: {
|
||||
CORSRules: mergedRules,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log("[direct-upload-cors] configured", {
|
||||
bucket: config.bucket,
|
||||
origins,
|
||||
ruleCount: mergedRules.length,
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to configure direct upload CORS for ${failures.length} bucket(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@ -6,12 +6,13 @@ import {
|
||||
S3Client,
|
||||
type LifecycleRule,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
loadEnv({ path: ".env.local" });
|
||||
loadEnv();
|
||||
|
||||
type S3UploadConfig = {
|
||||
name?: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
@ -65,7 +66,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
||||
|
||||
if (
|
||||
!raw ||
|
||||
raw.type !== "s3" ||
|
||||
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
|
||||
raw.isEnabled !== true ||
|
||||
!endpoint ||
|
||||
!bucket ||
|
||||
@ -76,12 +77,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
||||
}
|
||||
|
||||
return {
|
||||
name: raw.name ? String(raw.name) : undefined,
|
||||
endpoint,
|
||||
region: String(raw.region || "auto").trim() || "auto",
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
provider: raw.provider === "qiniu-kodo" ? "qiniu-kodo" : "s3",
|
||||
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
|
||||
};
|
||||
}
|
||||
|
||||
@ -98,6 +100,7 @@ function readEnvS3Config(): S3UploadConfig | null {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Environment S3",
|
||||
endpoint,
|
||||
region: process.env.S3_REGION?.trim() || "auto",
|
||||
bucket,
|
||||
@ -106,24 +109,31 @@ function readEnvS3Config(): S3UploadConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveS3Config() {
|
||||
async function resolveS3Configs() {
|
||||
const [{ db }, { storageConfig }] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("@/lib/db/schema"),
|
||||
]);
|
||||
const [defaultConfig] = await db
|
||||
const storageConfigs = await db
|
||||
.select()
|
||||
.from(storageConfig)
|
||||
.where(
|
||||
and(
|
||||
eq(storageConfig.type, "s3"),
|
||||
eq(storageConfig.isDefault, true),
|
||||
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
|
||||
eq(storageConfig.isEnabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
return toS3Config(defaultConfig) || readEnvS3Config();
|
||||
const configs = storageConfigs
|
||||
.map(toS3Config)
|
||||
.filter((config): config is S3UploadConfig => Boolean(config));
|
||||
const envConfig = readEnvS3Config();
|
||||
|
||||
if (configs.length === 0 && envConfig) {
|
||||
return [envConfig];
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
function readAbortDays() {
|
||||
@ -148,62 +158,123 @@ function normalizeRule(rule: LifecycleRule): LifecycleRule {
|
||||
};
|
||||
}
|
||||
|
||||
function isLifecycleApiUnsupported(error: unknown) {
|
||||
const value = error as { Code?: string; name?: string; message?: string } | null;
|
||||
const code = String(value?.Code || value?.name || "");
|
||||
const message = String(value?.message || "");
|
||||
return (
|
||||
code === "NotImplemented" ||
|
||||
/not implemented/i.test(message) ||
|
||||
/not well-formed/i.test(message) ||
|
||||
/validate against/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await resolveS3Config();
|
||||
if (!config) {
|
||||
console.log("[multipart-lifecycle] skip: no S3-compatible upload target");
|
||||
const configs = await resolveS3Configs();
|
||||
if (configs.length === 0) {
|
||||
console.log("[multipart-lifecycle] skip: no S3-compatible upload targets");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createS3Client(config);
|
||||
const prefix = readRulePrefix();
|
||||
const abortDays = readAbortDays();
|
||||
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
|
||||
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
|
||||
let configuredCount = 0;
|
||||
|
||||
let existingRules: LifecycleRule[] = [];
|
||||
try {
|
||||
const current = await client.send(
|
||||
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
|
||||
);
|
||||
existingRules = current.Rules || [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
for (const config of configs) {
|
||||
if (config.provider === "qiniu-kodo") {
|
||||
console.log("[multipart-lifecycle] skip bucket: qiniu-kodo lifecycle is not configured through S3 compatibility", {
|
||||
name: config.name,
|
||||
bucket: config.bucket,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = createS3Client(config);
|
||||
let existingRules: LifecycleRule[] = [];
|
||||
try {
|
||||
const current = await client.send(
|
||||
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
|
||||
);
|
||||
existingRules = current.Rules || [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
|
||||
{
|
||||
name: config.name,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const cleanupRule: LifecycleRule = {
|
||||
ID: ruleId,
|
||||
Status: "Enabled",
|
||||
Filter: { Prefix: prefix },
|
||||
AbortIncompleteMultipartUpload: {
|
||||
DaysAfterInitiation: abortDays,
|
||||
},
|
||||
};
|
||||
|
||||
const mergedRules = [
|
||||
cleanupRule,
|
||||
...existingRules.filter((rule) => rule.ID !== ruleId),
|
||||
]
|
||||
.slice(0, 1000)
|
||||
.map(normalizeRule);
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
new PutBucketLifecycleConfigurationCommand({
|
||||
Bucket: config.bucket,
|
||||
LifecycleConfiguration: {
|
||||
Rules: mergedRules,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
configuredCount += 1;
|
||||
console.log("[multipart-lifecycle] configured", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
prefix,
|
||||
abortDays,
|
||||
ruleCount: mergedRules.length,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isLifecycleApiUnsupported(error)) {
|
||||
console.warn("[multipart-lifecycle] bucket lifecycle API unsupported; skipping cleanup rule", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
failures.push({ config, error });
|
||||
console.error("[multipart-lifecycle] configure bucket failed", {
|
||||
name: config.name,
|
||||
provider: config.provider,
|
||||
bucket: config.bucket,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupRule: LifecycleRule = {
|
||||
ID: ruleId,
|
||||
Status: "Enabled",
|
||||
Filter: { Prefix: prefix },
|
||||
AbortIncompleteMultipartUpload: {
|
||||
DaysAfterInitiation: abortDays,
|
||||
},
|
||||
};
|
||||
if (configuredCount === 0) {
|
||||
console.log("[multipart-lifecycle] no compatible buckets required lifecycle configuration");
|
||||
}
|
||||
|
||||
const mergedRules = [
|
||||
cleanupRule,
|
||||
...existingRules.filter((rule) => rule.ID !== ruleId),
|
||||
]
|
||||
.slice(0, 1000)
|
||||
.map(normalizeRule);
|
||||
|
||||
await client.send(
|
||||
new PutBucketLifecycleConfigurationCommand({
|
||||
Bucket: config.bucket,
|
||||
LifecycleConfiguration: {
|
||||
Rules: mergedRules,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log("[multipart-lifecycle] configured", {
|
||||
bucket: config.bucket,
|
||||
prefix,
|
||||
abortDays,
|
||||
ruleCount: mergedRules.length,
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to configure multipart lifecycle for ${failures.length} bucket(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@ -54,6 +54,11 @@ import {
|
||||
} from "@/lib/ai/runtime/canvas-agent-kernel";
|
||||
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
|
||||
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
|
||||
import {
|
||||
buildAgentPlanningBriefMarkdown,
|
||||
extractAgentTaskGroups,
|
||||
isAgentPlanningBriefMarkdown,
|
||||
} from "@/lib/ai/runtime/agent-planning-brief";
|
||||
import {
|
||||
buildRecentDialogueMemory,
|
||||
isRecentDialogueRecallRequest,
|
||||
@ -137,7 +142,9 @@ import {
|
||||
shouldBypassCanvasImagePointsPrecheckForAgentControl,
|
||||
} from "@/lib/canvas/agent-client-intent";
|
||||
import {
|
||||
getDirectAgentToolDisplayName,
|
||||
getDirectAgentToolLabel,
|
||||
getDirectAgentToolPrefaceFallback,
|
||||
getDirectAgentToolSuccessMessage,
|
||||
hasExplicitDirectAgentBatchImageEditIntent,
|
||||
isDirectAgentBatchImageEditCapability,
|
||||
@ -385,6 +392,39 @@ test.describe("canvas chat pure helpers", () => {
|
||||
expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12);
|
||||
});
|
||||
|
||||
test("builds market-grade planning brief for grouped image generation requests", () => {
|
||||
const request =
|
||||
"基于上传的潮玩角色图片,生成3组共12张图片,要求保持角色核心特征一致。第一组为4张动作表情夸张的半身表情包;第二组为4张不同场景的角色活动照;第三组为4张服装发型差异巨大的造型照,所有图片比例保持3:4。";
|
||||
|
||||
const groups = extractAgentTaskGroups(request);
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups.map((group) => group.count)).toEqual([4, 4, 4]);
|
||||
expect(groups[0].description).toContain("半身表情包");
|
||||
expect(groups[1].description).toContain("不同场景");
|
||||
expect(groups[2].description).toContain("服装发型");
|
||||
|
||||
const markdown = buildAgentPlanningBriefMarkdown({
|
||||
userText: request,
|
||||
intent: "image_gen",
|
||||
nextAction: "generate_image",
|
||||
preferredCapability: "generate_image",
|
||||
targetSource: "current_upload",
|
||||
hasImage: true,
|
||||
imageCount: 12,
|
||||
aspectRatio: "3:4",
|
||||
imageSize: "2K",
|
||||
});
|
||||
|
||||
expect(isAgentPlanningBriefMarkdown(markdown)).toBe(true);
|
||||
expect(markdown).toContain("### 意图分析");
|
||||
expect(markdown).toContain("拆成 3 组,共 12 张");
|
||||
expect(markdown).toContain("### 任务规划");
|
||||
expect(markdown).toContain("第1组:生成4张");
|
||||
expect(markdown).toContain("第2组:生成4张");
|
||||
expect(markdown).toContain("第3组:生成4张");
|
||||
expect(markdown).toContain("图片比例:3:4");
|
||||
});
|
||||
|
||||
test("keeps canvas message empty-history access scoped and explicit", () => {
|
||||
const canvasChatId = "11111111-1111-4111-8111-111111111111";
|
||||
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
|
||||
@ -1275,9 +1315,20 @@ test.describe("canvas chat pure helpers", () => {
|
||||
expect(editText).toContain("### 执行结果");
|
||||
expect(editText).toContain("- 已完成抠图处理,结果如下。");
|
||||
expect(editText).toContain("https://cdn.example.com/cutout.png");
|
||||
expect(editText).toContain("◈ 抠图");
|
||||
expect(editText).not.toContain("Agent工具:");
|
||||
expect(editText).toContain("替换背景");
|
||||
});
|
||||
|
||||
test("uses user-facing direct agent tool labels in fallbacks", () => {
|
||||
expect(getDirectAgentToolDisplayName("generic_edit")).toBe("图片编辑");
|
||||
expect(getDirectAgentToolDisplayName("local_edit")).toBe("局部编辑");
|
||||
expect(getDirectAgentToolPrefaceFallback("generic_edit")).toBe(
|
||||
"好的,我来帮你修改这张图,稍等我一下。"
|
||||
);
|
||||
expect(getDirectAgentToolPrefaceFallback("generic_edit")).not.toContain("Agent工具:");
|
||||
});
|
||||
|
||||
test("builds capability-aware agent progress node labels", () => {
|
||||
const selectingSteps = buildAgentProgressNodeSteps({
|
||||
stage: "planning",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user