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
|
const displayLogo = mounted
|
||||||
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||||||
: siteLogo;
|
: siteLogo;
|
||||||
const shouldInvertLogo =
|
|
||||||
mounted &&
|
|
||||||
resolvedTheme === "dark" &&
|
|
||||||
!siteLogoDark?.trim() &&
|
|
||||||
Boolean(siteLogo?.trim());
|
|
||||||
const hasThirdPartyLogin =
|
const hasThirdPartyLogin =
|
||||||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||||
(isGoogleEnabled && isGoogleConfigured);
|
(isGoogleEnabled && isGoogleConfigured);
|
||||||
@ -545,7 +540,7 @@ export default function Page() {
|
|||||||
<img
|
<img
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={siteName}
|
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}
|
) : null}
|
||||||
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
|
||||||
|
|||||||
@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const knownTitles = [
|
const knownTitles = [
|
||||||
|
"意图分析",
|
||||||
|
"任务规划",
|
||||||
"需求理解",
|
"需求理解",
|
||||||
"图片分析",
|
"图片分析",
|
||||||
"执行计划",
|
"执行计划",
|
||||||
|
|||||||
@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
|
|||||||
: "基于当前上传图片处理。";
|
: "基于当前上传图片处理。";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"### 需求理解",
|
"### 意图分析",
|
||||||
`- ${demand}`,
|
`- ${demand}`,
|
||||||
"### 图片分析",
|
"### 图片分析",
|
||||||
params.imageSummary
|
params.imageSummary
|
||||||
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
|
||||||
: `- ${sourceLine}`,
|
: `- ${sourceLine}`,
|
||||||
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
|
||||||
"### 执行计划",
|
"### 任务规划",
|
||||||
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
|
||||||
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
|
||||||
"### 输出设置",
|
"### 输出设置",
|
||||||
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"### 需求理解",
|
"### 意图分析",
|
||||||
`- ${demand}`,
|
`- ${demand}`,
|
||||||
"### 执行计划",
|
"### 任务规划",
|
||||||
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
"- 先锁定主体与场景,再细化风格、材质与光影。",
|
||||||
"### 输出设置",
|
"### 输出设置",
|
||||||
"- 默认单图输出,优先稳定命中。",
|
"- 默认单图输出,优先稳定命中。",
|
||||||
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
|
|||||||
|
|
||||||
export function hasStructuredAgentSections(message?: string): boolean {
|
export function hasStructuredAgentSections(message?: string): boolean {
|
||||||
if (!message) return false;
|
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,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallback.includes("### 任务规划\n")) {
|
||||||
|
return fallback.replace(
|
||||||
|
"### 任务规划\n",
|
||||||
|
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return fallback.replace(
|
return fallback.replace(
|
||||||
"### 执行计划\n",
|
"### 执行计划\n",
|
||||||
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
|
||||||
|
|||||||
@ -239,6 +239,8 @@ import {
|
|||||||
safePersistDirectAgentVideoFailedTurn,
|
safePersistDirectAgentVideoFailedTurn,
|
||||||
} from "@/lib/ai/runtime/direct-agent-working-state";
|
} from "@/lib/ai/runtime/direct-agent-working-state";
|
||||||
import {
|
import {
|
||||||
|
getDirectAgentToolDisplayName,
|
||||||
|
getDirectAgentToolPrefaceFallback,
|
||||||
getDirectAgentToolErrorMessage,
|
getDirectAgentToolErrorMessage,
|
||||||
getDirectAgentToolLabel,
|
getDirectAgentToolLabel,
|
||||||
getDirectAgentToolTargetMissingMessage,
|
getDirectAgentToolTargetMissingMessage,
|
||||||
@ -545,7 +547,7 @@ function writeCanvasOperationShadowChunk(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN =
|
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: {
|
function buildAgentExecutionPrefaceText(params: {
|
||||||
friendlyMessage?: string | null;
|
friendlyMessage?: string | null;
|
||||||
@ -8296,6 +8298,9 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability);
|
const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability);
|
||||||
|
const directToolDisplayName = getDirectAgentToolDisplayName(
|
||||||
|
directAgentToolCapability,
|
||||||
|
);
|
||||||
const directToolStream = createUIMessageStream({
|
const directToolStream = createUIMessageStream({
|
||||||
execute: async ({ writer }) => {
|
execute: async ({ writer }) => {
|
||||||
await writeAgentExecutionPrefaceMessage({
|
await writeAgentExecutionPrefaceMessage({
|
||||||
@ -8303,7 +8308,7 @@ export async function POST(request: Request) {
|
|||||||
chatId: id,
|
chatId: id,
|
||||||
label: `agent:${directAgentToolCapability}:preface`,
|
label: `agent:${directAgentToolCapability}:preface`,
|
||||||
friendlyMessage: smartAutoFriendlyMessage,
|
friendlyMessage: smartAutoFriendlyMessage,
|
||||||
fallback: `好的,我来帮你${directToolLabel},稍等我处理一下。`,
|
fallback: getDirectAgentToolPrefaceFallback(directAgentToolCapability),
|
||||||
});
|
});
|
||||||
writeAgentProgressChunk(
|
writeAgentProgressChunk(
|
||||||
writer,
|
writer,
|
||||||
@ -8318,7 +8323,7 @@ export async function POST(request: Request) {
|
|||||||
buildDirectAgentImageEditSelectProgress({
|
buildDirectAgentImageEditSelectProgress({
|
||||||
toolName: directAgentExecution.toolName,
|
toolName: directAgentExecution.toolName,
|
||||||
capability: directAgentToolCapability,
|
capability: directAgentToolCapability,
|
||||||
label: directToolLabel,
|
label: directToolDisplayName,
|
||||||
}),
|
}),
|
||||||
`agent:${directAgentToolCapability}:progress-select-tool`,
|
`agent:${directAgentToolCapability}:progress-select-tool`,
|
||||||
);
|
);
|
||||||
@ -8331,7 +8336,7 @@ export async function POST(request: Request) {
|
|||||||
buildDirectAgentImageEditExecutingProgress({
|
buildDirectAgentImageEditExecutingProgress({
|
||||||
toolName: directAgentExecution.toolName,
|
toolName: directAgentExecution.toolName,
|
||||||
capability: directAgentToolCapability,
|
capability: directAgentToolCapability,
|
||||||
label: directToolLabel,
|
label: directToolDisplayName,
|
||||||
}),
|
}),
|
||||||
`agent:${directAgentToolCapability}:progress-executing`,
|
`agent:${directAgentToolCapability}:progress-executing`,
|
||||||
);
|
);
|
||||||
@ -8480,7 +8485,7 @@ export async function POST(request: Request) {
|
|||||||
buildDirectAgentImageEditCompletedProgress({
|
buildDirectAgentImageEditCompletedProgress({
|
||||||
toolName: directAgentExecution.toolName,
|
toolName: directAgentExecution.toolName,
|
||||||
capability: directAgentToolCapability,
|
capability: directAgentToolCapability,
|
||||||
label: directToolLabel,
|
label: directToolDisplayName,
|
||||||
}),
|
}),
|
||||||
`agent:${directAgentToolCapability}:progress-completed`,
|
`agent:${directAgentToolCapability}:progress-completed`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -29,6 +29,10 @@ const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
|||||||
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
||||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||||
|
|
||||||
|
function elapsedMs(startedAt: number) {
|
||||||
|
return Date.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
const allowedDirectUploadTypes = new Set([
|
const allowedDirectUploadTypes = new Set([
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
@ -141,6 +145,7 @@ async function createPartUrls(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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) => {
|
const target = await resolveS3UploadTarget().catch((error) => {
|
||||||
logger.error("resolve_storage_target_failed", error);
|
logger.error("resolve_storage_target_failed", error);
|
||||||
return null;
|
return null;
|
||||||
@ -170,9 +185,17 @@ export async function POST(request: Request) {
|
|||||||
{ status: 409 }
|
{ 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 client = createS3Client(target.config);
|
||||||
const bucket = target.config.bucket;
|
const bucket = target.config.bucket;
|
||||||
|
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (payload.action === "initiate") {
|
if (payload.action === "initiate") {
|
||||||
@ -202,38 +225,51 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
||||||
|
const presignStartedAt = Date.now();
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
CacheControl: "public, max-age=31536000, immutable",
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
ACL: "public-read",
|
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
|
||||||
});
|
});
|
||||||
const url = await getSignedUrl(client, command, {
|
const url = await getSignedUrl(client, command, {
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
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({
|
return NextResponse.json({
|
||||||
mode: "single",
|
mode: "single",
|
||||||
key,
|
key,
|
||||||
url,
|
url,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": contentType,
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
"x-amz-acl": "public-read",
|
|
||||||
},
|
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
clientUploadId: payload.clientUploadId || null,
|
clientUploadId: payload.clientUploadId || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const multipartStartedAt = Date.now();
|
||||||
const createResult = await client.send(
|
const createResult = await client.send(
|
||||||
new CreateMultipartUploadCommand({
|
new CreateMultipartUploadCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
CacheControl: "public, max-age=31536000, immutable",
|
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,
|
uploadId,
|
||||||
partCount,
|
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({
|
return NextResponse.json({
|
||||||
mode: "multipart",
|
mode: "multipart",
|
||||||
@ -266,6 +311,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (payload.action === "abort") {
|
if (payload.action === "abort") {
|
||||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
|
const abortStartedAt = Date.now();
|
||||||
await client.send(
|
await client.send(
|
||||||
new AbortMultipartUploadCommand({
|
new AbortMultipartUploadCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@ -273,6 +319,11 @@ export async function POST(request: Request) {
|
|||||||
UploadId: payload.uploadId,
|
UploadId: payload.uploadId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
logger.info("direct_upload_multipart_aborted", {
|
||||||
|
elapsedMs: elapsedMs(abortStartedAt),
|
||||||
|
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,6 +337,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.uploadId) {
|
if (payload.uploadId) {
|
||||||
|
const completeMultipartStartedAt = Date.now();
|
||||||
const parts = [...(payload.parts || [])].sort(
|
const parts = [...(payload.parts || [])].sort(
|
||||||
(a, b) => a.PartNumber - b.PartNumber
|
(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(
|
const head = await client.send(
|
||||||
new HeadObjectCommand({
|
new HeadObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
logger.info("direct_upload_head_object_completed", {
|
||||||
|
elapsedMs: elapsedMs(headStartedAt),
|
||||||
|
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
|
||||||
const actualSize =
|
const actualSize =
|
||||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||||
@ -324,6 +387,7 @@ export async function POST(request: Request) {
|
|||||||
const url = buildS3PublicUrl(target.config, key);
|
const url = buildS3PublicUrl(target.config, key);
|
||||||
const resolvedContentType =
|
const resolvedContentType =
|
||||||
head.ContentType || contentType || getContentType(payload.filename);
|
head.ContentType || contentType || getContentType(payload.filename);
|
||||||
|
const assetStartedAt = Date.now();
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType: "image",
|
mediaType: "image",
|
||||||
@ -344,6 +408,13 @@ export async function POST(request: Request) {
|
|||||||
clientUploadId: payload.clientUploadId || null,
|
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({
|
await maybeEnqueueStorageMigrationTask({
|
||||||
uploaded: { url, pathname: key },
|
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,
|
normalizeOpenClawChatConfig,
|
||||||
OPENCLAW_CHAT_CONFIG_KEY,
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
} from "@/lib/openclaw-chat/config";
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
cancelClawdRequest,
|
||||||
|
clearClawdRequestCancellation,
|
||||||
|
isClawdRequestCanceled,
|
||||||
|
registerActiveClawdRequest,
|
||||||
|
unregisterActiveClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
import {
|
import {
|
||||||
buildOpenClawDisplayMessage,
|
buildOpenClawDisplayMessage,
|
||||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
@ -141,6 +149,10 @@ function getMetadataString(
|
|||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCanceledResponse() {
|
||||||
|
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
|
|
||||||
@ -172,6 +184,16 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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<
|
let spreadsheetMarkdownResult: Awaited<
|
||||||
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
|
||||||
> | null = null;
|
> | null = null;
|
||||||
@ -200,6 +222,10 @@ export async function POST(request: Request) {
|
|||||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
|
return createCanceledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
if (!existingChat) {
|
if (!existingChat) {
|
||||||
await saveChat({
|
await saveChat({
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
@ -209,7 +235,6 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessageId = parsed.userMessageId || generateUUID();
|
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
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 upstreamPayload: unknown = null;
|
||||||
let upstreamStatus = 502;
|
let upstreamStatus = 502;
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
|
return createCanceledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||||
const timeout = setTimeout(() => controller.abort(), 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, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -266,7 +300,11 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).finally(() => clearTimeout(timeout));
|
}).finally(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||||
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
|
});
|
||||||
|
|
||||||
upstreamStatus = response.status;
|
upstreamStatus = response.status;
|
||||||
upstreamPayload = await response.json().catch(() => null);
|
upstreamPayload = await response.json().catch(() => null);
|
||||||
@ -287,8 +325,19 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isAbortError =
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
error instanceof Error && error.name === "AbortError";
|
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);
|
console.error("[ClawdChat API] OpenClaw request failed:", error);
|
||||||
return NextResponse.json(
|
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(
|
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||||
() => []
|
() => []
|
||||||
);
|
);
|
||||||
@ -366,6 +419,7 @@ export async function POST(request: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||||
|
clearClawdRequestCancellation(cancelKey);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
@ -8,7 +8,6 @@ export const dynamic = "force-dynamic";
|
|||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
const DEFAULT_CONTENT_TYPE = "image/x-icon";
|
const DEFAULT_CONTENT_TYPE = "image/x-icon";
|
||||||
const DARK_FAVICON_CONTENT_TYPE = "image/png";
|
|
||||||
const FETCH_TIMEOUT_MS = 10_000;
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
function noStoreHeaders(contentType: string) {
|
function noStoreHeaders(contentType: string) {
|
||||||
@ -71,86 +70,16 @@ function uniqueNonEmptyValues(values: string[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLocalFallbackFavicon(preferDark = false) {
|
async function readLocalFallbackFavicon() {
|
||||||
const file = await readFile(
|
const file = await readFile(
|
||||||
join(process.cwd(), "public", "default-favicon.ico")
|
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, {
|
return new NextResponse(file, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
|
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) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const requestUrl = new URL(request.url);
|
const requestUrl = new URL(request.url);
|
||||||
@ -159,20 +88,14 @@ export async function GET(request: Request) {
|
|||||||
const configuredFavicon = String(settings?.favicon || "").trim();
|
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||||
const configuredLightLogo = String(settings?.siteLogo || "").trim();
|
const configuredLightLogo = String(settings?.siteLogo || "").trim();
|
||||||
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
|
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
|
||||||
const rawFaviconCandidates = preferDark
|
const rawFaviconCandidates = configuredFavicon
|
||||||
? uniqueNonEmptyValues([
|
? [configuredFavicon]
|
||||||
configuredFavicon,
|
: preferDark
|
||||||
configuredDarkLogo,
|
? uniqueNonEmptyValues([configuredDarkLogo, configuredLightLogo])
|
||||||
configuredLightLogo,
|
: uniqueNonEmptyValues([configuredLightLogo, configuredDarkLogo]);
|
||||||
])
|
|
||||||
: uniqueNonEmptyValues([
|
|
||||||
configuredFavicon,
|
|
||||||
configuredLightLogo,
|
|
||||||
configuredDarkLogo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (rawFaviconCandidates.length === 0) {
|
if (rawFaviconCandidates.length === 0) {
|
||||||
return await readLocalFallbackFavicon(preferDark);
|
return await readLocalFallbackFavicon();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rawFaviconUrl of rawFaviconCandidates) {
|
for (const rawFaviconUrl of rawFaviconCandidates) {
|
||||||
@ -220,42 +143,15 @@ export async function GET(request: Request) {
|
|||||||
const contentType =
|
const contentType =
|
||||||
upstream.headers.get("content-type") || inferContentType(faviconUrl);
|
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, {
|
return new NextResponse(buffer, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: noStoreHeaders(contentType),
|
headers: noStoreHeaders(contentType),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferDark) {
|
return await readLocalFallbackFavicon();
|
||||||
const fallback = await renderDarkFallbackFavicon().catch(() => null);
|
|
||||||
if (fallback) {
|
|
||||||
return new NextResponse(fallback, {
|
|
||||||
status: 200,
|
|
||||||
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await readLocalFallbackFavicon(preferDark);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[favicon] Failed to proxy favicon:", error);
|
console.error("[favicon] Failed to proxy favicon:", error);
|
||||||
const preferDark = new URL(request.url).searchParams.get("theme") === "dark";
|
return await readLocalFallbackFavicon();
|
||||||
return await readLocalFallbackFavicon(preferDark);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9640,10 +9640,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
||||||
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
: lightCanvasBrandLogo || darkCanvasBrandLogo
|
||||||
).trim();
|
).trim();
|
||||||
const shouldInvertCanvasBrandLogo =
|
|
||||||
isDarkCanvasTheme &&
|
|
||||||
Boolean(canvasBrandLogo) &&
|
|
||||||
!hasSeparateDarkCanvasBrandLogo;
|
|
||||||
|
|
||||||
const brandEntry = (
|
const brandEntry = (
|
||||||
<div
|
<div
|
||||||
@ -9658,11 +9654,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
{canvasBrandLogo ? (
|
{canvasBrandLogo ? (
|
||||||
<img
|
<img
|
||||||
alt={siteSettingsData?.siteName || "返回首页"}
|
alt={siteSettingsData?.siteName || "返回首页"}
|
||||||
className={cn(
|
className="h-7 w-7 object-contain"
|
||||||
"h-7 w-7 object-contain",
|
|
||||||
shouldInvertCanvasBrandLogo && "brightness-0 invert",
|
|
||||||
!hasSeparateDarkCanvasBrandLogo && "dark:brightness-0 dark:invert"
|
|
||||||
)}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={canvasBrandLogo}
|
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.",
|
"AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
|
||||||
url: "/api/favicon?theme=dark",
|
|
||||||
media: "(prefers-color-scheme: dark)",
|
|
||||||
sizes: "any",
|
|
||||||
type: "image/png",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: "/api/favicon",
|
url: "/api/favicon",
|
||||||
media: "(prefers-color-scheme: light)",
|
|
||||||
sizes: "any",
|
sizes: "any",
|
||||||
},
|
},
|
||||||
{ url: "/api/favicon?theme=dark", sizes: "any", type: "image/png" },
|
|
||||||
{ url: "/api/favicon", type: "image/x-icon" },
|
|
||||||
],
|
],
|
||||||
apple: "/api/favicon",
|
apple: "/api/favicon",
|
||||||
shortcut: "/api/favicon",
|
shortcut: "/api/favicon",
|
||||||
|
|||||||
@ -53,7 +53,6 @@ export function AgentCanvasTopNavDark({
|
|||||||
}: AgentCanvasTopNavDarkProps) {
|
}: AgentCanvasTopNavDarkProps) {
|
||||||
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
|
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
|
||||||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||||||
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
|
<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
|
<img
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={resolvedSiteName}
|
alt={resolvedSiteName}
|
||||||
className={cn(
|
className="h-8 w-8 shrink-0 object-contain"
|
||||||
"h-8 w-8 shrink-0 object-contain",
|
|
||||||
shouldInvertLogo && "brightness-0 invert"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">
|
<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 resolvedSiteName = resolveSiteName(siteName);
|
||||||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||||||
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex min-w-0 items-center gap-2.5">
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
{displayLogo ? (
|
{displayLogo ? (
|
||||||
<img
|
<img
|
||||||
alt={resolvedSiteName}
|
alt={resolvedSiteName}
|
||||||
className={cn(
|
className="h-8 w-8 shrink-0 object-contain"
|
||||||
"h-8 w-8 shrink-0 object-contain",
|
|
||||||
shouldInvertLogo && "brightness-0 invert"
|
|
||||||
)}
|
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -33,9 +33,7 @@ export function CanvasLogoLoadingView({
|
|||||||
alt={resolvedSiteName}
|
alt={resolvedSiteName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[34px] w-[34px] object-contain",
|
"h-[34px] w-[34px] object-contain",
|
||||||
hasSeparateDarkLogo
|
hasSeparateDarkLogo && "dark:hidden"
|
||||||
? "dark:hidden"
|
|
||||||
: "dark:brightness-0 dark:invert"
|
|
||||||
)}
|
)}
|
||||||
src={lightLogo}
|
src={lightLogo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10193,7 +10193,7 @@ export function ImageNode({
|
|||||||
createPortal(
|
createPortal(
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@ -47,6 +47,8 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
|
|||||||
|
|
||||||
const IMAGE_ANALYSIS_TITLE = "图片分析";
|
const IMAGE_ANALYSIS_TITLE = "图片分析";
|
||||||
const AGENT_SECTION_TITLES = new Set([
|
const AGENT_SECTION_TITLES = new Set([
|
||||||
|
"意图分析",
|
||||||
|
"任务规划",
|
||||||
"需求理解",
|
"需求理解",
|
||||||
IMAGE_ANALYSIS_TITLE,
|
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
|
<ImagePreview
|
||||||
key={key}
|
key={key}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
compactGrid={compactGrid}
|
||||||
onImageEdit={onImageEdit}
|
onImageEdit={onImageEdit}
|
||||||
onDrawingComplete={onDrawingComplete}
|
onDrawingComplete={onDrawingComplete}
|
||||||
onImageEnhance={onImageEnhance}
|
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 => {
|
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
|
||||||
if (!text.trim()) return null;
|
if (!text.trim()) return null;
|
||||||
|
|
||||||
@ -935,20 +986,31 @@ export const Response = ({
|
|||||||
const hasImageSegments = segments.some((segment) => segment.type === "image");
|
const hasImageSegments = segments.some((segment) => segment.type === "image");
|
||||||
|
|
||||||
if (hasImageSegments) {
|
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") {
|
if (segment.type === "image") {
|
||||||
return renderImagePreview(
|
pendingImages.push({
|
||||||
segment.src,
|
alt: segment.alt,
|
||||||
segment.alt,
|
src: segment.src,
|
||||||
`${segment.src}-${processingKey}-${keyPrefix}-${index}`
|
});
|
||||||
);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!segment.value.trim()) {
|
if (!segment.value.trim()) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
flushImages(index);
|
||||||
|
nodes.push(
|
||||||
<Streamdown
|
<Streamdown
|
||||||
className={streamdownClassName}
|
className={streamdownClassName}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
@ -958,6 +1020,8 @@ export const Response = ({
|
|||||||
</Streamdown>
|
</Streamdown>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
flushImages(segments.length);
|
||||||
|
return nodes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,24 +5,12 @@ import { useEffect } from "react";
|
|||||||
export function FaviconUpdater() {
|
export function FaviconUpdater() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isDisposed = false;
|
let isDisposed = false;
|
||||||
const mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
|
const getFaviconUrl = () => "/api/favicon";
|
||||||
|
|
||||||
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 updateFavicon = () => {
|
const updateFavicon = () => {
|
||||||
if (isDisposed) return;
|
if (isDisposed) return;
|
||||||
|
|
||||||
const urlWithTheme = getFaviconUrl();
|
const urlWithTheme = getFaviconUrl();
|
||||||
const iconType = urlWithTheme.includes("theme=dark")
|
|
||||||
? "image/png"
|
|
||||||
: "image/x-icon";
|
|
||||||
const href =
|
const href =
|
||||||
`${urlWithTheme}${urlWithTheme.includes("?") ? "&" : "?"}v=${Date.now()}`;
|
`${urlWithTheme}${urlWithTheme.includes("?") ? "&" : "?"}v=${Date.now()}`;
|
||||||
const managedSelector =
|
const managedSelector =
|
||||||
@ -35,12 +23,12 @@ export function FaviconUpdater() {
|
|||||||
link.href = href;
|
link.href = href;
|
||||||
link.setAttribute("data-managed-favicon", "true");
|
link.setAttribute("data-managed-favicon", "true");
|
||||||
if (link.rel === "icon" || link.rel === "shortcut icon") {
|
if (link.rel === "icon" || link.rel === "shortcut icon") {
|
||||||
link.type = iconType;
|
link.removeAttribute("type");
|
||||||
link.setAttribute("sizes", "any");
|
link.setAttribute("sizes", "any");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ensureLink = (rel: string, type?: string) => {
|
const ensureLink = (rel: string) => {
|
||||||
let link = document.querySelector(
|
let link = document.querySelector(
|
||||||
`link[rel="${rel}"]`
|
`link[rel="${rel}"]`
|
||||||
) as HTMLLinkElement | null;
|
) as HTMLLinkElement | null;
|
||||||
@ -51,17 +39,15 @@ export function FaviconUpdater() {
|
|||||||
}
|
}
|
||||||
link.href = href;
|
link.href = href;
|
||||||
link.setAttribute("data-managed-favicon", "true");
|
link.setAttribute("data-managed-favicon", "true");
|
||||||
if (type) {
|
link.removeAttribute("type");
|
||||||
link.type = type;
|
|
||||||
}
|
|
||||||
if (rel === "icon") {
|
if (rel === "icon") {
|
||||||
link.setAttribute("sizes", "any");
|
link.setAttribute("sizes", "any");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ensureLink("icon", iconType);
|
ensureLink("icon");
|
||||||
ensureLink("apple-touch-icon");
|
ensureLink("apple-touch-icon");
|
||||||
ensureLink("shortcut icon", iconType);
|
ensureLink("shortcut icon");
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(updateFavicon);
|
const observer = new MutationObserver(updateFavicon);
|
||||||
@ -69,8 +55,6 @@ export function FaviconUpdater() {
|
|||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ["class"],
|
attributeFilter: ["class"],
|
||||||
});
|
});
|
||||||
mediaQuery?.addEventListener?.("change", updateFavicon);
|
|
||||||
|
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
updateFavicon();
|
updateFavicon();
|
||||||
});
|
});
|
||||||
@ -78,7 +62,6 @@ export function FaviconUpdater() {
|
|||||||
return () => {
|
return () => {
|
||||||
isDisposed = true;
|
isDisposed = true;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
mediaQuery?.removeEventListener?.("change", updateFavicon);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -677,8 +677,6 @@ export function LovartHome({
|
|||||||
resolvedTheme === "dark"
|
resolvedTheme === "dark"
|
||||||
? siteLogoDark || siteLogo || ""
|
? siteLogoDark || siteLogo || ""
|
||||||
: siteLogo || siteLogoDark || "";
|
: siteLogo || siteLogoDark || "";
|
||||||
const shouldInvertLogo =
|
|
||||||
resolvedTheme === "dark" && !siteLogoDark && Boolean(siteLogo);
|
|
||||||
|
|
||||||
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
|
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
|
||||||
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
|
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
|
||||||
@ -1564,7 +1562,6 @@ export function LovartHome({
|
|||||||
contentClassName="pl-[98px]"
|
contentClassName="pl-[98px]"
|
||||||
logo={displayLogo}
|
logo={displayLogo}
|
||||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
shouldInvertLogo={shouldInvertLogo}
|
|
||||||
showBrand={false}
|
showBrand={false}
|
||||||
siteName={siteName || "Lovart"}
|
siteName={siteName || "Lovart"}
|
||||||
>
|
>
|
||||||
@ -1814,7 +1811,6 @@ export function LovartHome({
|
|||||||
? "/settings/agent-workflow"
|
? "/settings/agent-workflow"
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
shouldInvertLogo={shouldInvertLogo}
|
|
||||||
siteLogo={displayLogo}
|
siteLogo={displayLogo}
|
||||||
siteName={siteName || "Lovart"}
|
siteName={siteName || "Lovart"}
|
||||||
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
||||||
@ -1852,7 +1848,6 @@ export function LovartHome({
|
|||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
removeFile={removeFile}
|
removeFile={removeFile}
|
||||||
setPrompt={setPrompt}
|
setPrompt={setPrompt}
|
||||||
shouldInvertLogo={shouldInvertLogo}
|
|
||||||
siteName={siteName}
|
siteName={siteName}
|
||||||
selectedModelId={selectedComposerModel.id}
|
selectedModelId={selectedComposerModel.id}
|
||||||
/>
|
/>
|
||||||
@ -1990,7 +1985,7 @@ export function LovartHome({
|
|||||||
<img
|
<img
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={siteName}
|
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">
|
<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;
|
prompt: string;
|
||||||
removeFile: (id: string) => void;
|
removeFile: (id: string) => void;
|
||||||
setPrompt: (value: string) => void;
|
setPrompt: (value: string) => void;
|
||||||
shouldInvertLogo: boolean;
|
|
||||||
siteName: string;
|
siteName: string;
|
||||||
modelOptions: ModelOption[];
|
modelOptions: ModelOption[];
|
||||||
onModelChange: (modelId: string) => void;
|
onModelChange: (modelId: string) => void;
|
||||||
@ -73,7 +72,6 @@ export function LovartHeroComposer({
|
|||||||
prompt,
|
prompt,
|
||||||
removeFile,
|
removeFile,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
shouldInvertLogo,
|
|
||||||
siteName,
|
siteName,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@ -104,7 +102,7 @@ export function LovartHeroComposer({
|
|||||||
<img
|
<img
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt={siteName}
|
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">
|
<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;
|
) => ReactNode;
|
||||||
settingsHref: string | null;
|
settingsHref: string | null;
|
||||||
shouldInvertLogo?: boolean;
|
|
||||||
siteLogo?: string | null;
|
siteLogo?: string | null;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
toolbox1Enabled: boolean;
|
toolbox1Enabled: boolean;
|
||||||
@ -71,7 +70,6 @@ export function LovartSidebar({
|
|||||||
productPlanningEnabled,
|
productPlanningEnabled,
|
||||||
renderIcon,
|
renderIcon,
|
||||||
settingsHref,
|
settingsHref,
|
||||||
shouldInvertLogo = false,
|
|
||||||
siteLogo,
|
siteLogo,
|
||||||
siteName,
|
siteName,
|
||||||
toolbox1Enabled,
|
toolbox1Enabled,
|
||||||
@ -184,12 +182,7 @@ export function LovartSidebar({
|
|||||||
<img
|
<img
|
||||||
src={siteLogo}
|
src={siteLogo}
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
className={[
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
"h-8 w-8 rounded-full object-cover",
|
|
||||||
shouldInvertLogo ? "brightness-0 invert" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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,
|
isProcessingExtend,
|
||||||
isProcessingAngleEdit,
|
isProcessingAngleEdit,
|
||||||
isProcessingWatermarkRemove,
|
isProcessingWatermarkRemove,
|
||||||
imageIndex
|
imageIndex,
|
||||||
|
compactGrid = false
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@ -64,6 +65,7 @@ export function ImagePreview({
|
|||||||
isProcessingAngleEdit?: boolean;
|
isProcessingAngleEdit?: boolean;
|
||||||
isProcessingWatermarkRemove?: boolean;
|
isProcessingWatermarkRemove?: boolean;
|
||||||
imageIndex?: number;
|
imageIndex?: number;
|
||||||
|
compactGrid?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||||||
@ -293,16 +295,26 @@ export function ImagePreview({
|
|||||||
return (
|
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
|
<img
|
||||||
src={previewSrc}
|
src={previewSrc}
|
||||||
alt="Generated Image"
|
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={{
|
style={{
|
||||||
maxWidth: `${maxWidth}px`,
|
maxWidth: compactGrid ? "none" : `${maxWidth}px`,
|
||||||
height: "auto",
|
height: compactGrid ? "100%" : "auto",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
objectFit: "contain"
|
objectFit: compactGrid ? "cover" : "contain"
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|||||||
@ -12,7 +12,6 @@ type BrandTopBarProps = {
|
|||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
onLogoClick?: () => void;
|
onLogoClick?: () => void;
|
||||||
position?: "fixed" | "sticky" | "static";
|
position?: "fixed" | "sticky" | "static";
|
||||||
shouldInvertLogo?: boolean;
|
|
||||||
showBrand?: boolean;
|
showBrand?: boolean;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
};
|
};
|
||||||
@ -25,7 +24,6 @@ export function BrandTopBar({
|
|||||||
logo,
|
logo,
|
||||||
onLogoClick,
|
onLogoClick,
|
||||||
position = "fixed",
|
position = "fixed",
|
||||||
shouldInvertLogo = false,
|
|
||||||
showBrand = true,
|
showBrand = true,
|
||||||
siteName,
|
siteName,
|
||||||
}: BrandTopBarProps) {
|
}: BrandTopBarProps) {
|
||||||
@ -61,10 +59,7 @@ export function BrandTopBar({
|
|||||||
{logo ? (
|
{logo ? (
|
||||||
<img
|
<img
|
||||||
alt={resolvedSiteName}
|
alt={resolvedSiteName}
|
||||||
className={cn(
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
"h-8 w-8 rounded-full object-cover",
|
|
||||||
shouldInvertLogo && "brightness-0 invert"
|
|
||||||
)}
|
|
||||||
src={logo}
|
src={logo}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -174,7 +174,7 @@ function filterAgentRoutingNoise(text: string): string {
|
|||||||
|
|
||||||
const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
|
const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
|
||||||
const ASSISTANT_TYPEWRITER_SECTION_REGEX =
|
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 ASSISTANT_TYPEWRITER_STORAGE_KEY = "assistant-typewriter-completed-keys-v1";
|
||||||
const MODEL_BADGE_LINE_REGEX =
|
const MODEL_BADGE_LINE_REGEX =
|
||||||
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im;
|
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im;
|
||||||
|
|||||||
@ -69,6 +69,48 @@ type PointsSummaryResponse = {
|
|||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
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) {
|
function isVideoProviderType(model?: Partial<DynamicModel> | null) {
|
||||||
return isVideoModelDescriptor({
|
return isVideoModelDescriptor({
|
||||||
providerType: model?.providerType,
|
providerType: model?.providerType,
|
||||||
@ -117,7 +159,10 @@ import { toast } from "sonner";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||||
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
|
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 { SelectItem } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@ -446,10 +491,71 @@ function PureMultimodalInput({
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const [videoSlotUploading, setVideoSlotUploading] = 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 () => {
|
const ensureImagePointsBeforeSend = useCallback(async () => {
|
||||||
if (!isImageModel || isVideoModel) return true;
|
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 {
|
try {
|
||||||
// 先压缩图片
|
// 先压缩图片
|
||||||
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
|
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
|
||||||
|
updateUploadQueueItem(uploadQueueItemId, {
|
||||||
|
progress: 2,
|
||||||
|
status: "preparing",
|
||||||
|
});
|
||||||
const processedFile = await compressImage(file);
|
const processedFile = await compressImage(file);
|
||||||
|
updateUploadQueueItem(uploadQueueItemId, {
|
||||||
|
progress: 6,
|
||||||
|
status: "uploading",
|
||||||
|
});
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", processedFile);
|
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) {
|
if (response.ok) {
|
||||||
|
updateUploadQueueItem(uploadQueueItemId, {
|
||||||
|
progress: 100,
|
||||||
|
status: "finalizing",
|
||||||
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const { url, pathname, contentType } = data;
|
const { url, pathname, contentType } = data;
|
||||||
|
|
||||||
@ -712,7 +840,7 @@ function PureMultimodalInput({
|
|||||||
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
|
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [compressImage]);
|
}, [compressImage, updateUploadQueueItem]);
|
||||||
|
|
||||||
const contextProps = useMemo(
|
const contextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -759,20 +887,30 @@ function PureMultimodalInput({
|
|||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const queueItem = createUploadQueueItem(file);
|
||||||
setVideoSlotUploading(slotIndex);
|
setVideoSlotUploading(slotIndex);
|
||||||
setUploadQueue((prev) => [...prev, file.name]);
|
setVideoSlotUploadItemIds((current) => ({
|
||||||
|
...current,
|
||||||
|
[slotIndex]: queueItem.id,
|
||||||
|
}));
|
||||||
|
setUploadQueue((prev) => [...prev, queueItem]);
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile(file);
|
const uploaded = await uploadFile(file, queueItem.id);
|
||||||
if (uploaded) {
|
if (uploaded) {
|
||||||
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
|
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setVideoSlotUploading(null);
|
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 = "";
|
event.currentTarget.value = "";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateAttachmentAtSlot, uploadFile]
|
[holdAndRemoveUploadQueueItems, updateAttachmentAtSlot, uploadFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 视频模式下的图片数量限制
|
// 🆕 视频模式下的图片数量限制
|
||||||
@ -879,11 +1017,14 @@ function PureMultimodalInput({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filesToUpload = files.slice(0, allowedCount);
|
const filesToUpload = files.slice(0, allowedCount);
|
||||||
|
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
|
||||||
|
|
||||||
setUploadQueue(filesToUpload.map((file) => file.name));
|
setUploadQueue((prev) => [...prev, ...queueItems]);
|
||||||
|
|
||||||
try {
|
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 uploadedAttachments = await Promise.all(uploadPromises);
|
||||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||||
(attachment): attachment is Attachment => attachment !== null
|
(attachment): attachment is Attachment => attachment !== null
|
||||||
@ -896,13 +1037,13 @@ function PureMultimodalInput({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading files!", error);
|
console.error("Error uploading files!", error);
|
||||||
} finally {
|
} finally {
|
||||||
setUploadQueue([]);
|
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enforceVideoImageLimit, setAttachments, uploadFile]
|
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
@ -923,19 +1064,24 @@ function PureMultimodalInput({
|
|||||||
// Prevent default paste behavior for images
|
// Prevent default paste behavior for images
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
setUploadQueue((prev) => [...prev, "已粘贴图片"]);
|
let queueItems: UploadQueueItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allowedCount = enforceVideoImageLimit(imageItems.length);
|
const allowedCount = enforceVideoImageLimit(imageItems.length);
|
||||||
if (allowedCount <= 0) {
|
if (allowedCount <= 0) {
|
||||||
setUploadQueue([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uploadPromises = imageItems
|
const filesToUpload = imageItems
|
||||||
.map((item) => item.getAsFile())
|
.map((item) => item.getAsFile())
|
||||||
.filter((file): file is File => file !== null)
|
.filter((file): file is File => file !== null)
|
||||||
.slice(0, allowedCount)
|
.slice(0, allowedCount);
|
||||||
.map((file) => uploadFile(file));
|
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 uploadedAttachments = await Promise.all(uploadPromises);
|
||||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||||
@ -953,10 +1099,10 @@ function PureMultimodalInput({
|
|||||||
console.error("Error uploading pasted images:", error);
|
console.error("Error uploading pasted images:", error);
|
||||||
toast.error("粘贴的图片上传失败");
|
toast.error("粘贴的图片上传失败");
|
||||||
} finally {
|
} finally {
|
||||||
setUploadQueue([]);
|
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enforceVideoImageLimit, setAttachments, uploadFile]
|
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add paste event listener to textarea
|
// Add paste event listener to textarea
|
||||||
@ -1022,20 +1168,20 @@ function PureMultimodalInput({
|
|||||||
|
|
||||||
const allowedCount = enforceVideoImageLimit(imageFiles.length);
|
const allowedCount = enforceVideoImageLimit(imageFiles.length);
|
||||||
if (allowedCount <= 0) {
|
if (allowedCount <= 0) {
|
||||||
setUploadQueue([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesToUpload = imageFiles.slice(0, allowedCount);
|
const filesToUpload = imageFiles.slice(0, allowedCount);
|
||||||
|
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
|
||||||
|
|
||||||
console.log("[拖拽] 开始处理文件:", imageFiles.length, "个");
|
console.log("[拖拽] 开始处理文件:", imageFiles.length, "个");
|
||||||
|
|
||||||
// 添加到上传队列
|
// 添加到上传队列
|
||||||
setUploadQueue((prev) => [...prev, ...filesToUpload.map((f) => f.name)]);
|
setUploadQueue((prev) => [...prev, ...queueItems]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadedAttachments = await Promise.all(
|
const uploadedAttachments = await Promise.all(
|
||||||
filesToUpload.map((file) => uploadFile(file))
|
filesToUpload.map((file, index) => uploadFile(file, queueItems[index]?.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
const validAttachments = uploadedAttachments.filter(
|
const validAttachments = uploadedAttachments.filter(
|
||||||
@ -1050,10 +1196,10 @@ function PureMultimodalInput({
|
|||||||
console.error("拖拽上传失败:", error);
|
console.error("拖拽上传失败:", error);
|
||||||
toast.error("拖拽的图片上传失败");
|
toast.error("拖拽的图片上传失败");
|
||||||
} finally {
|
} 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 && (
|
{videoFrameSlots.length > 0 && (
|
||||||
<div className="mb-2 flex flex-row gap-2">
|
<div className="mb-2 flex flex-row gap-2">
|
||||||
{videoFrameSlots.map((label, index) => {
|
{videoFrameSlots.map((label, index) => {
|
||||||
const slotAttachment = attachments[index];
|
const slotAttachment = attachments[index];
|
||||||
const disabled = index > 0 && !attachments[index - 1];
|
const disabled = index > 0 && !attachments[index - 1];
|
||||||
const isUploading = videoSlotUploading === index;
|
const isUploading = videoSlotUploading === index;
|
||||||
|
const uploadItem = isUploading
|
||||||
|
? uploadQueue.find((item) => item.id === videoSlotUploadItemIds[index])
|
||||||
|
: null;
|
||||||
|
const uploadProgress = uploadItem?.progress ?? 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`video-slot-${label}-${index}`}
|
key={`video-slot-${label}-${index}`}
|
||||||
@ -1196,10 +1374,22 @@ function PureMultimodalInput({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<>
|
||||||
<span className="text-xl leading-none">+</span>
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-sm">{isUploading ? "上传中" : label}</span>
|
<span className="text-xl leading-none">+</span>
|
||||||
</div>
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
@ -1284,15 +1474,17 @@ function PureMultimodalInput({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{uploadQueue.map((filename) => (
|
{uploadQueue.map((item) => (
|
||||||
<PreviewAttachment
|
<PreviewAttachment
|
||||||
attachment={{
|
attachment={{
|
||||||
url: "",
|
url: "",
|
||||||
name: filename,
|
name: item.name,
|
||||||
contentType: "",
|
contentType: "",
|
||||||
}}
|
}}
|
||||||
isUploading={true}
|
isUploading={true}
|
||||||
key={filename}
|
key={item.id}
|
||||||
|
uploadProgress={item.progress}
|
||||||
|
uploadStatus={item.status}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,8 +59,6 @@ export function PascalEditorFrame({
|
|||||||
const lightLogo = String(siteLogo || "").trim();
|
const lightLogo = String(siteLogo || "").trim();
|
||||||
return darkLogo || lightLogo;
|
return darkLogo || lightLogo;
|
||||||
}, [siteLogo, siteLogoDark]);
|
}, [siteLogo, siteLogoDark]);
|
||||||
const shouldInvertLogo =
|
|
||||||
!String(siteLogoDark || "").trim() && Boolean(String(siteLogo || "").trim());
|
|
||||||
const userEmail = String(user?.email || "").trim();
|
const userEmail = String(user?.email || "").trim();
|
||||||
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
|
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
|
||||||
const isGuest = guestRegex.test(userEmail);
|
const isGuest = guestRegex.test(userEmail);
|
||||||
@ -91,10 +89,7 @@ export function PascalEditorFrame({
|
|||||||
{displayLogo ? (
|
{displayLogo ? (
|
||||||
<img
|
<img
|
||||||
alt={resolvedSiteName}
|
alt={resolvedSiteName}
|
||||||
className={cn(
|
className="h-7 w-7 shrink-0 object-contain"
|
||||||
"h-7 w-7 shrink-0 object-contain",
|
|
||||||
shouldInvertLogo && "brightness-0 invert"
|
|
||||||
)}
|
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -22,9 +22,13 @@ export const PreviewAttachment = ({
|
|||||||
onAnnotate,
|
onAnnotate,
|
||||||
onDrawingComplete,
|
onDrawingComplete,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
|
uploadProgress,
|
||||||
|
uploadStatus,
|
||||||
}: {
|
}: {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
isUploading?: boolean;
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number;
|
||||||
|
uploadStatus?: "preparing" | "uploading" | "finalizing";
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
onDragEnd?: (e: React.DragEvent) => void;
|
onDragEnd?: (e: React.DragEvent) => void;
|
||||||
@ -40,6 +44,16 @@ export const PreviewAttachment = ({
|
|||||||
imageIndex?: number;
|
imageIndex?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const { name, url, contentType } = attachment;
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||||||
@ -164,7 +178,7 @@ export const PreviewAttachment = ({
|
|||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
{contentType?.startsWith("image") ? (
|
{!isUploading && contentType?.startsWith("image") ? (
|
||||||
<Image
|
<Image
|
||||||
alt={name ?? "图片附件"}
|
alt={name ?? "图片附件"}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
@ -180,10 +194,21 @@ export const PreviewAttachment = ({
|
|||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div
|
<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"
|
data-testid="input-attachment-loader"
|
||||||
>
|
>
|
||||||
<Loader size={16} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -150,7 +150,7 @@ export function TopNavbar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
|
// 彩色 logo 不能用滤镜自动反白;暗色主题只在配置了独立 dark logo 时切换。
|
||||||
const displayLogo =
|
const displayLogo =
|
||||||
resolvedTheme === "dark"
|
resolvedTheme === "dark"
|
||||||
? siteLogoDark && siteLogoDark.trim() !== ""
|
? siteLogoDark && siteLogoDark.trim() !== ""
|
||||||
@ -159,10 +159,6 @@ export function TopNavbar({
|
|||||||
: siteLogo && siteLogo.trim() !== ""
|
: siteLogo && siteLogo.trim() !== ""
|
||||||
? siteLogo
|
? siteLogo
|
||||||
: siteLogoDark;
|
: siteLogoDark;
|
||||||
const shouldInvertLogo =
|
|
||||||
resolvedTheme === "dark" &&
|
|
||||||
(!siteLogoDark || siteLogoDark.trim() === "") &&
|
|
||||||
Boolean(siteLogo && siteLogo.trim() !== "");
|
|
||||||
|
|
||||||
const getShortLabel = (label: string) => {
|
const getShortLabel = (label: string) => {
|
||||||
const trimmed = label.trim();
|
const trimmed = label.trim();
|
||||||
@ -287,7 +283,7 @@ export function TopNavbar({
|
|||||||
{displayLogo && (
|
{displayLogo && (
|
||||||
<img
|
<img
|
||||||
alt={siteName}
|
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}
|
src={displayLogo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -19,6 +19,10 @@ import {
|
|||||||
normalizeAgentThreadState,
|
normalizeAgentThreadState,
|
||||||
type AgentThreadState,
|
type AgentThreadState,
|
||||||
} from "@/lib/ai/runtime/agent-thread-state";
|
} from "@/lib/ai/runtime/agent-thread-state";
|
||||||
|
import {
|
||||||
|
buildAgentPlanningBriefMarkdown,
|
||||||
|
isAgentPlanningBriefMarkdown,
|
||||||
|
} from "@/lib/ai/runtime/agent-planning-brief";
|
||||||
import {
|
import {
|
||||||
getAiModelAgentCodingModelId,
|
getAiModelAgentCodingModelId,
|
||||||
isAiModelMultimodal,
|
isAiModelMultimodal,
|
||||||
@ -1153,17 +1157,59 @@ function buildStrategicFriendlyMessage(
|
|||||||
|
|
||||||
if (existingReply) {
|
if (existingReply) {
|
||||||
const isStructuredReply =
|
const isStructuredReply =
|
||||||
|
isAgentPlanningBriefMarkdown(existingReply) ||
|
||||||
|
/###\s*意图分析/.test(existingReply) ||
|
||||||
|
/###\s*任务规划/.test(existingReply) ||
|
||||||
/###\s*需求理解/.test(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) ||
|
/【\s*图片分析\s*】/.test(existingReply) ||
|
||||||
/【\s*执行计划\s*】/.test(existingReply);
|
/【\s*执行计划\s*】/.test(existingReply);
|
||||||
if (isStructuredReply || existingReply.length >= 24) {
|
if (isStructuredReply) {
|
||||||
return Promise.resolve(existingReply);
|
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(
|
return withTimeoutFallback(
|
||||||
generateAgentConversation(analysis, state),
|
generateAgentConversation(analysis, state),
|
||||||
AGENT_OPERATION_TIMEOUT_MS,
|
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(
|
export function findAgentToolCallArgs(
|
||||||
toolName: AgentPlannedToolCall["name"],
|
toolName: AgentPlannedToolCall["name"],
|
||||||
execution?: AgentPreparedExecution,
|
execution?: AgentPreparedExecution,
|
||||||
|
|||||||
@ -169,15 +169,23 @@ export async function executeDirectAgentImageGeneration(
|
|||||||
params.imageParams?.imageSize || params.defaultImageQualityParams?.imageSize;
|
params.imageParams?.imageSize || params.defaultImageQualityParams?.imageSize;
|
||||||
const imageCount =
|
const imageCount =
|
||||||
params.imageParams?.imageCount || params.defaultImageQualityParams?.imageCount;
|
params.imageParams?.imageCount || params.defaultImageQualityParams?.imageCount;
|
||||||
|
const shouldIgnoreModelMaxImages =
|
||||||
|
typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount > 1;
|
||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt,
|
prompt,
|
||||||
negativePrompt: params.imageParams?.negativePrompt,
|
negativePrompt: params.imageParams?.negativePrompt,
|
||||||
model: params.finalModelId,
|
model: params.finalModelId,
|
||||||
ownerKey: params.userId,
|
ownerKey: params.userId,
|
||||||
|
debugLabel: "direct-agent-image-generation",
|
||||||
|
debugMeta: {
|
||||||
|
agentIgnoreModelMaxImages: shouldIgnoreModelMaxImages,
|
||||||
|
requestedAgentImageCount: imageCount ?? null,
|
||||||
|
},
|
||||||
aspectRatio:
|
aspectRatio:
|
||||||
params.imageParams?.aspectRatio || params.defaultImageQualityParams?.aspectRatio,
|
params.imageParams?.aspectRatio || params.defaultImageQualityParams?.aspectRatio,
|
||||||
imageSize,
|
imageSize,
|
||||||
imageCount,
|
imageCount,
|
||||||
|
ignoreModelMaxImages: shouldIgnoreModelMaxImages,
|
||||||
referenceImages: params.referenceImageUrls,
|
referenceImages: params.referenceImageUrls,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
prependImageModelBadge,
|
prependImageModelBadge,
|
||||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||||
import {
|
import {
|
||||||
getDirectAgentToolLabel,
|
getDirectAgentToolDisplayName,
|
||||||
getDirectAgentToolSuccessMessage,
|
getDirectAgentToolSuccessMessage,
|
||||||
type DirectAgentToolCapability,
|
type DirectAgentToolCapability,
|
||||||
} from "@/lib/ai/runtime/direct-agent-execution";
|
} from "@/lib/ai/runtime/direct-agent-execution";
|
||||||
@ -73,7 +73,7 @@ export function buildDirectAgentAnalysisAssistantText(params: {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n"),
|
.join("\n\n"),
|
||||||
getDirectAgentToolLabel("analyze"),
|
getDirectAgentToolDisplayName("analyze"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ export function buildDirectAgentImageEditAssistantText(params: {
|
|||||||
}),
|
}),
|
||||||
followUp: getDirectAgentFollowUpMessage(params.capability),
|
followUp: getDirectAgentFollowUpMessage(params.capability),
|
||||||
}),
|
}),
|
||||||
getDirectAgentToolLabel(params.capability),
|
getDirectAgentToolDisplayName(params.capability),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,6 +139,6 @@ export function buildDirectAgentBatchImageEditAssistantText(params: {
|
|||||||
mediaMarkdown: buildAgentToolImagesMarkdown(params.results),
|
mediaMarkdown: buildAgentToolImagesMarkdown(params.results),
|
||||||
followUp: getDirectAgentFollowUpMessage(params.capability),
|
followUp: getDirectAgentFollowUpMessage(params.capability),
|
||||||
}),
|
}),
|
||||||
getDirectAgentToolLabel(params.capability),
|
getDirectAgentToolDisplayName(params.capability),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ export interface CanvasGenerateImageRequest {
|
|||||||
ownerKey?: string;
|
ownerKey?: string;
|
||||||
debugLabel?: string;
|
debugLabel?: string;
|
||||||
debugMeta?: Record<string, unknown>;
|
debugMeta?: Record<string, unknown>;
|
||||||
|
ignoreModelMaxImages?: boolean;
|
||||||
localEditComposite?: CanvasLocalEditCompositeConfig;
|
localEditComposite?: CanvasLocalEditCompositeConfig;
|
||||||
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
|
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
|
||||||
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
|
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
|
||||||
@ -3949,12 +3950,14 @@ function normalizeCanvasImageParamsByModel(params: {
|
|||||||
imageCount: number;
|
imageCount: number;
|
||||||
imageSize: string;
|
imageSize: string;
|
||||||
imageGenConfig: CanvasImageGenConfig | null | undefined;
|
imageGenConfig: CanvasImageGenConfig | null | undefined;
|
||||||
|
ignoreModelMaxImages?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const maxImages =
|
const configuredMaxImages =
|
||||||
typeof params.imageGenConfig?.maxImages === "number" &&
|
typeof params.imageGenConfig?.maxImages === "number" &&
|
||||||
Number.isFinite(params.imageGenConfig.maxImages)
|
Number.isFinite(params.imageGenConfig.maxImages)
|
||||||
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
|
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
|
||||||
: 12;
|
: 12;
|
||||||
|
const maxImages = params.ignoreModelMaxImages ? 12 : configuredMaxImages;
|
||||||
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
|
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
|
||||||
|
|
||||||
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
|
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
|
||||||
@ -4470,6 +4473,7 @@ export async function generateCanvasImage(
|
|||||||
imageCount: requestedImageCount,
|
imageCount: requestedImageCount,
|
||||||
imageSize: requestedImageSize,
|
imageSize: requestedImageSize,
|
||||||
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
|
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
|
||||||
|
ignoreModelMaxImages: request.ignoreModelMaxImages === true,
|
||||||
});
|
});
|
||||||
const imageSize = normalizedImageParams.imageSize;
|
const imageSize = normalizedImageParams.imageSize;
|
||||||
const imageCount =
|
const imageCount =
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
const DEFAULT_MAX_RETRIES = 3;
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
const MAX_BACKOFF_MS = 8000;
|
const MAX_BACKOFF_MS = 8000;
|
||||||
const MAX_CONCURRENT_UPLOADS = 3;
|
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 DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
|
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
|
||||||
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
|
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
|
||||||
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
|
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([
|
const RETRYABLE_UPLOAD_STATUSES = new Set([
|
||||||
408,
|
408,
|
||||||
@ -36,6 +42,23 @@ let activeUploadCount = 0;
|
|||||||
const uploadQueue: Array<() => void> = [];
|
const uploadQueue: Array<() => void> = [];
|
||||||
let directUploadSuppressedUntil = 0;
|
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 = {
|
type UploadFormFile = {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
filename: string;
|
filename: string;
|
||||||
@ -71,6 +94,12 @@ function createAbortError() {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTimeoutError(message: string) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = "TimeoutError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number, signal?: AbortSignal) {
|
function sleep(ms: number, signal?: AbortSignal) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return Promise.reject(createAbortError());
|
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) {
|
function cloneFormData(formData: FormData) {
|
||||||
const copy = new 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>(
|
async function postDirectUploadAction<T>(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
timeoutMs = DIRECT_UPLOAD_ACTION_TIMEOUT_MS
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch("/api/files/direct-upload", {
|
const response = await fetchWithControlTimeout(
|
||||||
method: "POST",
|
"/api/files/direct-upload",
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
timeoutMs,
|
||||||
signal,
|
signal
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(String(await readErrorPayload(response)));
|
throw new Error(String(await readErrorPayload(response)));
|
||||||
@ -283,18 +416,167 @@ async function postDirectUploadAction<T>(
|
|||||||
return (await response.json()) as 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(
|
async function fetchDirectPartWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: {
|
||||||
|
method: string;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
body: XMLHttpRequestBodyInit;
|
||||||
|
},
|
||||||
maxRetries: number,
|
maxRetries: number,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onProgress?: (loaded: number, total: number) => void,
|
||||||
|
options?: { stallTimeoutMs?: number }
|
||||||
) {
|
) {
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await xhrUpload(
|
||||||
...init,
|
url,
|
||||||
signal,
|
init,
|
||||||
});
|
{
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
stallTimeoutMs: options?.stallTimeoutMs,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
||||||
return response;
|
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: {
|
async function uploadMultipartParts(params: {
|
||||||
file: UploadFormFile;
|
file: UploadFormFile;
|
||||||
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
|
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
onProgress?: UploadProgressHandler;
|
||||||
}) {
|
}) {
|
||||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
|
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
|
||||||
|
const partLoadedBytes = new Map<number, number>();
|
||||||
let nextIndex = 0;
|
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() {
|
async function worker() {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const part = params.upload.parts[nextIndex];
|
const part = params.upload.parts[nextIndex];
|
||||||
@ -336,13 +682,19 @@ async function uploadMultipartParts(params: {
|
|||||||
body,
|
body,
|
||||||
},
|
},
|
||||||
params.maxRetries,
|
params.maxRetries,
|
||||||
params.signal
|
params.signal,
|
||||||
|
(loaded, total) => {
|
||||||
|
reportPartProgress(part.partNumber, loaded, total || body.size);
|
||||||
|
},
|
||||||
|
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Direct multipart upload failed: ${response.status}`);
|
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");
|
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
||||||
if (!etag) {
|
if (!etag) {
|
||||||
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
|
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(
|
async function fetchDirectUpload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
maxRetries: number,
|
maxRetries: number,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onProgress?: UploadProgressHandler
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
const file = getUploadFormFile(formData);
|
const file = getUploadFormFile(formData);
|
||||||
if (!file || !shouldTryDirectUpload(file)) {
|
if (!file || !shouldTryDirectUpload(file)) {
|
||||||
@ -380,6 +733,9 @@ async function fetchDirectUpload(
|
|||||||
| null = null;
|
| null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
|
||||||
|
percent: 1,
|
||||||
|
});
|
||||||
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
|
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
|
||||||
{
|
{
|
||||||
action: "initiate",
|
action: "initiate",
|
||||||
@ -389,10 +745,14 @@ async function fetchDirectUpload(
|
|||||||
purpose: file.purpose,
|
purpose: file.purpose,
|
||||||
clientUploadId: file.clientUploadId,
|
clientUploadId: file.clientUploadId,
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
if (upload.mode === "single") {
|
if (upload.mode === "single") {
|
||||||
|
emitUploadProgress(onProgress, 0, file.blob.size, "direct", {
|
||||||
|
percent: 2,
|
||||||
|
});
|
||||||
const response = await fetchDirectPartWithRetry(
|
const response = await fetchDirectPartWithRetry(
|
||||||
upload.url,
|
upload.url,
|
||||||
{
|
{
|
||||||
@ -403,13 +763,20 @@ async function fetchDirectUpload(
|
|||||||
body: file.blob,
|
body: file.blob,
|
||||||
},
|
},
|
||||||
maxRetries,
|
maxRetries,
|
||||||
signal
|
signal,
|
||||||
|
(loaded, total) => {
|
||||||
|
emitUploadProgress(onProgress, loaded, total || file.blob.size, "direct");
|
||||||
|
},
|
||||||
|
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Direct upload failed: ${response.status}`);
|
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>>(
|
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
||||||
{
|
{
|
||||||
action: "complete",
|
action: "complete",
|
||||||
@ -423,15 +790,25 @@ async function fetchDirectUpload(
|
|||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.json(payload);
|
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
return createJsonResponse(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
multipartUpload = upload;
|
multipartUpload = upload;
|
||||||
|
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
|
||||||
|
percent: 2,
|
||||||
|
});
|
||||||
const parts = await uploadMultipartParts({
|
const parts = await uploadMultipartParts({
|
||||||
file,
|
file,
|
||||||
upload,
|
upload,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
signal,
|
signal,
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
|
||||||
|
percent: 99,
|
||||||
});
|
});
|
||||||
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
||||||
{
|
{
|
||||||
@ -448,7 +825,10 @@ async function fetchDirectUpload(
|
|||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
|
|
||||||
return Response.json(payload);
|
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
return createJsonResponse(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (multipartUpload && !isAbortError(error, signal)) {
|
if (multipartUpload && !isAbortError(error, signal)) {
|
||||||
postDirectUploadAction(
|
postDirectUploadAction(
|
||||||
@ -458,13 +838,14 @@ async function fetchDirectUpload(
|
|||||||
uploadId: multipartUpload.uploadId,
|
uploadId: multipartUpload.uploadId,
|
||||||
clientUploadId: file.clientUploadId,
|
clientUploadId: file.clientUploadId,
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
DIRECT_UPLOAD_ABORT_TIMEOUT_MS
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAbortError(error, signal)) {
|
if (!isAbortError(error, signal)) {
|
||||||
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
|
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
|
||||||
return Response.json(
|
return createJsonResponse(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
|
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
|
||||||
@ -489,6 +870,7 @@ export async function fetchUploadWithRetry(
|
|||||||
options?: {
|
options?: {
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
onProgress?: UploadProgressHandler;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||||
@ -497,45 +879,27 @@ export async function fetchUploadWithRetry(
|
|||||||
const directResponse = await fetchDirectUpload(
|
const directResponse = await fetchDirectUpload(
|
||||||
formData,
|
formData,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
options?.signal
|
options?.signal,
|
||||||
|
options?.onProgress
|
||||||
);
|
);
|
||||||
if (directResponse) {
|
if (directResponse) {
|
||||||
return directResponse;
|
return directResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
const response = await fetchServerUploadWithRetry(
|
||||||
try {
|
formData,
|
||||||
const response = await fetch("/api/files/upload", {
|
maxRetries,
|
||||||
method: "POST",
|
options?.signal,
|
||||||
body: cloneFormData(formData),
|
options?.onProgress
|
||||||
signal: options?.signal,
|
);
|
||||||
});
|
if (response.ok) {
|
||||||
|
options?.onProgress?.({
|
||||||
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
loaded: 1,
|
||||||
return response;
|
total: 1,
|
||||||
}
|
percent: 100,
|
||||||
|
phase: "complete",
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,28 @@ function isAliyunOssImageHost(hostname: string): boolean {
|
|||||||
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
|
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(
|
function resolveAliyunOssDisplayUrl(
|
||||||
url: string,
|
url: string,
|
||||||
options: Pick<ImageProxyOptions, "width" | "quality" | "format">
|
options: Pick<ImageProxyOptions, "width" | "quality" | "format">
|
||||||
@ -94,6 +116,11 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(source);
|
||||||
|
if (trustedDirectStorageUrl) {
|
||||||
|
return trustedDirectStorageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.delivery !== "proxy") {
|
if (options.delivery !== "proxy") {
|
||||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||||
if (directDisplayUrl) {
|
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),
|
Body: Buffer.from(buffer),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
CacheControl: "public, max-age=31536000, immutable",
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
// 设置为公开可读(如果您的 MinIO 桶支持)
|
...(config.provider === "qiniu-kodo" ? {} : { ACL: "public-read" as const }),
|
||||||
ACL: "public-read",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await withStorageOperationTimeout(
|
await withStorageOperationTimeout(
|
||||||
|
|||||||
@ -6,23 +6,26 @@ import {
|
|||||||
S3Client,
|
S3Client,
|
||||||
type CORSRule,
|
type CORSRule,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
loadEnv({ path: ".env.local" });
|
loadEnv({ path: ".env.local" });
|
||||||
loadEnv();
|
loadEnv();
|
||||||
|
|
||||||
type S3UploadConfig = {
|
type S3UploadConfig = {
|
||||||
|
name?: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
region: string;
|
region: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
|
provider?: "s3" | "qiniu-kodo";
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ORIGINS = [
|
const DEFAULT_ORIGINS = [
|
||||||
"https://ai.yydd.ai",
|
"https://ai.yydd.ai",
|
||||||
"https://yydd.ai",
|
"https://yydd.ai",
|
||||||
"https://bbyy.ai",
|
"https://bbyy.ai",
|
||||||
|
"https://yybb.top",
|
||||||
"https://goyingup.chat",
|
"https://goyingup.chat",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3001",
|
"http://localhost:3001",
|
||||||
@ -54,7 +57,7 @@ function createS3Client(config: S3UploadConfig) {
|
|||||||
accessKeyId: config.accessKeyId,
|
accessKeyId: config.accessKeyId,
|
||||||
secretAccessKey: config.secretAccessKey,
|
secretAccessKey: config.secretAccessKey,
|
||||||
},
|
},
|
||||||
forcePathStyle: !isCloudS3,
|
forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +72,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!raw ||
|
!raw ||
|
||||||
raw.type !== "s3" ||
|
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
|
||||||
raw.isEnabled !== true ||
|
raw.isEnabled !== true ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
@ -80,11 +83,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
name: raw.name ? String(raw.name) : undefined,
|
||||||
endpoint,
|
endpoint,
|
||||||
region: String(raw.region || "auto").trim() || "auto",
|
region: String(raw.region || "auto").trim() || "auto",
|
||||||
bucket,
|
bucket,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
|
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +106,7 @@ function readEnvS3Config(): S3UploadConfig | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
name: "Environment S3",
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
bucket,
|
bucket,
|
||||||
@ -109,24 +115,31 @@ function readEnvS3Config(): S3UploadConfig | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveS3Config() {
|
async function resolveS3Configs() {
|
||||||
const [{ db }, { storageConfig }] = await Promise.all([
|
const [{ db }, { storageConfig }] = await Promise.all([
|
||||||
import("@/lib/db"),
|
import("@/lib/db"),
|
||||||
import("@/lib/db/schema"),
|
import("@/lib/db/schema"),
|
||||||
]);
|
]);
|
||||||
const [defaultConfig] = await db
|
const storageConfigs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(storageConfig)
|
.from(storageConfig)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(storageConfig.type, "s3"),
|
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
|
||||||
eq(storageConfig.isDefault, true),
|
|
||||||
eq(storageConfig.isEnabled, true)
|
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() {
|
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() {
|
async function main() {
|
||||||
const config = await resolveS3Config();
|
const configs = await resolveS3Configs();
|
||||||
if (!config) {
|
if (configs.length === 0) {
|
||||||
console.log("[direct-upload-cors] skip: no S3-compatible upload target");
|
console.log("[direct-upload-cors] skip: no S3-compatible upload targets");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origins = readOrigins();
|
const origins = readOrigins();
|
||||||
const client = createS3Client(config);
|
|
||||||
const directRule: CORSRule = {
|
const directRule: CORSRule = {
|
||||||
AllowedOrigins: origins,
|
AllowedOrigins: origins,
|
||||||
AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
|
AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
|
||||||
@ -175,41 +194,77 @@ async function main() {
|
|||||||
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
|
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
|
||||||
MaxAgeSeconds: 3600,
|
MaxAgeSeconds: 3600,
|
||||||
};
|
};
|
||||||
|
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
|
||||||
|
|
||||||
let existingRules: CORSRule[] = [];
|
for (const config of configs) {
|
||||||
try {
|
const client = createS3Client(config);
|
||||||
const current = await client.send(
|
let existingRules: CORSRule[] = [];
|
||||||
new GetBucketCorsCommand({ Bucket: config.bucket })
|
try {
|
||||||
);
|
const current = await client.send(
|
||||||
existingRules = current.CORSRules || [];
|
new GetBucketCorsCommand({ Bucket: config.bucket })
|
||||||
} catch (error) {
|
);
|
||||||
console.warn(
|
existingRules = current.CORSRules || [];
|
||||||
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
|
} catch (error) {
|
||||||
error instanceof Error ? error.message : String(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 = [
|
if (failures.length > 0) {
|
||||||
directRule,
|
throw new Error(
|
||||||
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
|
`Failed to configure direct upload CORS for ${failures.length} bucket(s)`
|
||||||
]
|
);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import {
|
|||||||
S3Client,
|
S3Client,
|
||||||
type LifecycleRule,
|
type LifecycleRule,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
loadEnv({ path: ".env.local" });
|
loadEnv({ path: ".env.local" });
|
||||||
loadEnv();
|
loadEnv();
|
||||||
|
|
||||||
type S3UploadConfig = {
|
type S3UploadConfig = {
|
||||||
|
name?: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
region: string;
|
region: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
@ -65,7 +66,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!raw ||
|
!raw ||
|
||||||
raw.type !== "s3" ||
|
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
|
||||||
raw.isEnabled !== true ||
|
raw.isEnabled !== true ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
@ -76,12 +77,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
name: raw.name ? String(raw.name) : undefined,
|
||||||
endpoint,
|
endpoint,
|
||||||
region: String(raw.region || "auto").trim() || "auto",
|
region: String(raw.region || "auto").trim() || "auto",
|
||||||
bucket,
|
bucket,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
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 {
|
return {
|
||||||
|
name: "Environment S3",
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
bucket,
|
bucket,
|
||||||
@ -106,24 +109,31 @@ function readEnvS3Config(): S3UploadConfig | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveS3Config() {
|
async function resolveS3Configs() {
|
||||||
const [{ db }, { storageConfig }] = await Promise.all([
|
const [{ db }, { storageConfig }] = await Promise.all([
|
||||||
import("@/lib/db"),
|
import("@/lib/db"),
|
||||||
import("@/lib/db/schema"),
|
import("@/lib/db/schema"),
|
||||||
]);
|
]);
|
||||||
const [defaultConfig] = await db
|
const storageConfigs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(storageConfig)
|
.from(storageConfig)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(storageConfig.type, "s3"),
|
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
|
||||||
eq(storageConfig.isDefault, true),
|
|
||||||
eq(storageConfig.isEnabled, true)
|
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() {
|
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() {
|
async function main() {
|
||||||
const config = await resolveS3Config();
|
const configs = await resolveS3Configs();
|
||||||
if (!config) {
|
if (configs.length === 0) {
|
||||||
console.log("[multipart-lifecycle] skip: no S3-compatible upload target");
|
console.log("[multipart-lifecycle] skip: no S3-compatible upload targets");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createS3Client(config);
|
|
||||||
const prefix = readRulePrefix();
|
const prefix = readRulePrefix();
|
||||||
const abortDays = readAbortDays();
|
const abortDays = readAbortDays();
|
||||||
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
|
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[] = [];
|
for (const config of configs) {
|
||||||
try {
|
if (config.provider === "qiniu-kodo") {
|
||||||
const current = await client.send(
|
console.log("[multipart-lifecycle] skip bucket: qiniu-kodo lifecycle is not configured through S3 compatibility", {
|
||||||
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
|
name: config.name,
|
||||||
);
|
bucket: config.bucket,
|
||||||
existingRules = current.Rules || [];
|
});
|
||||||
} catch (error) {
|
continue;
|
||||||
console.warn(
|
}
|
||||||
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
|
|
||||||
error instanceof Error ? error.message : String(error)
|
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 = {
|
if (configuredCount === 0) {
|
||||||
ID: ruleId,
|
console.log("[multipart-lifecycle] no compatible buckets required lifecycle configuration");
|
||||||
Status: "Enabled",
|
}
|
||||||
Filter: { Prefix: prefix },
|
|
||||||
AbortIncompleteMultipartUpload: {
|
|
||||||
DaysAfterInitiation: abortDays,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergedRules = [
|
if (failures.length > 0) {
|
||||||
cleanupRule,
|
throw new Error(
|
||||||
...existingRules.filter((rule) => rule.ID !== ruleId),
|
`Failed to configure multipart lifecycle for ${failures.length} bucket(s)`
|
||||||
]
|
);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -54,6 +54,11 @@ import {
|
|||||||
} from "@/lib/ai/runtime/canvas-agent-kernel";
|
} from "@/lib/ai/runtime/canvas-agent-kernel";
|
||||||
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
|
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
|
||||||
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
|
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
|
||||||
|
import {
|
||||||
|
buildAgentPlanningBriefMarkdown,
|
||||||
|
extractAgentTaskGroups,
|
||||||
|
isAgentPlanningBriefMarkdown,
|
||||||
|
} from "@/lib/ai/runtime/agent-planning-brief";
|
||||||
import {
|
import {
|
||||||
buildRecentDialogueMemory,
|
buildRecentDialogueMemory,
|
||||||
isRecentDialogueRecallRequest,
|
isRecentDialogueRecallRequest,
|
||||||
@ -137,7 +142,9 @@ import {
|
|||||||
shouldBypassCanvasImagePointsPrecheckForAgentControl,
|
shouldBypassCanvasImagePointsPrecheckForAgentControl,
|
||||||
} from "@/lib/canvas/agent-client-intent";
|
} from "@/lib/canvas/agent-client-intent";
|
||||||
import {
|
import {
|
||||||
|
getDirectAgentToolDisplayName,
|
||||||
getDirectAgentToolLabel,
|
getDirectAgentToolLabel,
|
||||||
|
getDirectAgentToolPrefaceFallback,
|
||||||
getDirectAgentToolSuccessMessage,
|
getDirectAgentToolSuccessMessage,
|
||||||
hasExplicitDirectAgentBatchImageEditIntent,
|
hasExplicitDirectAgentBatchImageEditIntent,
|
||||||
isDirectAgentBatchImageEditCapability,
|
isDirectAgentBatchImageEditCapability,
|
||||||
@ -385,6 +392,39 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12);
|
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", () => {
|
test("keeps canvas message empty-history access scoped and explicit", () => {
|
||||||
const canvasChatId = "11111111-1111-4111-8111-111111111111";
|
const canvasChatId = "11111111-1111-4111-8111-111111111111";
|
||||||
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
|
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("- 已完成抠图处理,结果如下。");
|
expect(editText).toContain("- 已完成抠图处理,结果如下。");
|
||||||
expect(editText).toContain("https://cdn.example.com/cutout.png");
|
expect(editText).toContain("https://cdn.example.com/cutout.png");
|
||||||
|
expect(editText).toContain("◈ 抠图");
|
||||||
|
expect(editText).not.toContain("Agent工具:");
|
||||||
expect(editText).toContain("替换背景");
|
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", () => {
|
test("builds capability-aware agent progress node labels", () => {
|
||||||
const selectingSteps = buildAgentProgressNodeSteps({
|
const selectingSteps = buildAgentProgressNodeSteps({
|
||||||
stage: "planning",
|
stage: "planning",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user