Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge

This commit is contained in:
root 2026-07-08 22:16:02 +08:00
commit 2398930ecb
40 changed files with 2360 additions and 505 deletions

View File

@ -501,11 +501,6 @@ export default function Page() {
const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo;
const shouldInvertLogo =
mounted &&
resolvedTheme === "dark" &&
!siteLogoDark?.trim() &&
Boolean(siteLogo?.trim());
const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured);
@ -545,7 +540,7 @@ export default function Page() {
<img
src={displayLogo}
alt={siteName}
className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
className="mb-2 h-12 w-auto object-contain"
/>
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>

View File

@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string {
.trim();
const knownTitles = [
"意图分析",
"任务规划",
"需求理解",
"图片分析",
"执行计划",

View File

@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
: "基于当前上传图片处理。";
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 图片分析",
params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 执行计划",
"### 任务规划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置",
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
}
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 执行计划",
"### 任务规划",
"- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置",
"- 默认单图输出,优先稳定命中。",
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
message,
);
}
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
return fallback;
}
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace(
"### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,

View File

@ -239,6 +239,8 @@ import {
safePersistDirectAgentVideoFailedTurn,
} from "@/lib/ai/runtime/direct-agent-working-state";
import {
getDirectAgentToolDisplayName,
getDirectAgentToolPrefaceFallback,
getDirectAgentToolErrorMessage,
getDirectAgentToolLabel,
getDirectAgentToolTargetMissingMessage,
@ -545,7 +547,7 @@ function writeCanvasOperationShadowChunk(params: {
}
const AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN =
/(?:^|\n)\s*(?:#{1,6}\s*)?(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/;
/(?:^|\n)\s*(?:#{1,6}\s*)?(?:意图分析|任务规划|需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:意图分析|任务规划|需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/;
function buildAgentExecutionPrefaceText(params: {
friendlyMessage?: string | null;
@ -8296,6 +8298,9 @@ export async function POST(request: Request) {
const assistantMessageId = generateUUID();
const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability);
const directToolDisplayName = getDirectAgentToolDisplayName(
directAgentToolCapability,
);
const directToolStream = createUIMessageStream({
execute: async ({ writer }) => {
await writeAgentExecutionPrefaceMessage({
@ -8303,7 +8308,7 @@ export async function POST(request: Request) {
chatId: id,
label: `agent:${directAgentToolCapability}:preface`,
friendlyMessage: smartAutoFriendlyMessage,
fallback: `好的,我来帮你${directToolLabel},稍等我处理一下。`,
fallback: getDirectAgentToolPrefaceFallback(directAgentToolCapability),
});
writeAgentProgressChunk(
writer,
@ -8318,7 +8323,7 @@ export async function POST(request: Request) {
buildDirectAgentImageEditSelectProgress({
toolName: directAgentExecution.toolName,
capability: directAgentToolCapability,
label: directToolLabel,
label: directToolDisplayName,
}),
`agent:${directAgentToolCapability}:progress-select-tool`,
);
@ -8331,7 +8336,7 @@ export async function POST(request: Request) {
buildDirectAgentImageEditExecutingProgress({
toolName: directAgentExecution.toolName,
capability: directAgentToolCapability,
label: directToolLabel,
label: directToolDisplayName,
}),
`agent:${directAgentToolCapability}:progress-executing`,
);
@ -8480,7 +8485,7 @@ export async function POST(request: Request) {
buildDirectAgentImageEditCompletedProgress({
toolName: directAgentExecution.toolName,
capability: directAgentToolCapability,
label: directToolLabel,
label: directToolDisplayName,
}),
`agent:${directAgentToolCapability}:progress-completed`,
);

View File

@ -29,6 +29,10 @@ const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) {
return Date.now() - startedAt;
}
const allowedDirectUploadTypes = new Set([
"image/jpeg",
"image/jpg",
@ -141,6 +145,7 @@ async function createPartUrls(params: {
}
export async function POST(request: Request) {
const requestStartedAt = Date.now();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -159,6 +164,16 @@ export async function POST(request: Request) {
);
}
logger.info("direct_upload_request_start", {
action: payload.action,
size: "size" in payload ? payload.size : null,
contentType: "contentType" in payload ? payload.contentType : null,
purpose: "purpose" in payload ? payload.purpose || null : null,
clientUploadId:
"clientUploadId" in payload ? payload.clientUploadId || null : null,
});
const resolveStartedAt = Date.now();
const target = await resolveS3UploadTarget().catch((error) => {
logger.error("resolve_storage_target_failed", error);
return null;
@ -170,9 +185,17 @@ export async function POST(request: Request) {
{ status: 409 }
);
}
logger.info("direct_upload_storage_target_resolved", {
action: payload.action,
elapsedMs: elapsedMs(resolveStartedAt),
provider: target.config.provider || "s3",
bucket: target.config.bucket,
hasPublicUrl: Boolean(target.config.publicUrl),
});
const client = createS3Client(target.config);
const bucket = target.config.bucket;
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
try {
if (payload.action === "initiate") {
@ -202,38 +225,51 @@ export async function POST(request: Request) {
);
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
const presignStartedAt = Date.now();
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
});
const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
});
logger.info("direct_upload_single_presigned", {
elapsedMs: elapsedMs(presignStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
contentType,
clientUploadId: payload.clientUploadId || null,
});
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
};
if (usePublicReadAcl) {
headers["x-amz-acl"] = "public-read";
}
return NextResponse.json({
mode: "single",
key,
url,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
headers,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
}
const multipartStartedAt = Date.now();
const createResult = await client.send(
new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
})
);
@ -251,6 +287,15 @@ export async function POST(request: Request) {
uploadId,
partCount,
});
logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
partCount,
partSize,
contentType,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
mode: "multipart",
@ -266,6 +311,7 @@ export async function POST(request: Request) {
if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now();
await client.send(
new AbortMultipartUploadCommand({
Bucket: bucket,
@ -273,6 +319,11 @@ export async function POST(request: Request) {
UploadId: payload.uploadId,
})
);
logger.info("direct_upload_multipart_aborted", {
elapsedMs: elapsedMs(abortStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({ ok: true });
}
@ -286,6 +337,7 @@ export async function POST(request: Request) {
}
if (payload.uploadId) {
const completeMultipartStartedAt = Date.now();
const parts = [...(payload.parts || [])].sort(
(a, b) => a.PartNumber - b.PartNumber
);
@ -306,14 +358,25 @@ export async function POST(request: Request) {
},
})
);
logger.info("direct_upload_multipart_completed", {
elapsedMs: elapsedMs(completeMultipartStartedAt),
partCount: parts.length,
clientUploadId: payload.clientUploadId || null,
});
}
const headStartedAt = Date.now();
const head = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
})
);
logger.info("direct_upload_head_object_completed", {
elapsedMs: elapsedMs(headStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
clientUploadId: payload.clientUploadId || null,
});
const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null;
@ -324,6 +387,7 @@ export async function POST(request: Request) {
const url = buildS3PublicUrl(target.config, key);
const resolvedContentType =
head.ContentType || contentType || getContentType(payload.filename);
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
@ -344,6 +408,13 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null,
},
});
logger.info("direct_upload_asset_registered", {
elapsedMs: elapsedMs(assetStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
assetId: asset?.id ?? null,
clientUploadId: payload.clientUploadId || null,
});
await maybeEnqueueStorageMigrationTask({
uploaded: { url, pathname: key },

View 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 });
}

View File

@ -13,6 +13,14 @@ import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
@ -141,6 +149,10 @@ function getMetadataString(
return typeof value === "string" ? value.trim() : "";
}
function createCanceledResponse() {
return NextResponse.json({ canceled: true }, { status: 499 });
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
@ -172,6 +184,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const userMessageId = parsed.userMessageId || generateUUID();
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.id,
userMessageId,
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
let spreadsheetMarkdownResult: Awaited<
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
> | null = null;
@ -200,6 +222,10 @@ export async function POST(request: Request) {
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
if (!existingChat) {
await saveChat({
id: parsed.id,
@ -209,7 +235,6 @@ export async function POST(request: Request) {
});
}
const userMessageId = parsed.userMessageId || generateUUID();
const assistantMessageId = generateUUID();
const conversationId = createConversationId(session.user.id, parsed.id);
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
@ -226,9 +251,18 @@ export async function POST(request: Request) {
let upstreamPayload: unknown = null;
let upstreamStatus = 502;
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
try {
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const handleRequestAbort = () => {
cancelClawdRequest(cancelKey);
};
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
const response = await fetch(endpoint, {
method: "POST",
headers: {
@ -266,7 +300,11 @@ export async function POST(request: Request) {
},
}),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
}).finally(() => {
clearTimeout(timeout);
request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey);
});
upstreamStatus = response.status;
upstreamPayload = await response.json().catch(() => null);
@ -287,8 +325,19 @@ export async function POST(request: Request) {
);
}
} catch (error) {
const isAbortError =
error instanceof Error && error.name === "AbortError";
unregisterActiveClawdRequest(cancelKey);
const errorName =
error && typeof error === "object" && "name" in error
? String((error as { name?: unknown }).name || "")
: "";
const isAbortError = errorName === "AbortError";
if (isClawdRequestCanceled(cancelKey)) {
console.info("[ClawdChat API] OpenClaw request canceled:", {
chatId: parsed.id,
userMessageId,
});
return createCanceledResponse();
}
console.error("[ClawdChat API] OpenClaw request failed:", error);
return NextResponse.json(
{
@ -311,6 +360,10 @@ export async function POST(request: Request) {
);
}
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
() => []
);
@ -366,6 +419,7 @@ export async function POST(request: Request) {
};
await saveMessages({ messages: [userMessage, assistantMessage] });
clearClawdRequestCancellation(cancelKey);
return NextResponse.json({
conversationId,

View File

@ -8,7 +8,6 @@ export const dynamic = "force-dynamic";
export const revalidate = 0;
const DEFAULT_CONTENT_TYPE = "image/x-icon";
const DARK_FAVICON_CONTENT_TYPE = "image/png";
const FETCH_TIMEOUT_MS = 10_000;
function noStoreHeaders(contentType: string) {
@ -71,86 +70,16 @@ function uniqueNonEmptyValues(values: string[]) {
return result;
}
async function readLocalFallbackFavicon(preferDark = false) {
async function readLocalFallbackFavicon() {
const file = await readFile(
join(process.cwd(), "public", "default-favicon.ico")
);
if (preferDark) {
const transformed = await transformFaviconForDark(file).catch(() => null);
const fallback =
transformed || (await renderDarkFallbackFavicon().catch(() => null));
if (fallback) {
return new NextResponse(fallback, {
status: 200,
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
});
}
}
return new NextResponse(file, {
status: 200,
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
});
}
async function renderDarkFallbackFavicon() {
const sharp = (await import("sharp")).default;
const svg = `\
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="none"/>
<path d="M15 9h34v12H29v13h20v12H29v9l-14-11V9z" fill="#fff"/>
<path d="M37 25h12v30L37 45V25z" fill="#fff" opacity=".92"/>
</svg>`;
return await sharp(Buffer.from(svg)).png().toBuffer();
}
async function transformFaviconForDark(buffer: Buffer | ArrayBuffer) {
const sharp = (await import("sharp")).default;
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(new Uint8Array(buffer));
const createBaseImage = () =>
sharp(inputBuffer, {
animated: false,
failOn: "none",
}).resize({
width: 64,
height: 64,
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
});
const { data, info } = await createBaseImage()
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let visiblePixelCount = 0;
let luminanceTotal = 0;
for (let index = 0; index < data.length; index += info.channels) {
const alpha = data[index + 3] ?? 255;
if (alpha < 24) continue;
const red = data[index] ?? 0;
const green = data[index + 1] ?? 0;
const blue = data[index + 2] ?? 0;
luminanceTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
visiblePixelCount += 1;
}
const averageLuminance =
visiblePixelCount > 0 ? luminanceTotal / visiblePixelCount : 0;
const needsInvertForDark = averageLuminance < 150;
const output = createBaseImage();
if (needsInvertForDark) {
output.negate({ alpha: false });
}
return await output
.png()
.toBuffer();
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
@ -159,20 +88,14 @@ export async function GET(request: Request) {
const configuredFavicon = String(settings?.favicon || "").trim();
const configuredLightLogo = String(settings?.siteLogo || "").trim();
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
const rawFaviconCandidates = preferDark
? uniqueNonEmptyValues([
configuredFavicon,
configuredDarkLogo,
configuredLightLogo,
])
: uniqueNonEmptyValues([
configuredFavicon,
configuredLightLogo,
configuredDarkLogo,
]);
const rawFaviconCandidates = configuredFavicon
? [configuredFavicon]
: preferDark
? uniqueNonEmptyValues([configuredDarkLogo, configuredLightLogo])
: uniqueNonEmptyValues([configuredLightLogo, configuredDarkLogo]);
if (rawFaviconCandidates.length === 0) {
return await readLocalFallbackFavicon(preferDark);
return await readLocalFallbackFavicon();
}
for (const rawFaviconUrl of rawFaviconCandidates) {
@ -220,42 +143,15 @@ export async function GET(request: Request) {
const contentType =
upstream.headers.get("content-type") || inferContentType(faviconUrl);
if (preferDark) {
const transformed = await transformFaviconForDark(buffer).catch((error) => {
console.error("[favicon] Failed to transform dark favicon candidate:", {
targetUrl,
error,
});
return null;
});
if (!transformed) continue;
return new NextResponse(transformed, {
status: 200,
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
});
}
return new NextResponse(buffer, {
status: 200,
headers: noStoreHeaders(contentType),
});
}
if (preferDark) {
const fallback = await renderDarkFallbackFavicon().catch(() => null);
if (fallback) {
return new NextResponse(fallback, {
status: 200,
headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE),
});
}
}
return await readLocalFallbackFavicon(preferDark);
return await readLocalFallbackFavicon();
} catch (error) {
console.error("[favicon] Failed to proxy favicon:", error);
const preferDark = new URL(request.url).searchParams.get("theme") === "dark";
return await readLocalFallbackFavicon(preferDark);
return await readLocalFallbackFavicon();
}
}

View File

@ -9640,10 +9640,6 @@ export default function CanvasEditorLegacyPage({
: lightCanvasBrandLogo || darkCanvasBrandLogo
: lightCanvasBrandLogo || darkCanvasBrandLogo
).trim();
const shouldInvertCanvasBrandLogo =
isDarkCanvasTheme &&
Boolean(canvasBrandLogo) &&
!hasSeparateDarkCanvasBrandLogo;
const brandEntry = (
<div
@ -9658,11 +9654,7 @@ export default function CanvasEditorLegacyPage({
{canvasBrandLogo ? (
<img
alt={siteSettingsData?.siteName || "返回首页"}
className={cn(
"h-7 w-7 object-contain",
shouldInvertCanvasBrandLogo && "brightness-0 invert",
!hasSeparateDarkCanvasBrandLogo && "dark:brightness-0 dark:invert"
)}
className="h-7 w-7 object-contain"
draggable={false}
src={canvasBrandLogo}
/>

View File

@ -24,19 +24,10 @@ export async function generateMetadata(): Promise<Metadata> {
"AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
icons: {
icon: [
{
url: "/api/favicon?theme=dark",
media: "(prefers-color-scheme: dark)",
sizes: "any",
type: "image/png",
},
{
url: "/api/favicon",
media: "(prefers-color-scheme: light)",
sizes: "any",
},
{ url: "/api/favicon?theme=dark", sizes: "any", type: "image/png" },
{ url: "/api/favicon", type: "image/x-icon" },
],
apple: "/api/favicon",
shortcut: "/api/favicon",

View File

@ -53,7 +53,6 @@ export function AgentCanvasTopNavDark({
}: AgentCanvasTopNavDarkProps) {
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
return (
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
@ -80,10 +79,7 @@ export function AgentCanvasTopNavDark({
<img
src={displayLogo}
alt={resolvedSiteName}
className={cn(
"h-8 w-8 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
className="h-8 w-8 shrink-0 object-contain"
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">

File diff suppressed because it is too large Load Diff

View File

@ -6695,17 +6695,13 @@ function StylizedBrandLogo({
}) {
const resolvedSiteName = resolveSiteName(siteName);
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
return (
<span className="flex min-w-0 items-center gap-2.5">
{displayLogo ? (
<img
alt={resolvedSiteName}
className={cn(
"h-8 w-8 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
className="h-8 w-8 shrink-0 object-contain"
src={displayLogo}
/>
) : (

View File

@ -33,9 +33,7 @@ export function CanvasLogoLoadingView({
alt={resolvedSiteName}
className={cn(
"h-[34px] w-[34px] object-contain",
hasSeparateDarkLogo
? "dark:hidden"
: "dark:brightness-0 dark:invert"
hasSeparateDarkLogo && "dark:hidden"
)}
src={lightLogo}
/>

View File

@ -10193,7 +10193,7 @@ export function ImageNode({
createPortal(
<TooltipProvider delayDuration={200}>
<div
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-slate-200 bg-white px-2 py-1.5 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_16px_44px_rgba(0,0,0,0.45)]"
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}

View File

@ -47,6 +47,8 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
const IMAGE_ANALYSIS_TITLE = "图片分析";
const AGENT_SECTION_TITLES = new Set([
"意图分析",
"任务规划",
"需求理解",
IMAGE_ANALYSIS_TITLE,
"执行计划",
@ -903,11 +905,17 @@ export const Response = ({
),
};
const renderImagePreview = (src: string, alt: string | undefined, key: string) => (
const renderImagePreview = (
src: string,
alt: string | undefined,
key: string,
compactGrid = false
) => (
<ImagePreview
key={key}
src={src}
alt={alt}
compactGrid={compactGrid}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
@ -923,6 +931,49 @@ export const Response = ({
/>
);
const renderImageGrid = (
images: Array<{ alt?: string; src: string }>,
keyPrefix: string
) => {
if (images.length === 0) return null;
if (images.length === 1) {
const image = images[0];
return renderImagePreview(
image.src,
image.alt,
`${image.src}-${processingKey}-${keyPrefix}-single`
);
}
const gridClassName =
images.length === 2
? "grid-cols-2 sm:max-w-[640px]"
: images.length === 3
? "grid-cols-2 sm:grid-cols-3 sm:max-w-[780px]"
: "grid-cols-2 sm:grid-cols-4 sm:max-w-[920px]";
return (
<div
className={cn(
"not-prose my-3 grid w-full gap-1.5 overflow-hidden rounded-lg",
gridClassName
)}
key={`${keyPrefix}-image-grid`}
>
{images.map((image, index) => (
<div className="min-w-0" key={`${image.src}-${index}`}>
{renderImagePreview(
image.src,
image.alt,
`${image.src}-${processingKey}-${keyPrefix}-grid-${index}`,
true
)}
</div>
))}
</div>
);
};
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
if (!text.trim()) return null;
@ -935,20 +986,31 @@ export const Response = ({
const hasImageSegments = segments.some((segment) => segment.type === "image");
if (hasImageSegments) {
return segments.map((segment, index) => {
const nodes: ReactNode[] = [];
let pendingImages: Array<{ alt?: string; src: string }> = [];
const flushImages = (index: number) => {
if (pendingImages.length === 0) return;
nodes.push(
renderImageGrid(pendingImages, `${keyPrefix}-images-${index}`)
);
pendingImages = [];
};
segments.forEach((segment, index) => {
if (segment.type === "image") {
return renderImagePreview(
segment.src,
segment.alt,
`${segment.src}-${processingKey}-${keyPrefix}-${index}`
);
pendingImages.push({
alt: segment.alt,
src: segment.src,
});
return;
}
if (!segment.value.trim()) {
return null;
return;
}
return (
flushImages(index);
nodes.push(
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
@ -958,6 +1020,8 @@ export const Response = ({
</Streamdown>
);
});
flushImages(segments.length);
return nodes;
}
}

View File

@ -5,24 +5,12 @@ import { useEffect } from "react";
export function FaviconUpdater() {
useEffect(() => {
let isDisposed = false;
const mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
const getFaviconUrl = () => {
const html = document.documentElement;
const hasDarkClass = html.classList.contains("dark");
const hasLightClass = html.classList.contains("light");
const prefersDark = Boolean(mediaQuery?.matches);
const isDark = hasDarkClass || (!hasLightClass && prefersDark);
return isDark ? "/api/favicon?theme=dark" : "/api/favicon";
};
const getFaviconUrl = () => "/api/favicon";
const updateFavicon = () => {
if (isDisposed) return;
const urlWithTheme = getFaviconUrl();
const iconType = urlWithTheme.includes("theme=dark")
? "image/png"
: "image/x-icon";
const href =
`${urlWithTheme}${urlWithTheme.includes("?") ? "&" : "?"}v=${Date.now()}`;
const managedSelector =
@ -35,12 +23,12 @@ export function FaviconUpdater() {
link.href = href;
link.setAttribute("data-managed-favicon", "true");
if (link.rel === "icon" || link.rel === "shortcut icon") {
link.type = iconType;
link.removeAttribute("type");
link.setAttribute("sizes", "any");
}
});
const ensureLink = (rel: string, type?: string) => {
const ensureLink = (rel: string) => {
let link = document.querySelector(
`link[rel="${rel}"]`
) as HTMLLinkElement | null;
@ -51,17 +39,15 @@ export function FaviconUpdater() {
}
link.href = href;
link.setAttribute("data-managed-favicon", "true");
if (type) {
link.type = type;
}
link.removeAttribute("type");
if (rel === "icon") {
link.setAttribute("sizes", "any");
}
};
ensureLink("icon", iconType);
ensureLink("icon");
ensureLink("apple-touch-icon");
ensureLink("shortcut icon", iconType);
ensureLink("shortcut icon");
};
const observer = new MutationObserver(updateFavicon);
@ -69,8 +55,6 @@ export function FaviconUpdater() {
attributes: true,
attributeFilter: ["class"],
});
mediaQuery?.addEventListener?.("change", updateFavicon);
Promise.resolve().then(() => {
updateFavicon();
});
@ -78,7 +62,6 @@ export function FaviconUpdater() {
return () => {
isDisposed = true;
observer.disconnect();
mediaQuery?.removeEventListener?.("change", updateFavicon);
};
}, []);

View File

@ -677,8 +677,6 @@ export function LovartHome({
resolvedTheme === "dark"
? siteLogoDark || siteLogo || ""
: siteLogo || siteLogoDark || "";
const shouldInvertLogo =
resolvedTheme === "dark" && !siteLogoDark && Boolean(siteLogo);
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
@ -1564,7 +1562,6 @@ export function LovartHome({
contentClassName="pl-[98px]"
logo={displayLogo}
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
shouldInvertLogo={shouldInvertLogo}
showBrand={false}
siteName={siteName || "Lovart"}
>
@ -1814,7 +1811,6 @@ export function LovartHome({
? "/settings/agent-workflow"
: null
}
shouldInvertLogo={shouldInvertLogo}
siteLogo={displayLogo}
siteName={siteName || "Lovart"}
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
@ -1852,7 +1848,6 @@ export function LovartHome({
prompt={prompt}
removeFile={removeFile}
setPrompt={setPrompt}
shouldInvertLogo={shouldInvertLogo}
siteName={siteName}
selectedModelId={selectedComposerModel.id}
/>
@ -1990,7 +1985,7 @@ export function LovartHome({
<img
src={displayLogo}
alt={siteName}
className={`h-7 w-7 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
className="h-7 w-7 rounded-full object-cover"
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">

View File

@ -47,7 +47,6 @@ type LovartHeroComposerProps = {
prompt: string;
removeFile: (id: string) => void;
setPrompt: (value: string) => void;
shouldInvertLogo: boolean;
siteName: string;
modelOptions: ModelOption[];
onModelChange: (modelId: string) => void;
@ -73,7 +72,6 @@ export function LovartHeroComposer({
prompt,
removeFile,
setPrompt,
shouldInvertLogo,
siteName,
modelOptions,
onModelChange,
@ -104,7 +102,7 @@ export function LovartHeroComposer({
<img
src={displayLogo}
alt={siteName}
className={`h-10 w-10 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">

View File

@ -42,7 +42,6 @@ type LovartSidebarProps = {
}
) => ReactNode;
settingsHref: string | null;
shouldInvertLogo?: boolean;
siteLogo?: string | null;
siteName: string;
toolbox1Enabled: boolean;
@ -71,7 +70,6 @@ export function LovartSidebar({
productPlanningEnabled,
renderIcon,
settingsHref,
shouldInvertLogo = false,
siteLogo,
siteName,
toolbox1Enabled,
@ -184,12 +182,7 @@ export function LovartSidebar({
<img
src={siteLogo}
alt={siteName}
className={[
"h-8 w-8 rounded-full object-cover",
shouldInvertLogo ? "brightness-0 invert" : "",
]
.filter(Boolean)
.join(" ")}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-[13px] font-semibold text-white dark:bg-white dark:text-black">

View File

@ -42,7 +42,8 @@ export function ImagePreview({
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
imageIndex
imageIndex,
compactGrid = false
}: {
src: string;
alt?: string;
@ -64,6 +65,7 @@ export function ImagePreview({
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
imageIndex?: number;
compactGrid?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
@ -293,16 +295,26 @@ export function ImagePreview({
return (
<>
{/* 缩略图(显示压缩后的缩略图)*/}
<div className="group relative inline-block max-w-full mr-3 mb-3">
<div
className={
compactGrid
? "group relative block aspect-[3/4] w-full overflow-hidden rounded-lg"
: "group relative inline-block max-w-full mr-3 mb-3"
}
>
<img
src={previewSrc}
alt="Generated Image"
className="cursor-pointer rounded-lg transition-opacity hover:opacity-90"
className={
compactGrid
? "h-full w-full cursor-pointer rounded-lg object-cover transition-opacity hover:opacity-90"
: "cursor-pointer rounded-lg transition-opacity hover:opacity-90"
}
style={{
maxWidth: `${maxWidth}px`,
height: "auto",
maxWidth: compactGrid ? "none" : `${maxWidth}px`,
height: compactGrid ? "100%" : "auto",
width: "100%",
objectFit: "contain"
objectFit: compactGrid ? "cover" : "contain"
}}
loading="lazy"
decoding="async"

View File

@ -12,7 +12,6 @@ type BrandTopBarProps = {
logo?: string | null;
onLogoClick?: () => void;
position?: "fixed" | "sticky" | "static";
shouldInvertLogo?: boolean;
showBrand?: boolean;
siteName: string;
};
@ -25,7 +24,6 @@ export function BrandTopBar({
logo,
onLogoClick,
position = "fixed",
shouldInvertLogo = false,
showBrand = true,
siteName,
}: BrandTopBarProps) {
@ -61,10 +59,7 @@ export function BrandTopBar({
{logo ? (
<img
alt={resolvedSiteName}
className={cn(
"h-8 w-8 rounded-full object-cover",
shouldInvertLogo && "brightness-0 invert"
)}
className="h-8 w-8 rounded-full object-cover"
src={logo}
/>
) : (

View File

@ -174,7 +174,7 @@ function filterAgentRoutingNoise(text: string): string {
const IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
const ASSISTANT_TYPEWRITER_SECTION_REGEX =
/(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】|###\s*输出设置|【\s*输出设置\s*】)/;
/(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】|###\s*输出设置|【\s*输出设置\s*】)/;
const ASSISTANT_TYPEWRITER_STORAGE_KEY = "assistant-typewriter-completed-keys-v1";
const MODEL_BADGE_LINE_REGEX =
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im;

View File

@ -69,6 +69,48 @@ type PointsSummaryResponse = {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type UploadQueueStatus = "preparing" | "uploading" | "finalizing";
type UploadQueueItem = {
id: string;
name: string;
progress: number;
status: UploadQueueStatus;
};
const UPLOAD_PROGRESS_HOLD_MS = 700;
function createUploadQueueId(file: File) {
return [
file.name || "image",
file.size,
file.lastModified,
Date.now(),
Math.random().toString(36).slice(2, 8),
].join("-");
}
function createUploadQueueItem(file: File, fallbackName?: string): UploadQueueItem {
return {
id: createUploadQueueId(file),
name: file.name || fallbackName || "图片",
progress: 0,
status: "preparing",
};
}
function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus {
if (phase === "preparing") return "preparing";
if (phase === "finalizing" || phase === "complete") return "finalizing";
return "uploading";
}
function getUploadQueueStatusText(status: UploadQueueStatus) {
if (status === "preparing") return "正在处理图片";
if (status === "finalizing") return "正在保存图片";
return "正在上传图片";
}
function isVideoProviderType(model?: Partial<DynamicModel> | null) {
return isVideoModelDescriptor({
providerType: model?.providerType,
@ -117,7 +159,10 @@ import { toast } from "sonner";
import useSWR from "swr";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
fetchUploadWithRetry,
type UploadProgressPhase,
} from "@/lib/client/upload-with-retry";
import { SelectItem } from "@/components/ui/select";
import {
AlertDialog,
@ -446,10 +491,71 @@ function PureMultimodalInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState<Record<number, string>>({});
const updateUploadQueueItem = useCallback(
(
id: string | undefined,
patch: Partial<Pick<UploadQueueItem, "progress" | "status">>
) => {
if (!id) return;
setUploadQueue((current) =>
current.map((item) =>
item.id === id
? {
...item,
...patch,
progress:
typeof patch.progress === "number"
? Math.max(0, Math.min(100, Math.round(patch.progress)))
: item.progress,
}
: item
)
);
},
[]
);
const removeUploadQueueItems = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
setUploadQueue((current) => current.filter((item) => !idSet.has(item.id)));
}, []);
const holdAndRemoveUploadQueueItems = useCallback(
async (ids: string[]) => {
if (ids.length === 0) return;
await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, UPLOAD_PROGRESS_HOLD_MS);
});
removeUploadQueueItems(ids);
},
[removeUploadQueueItems]
);
const uploadProgressSummary = useMemo(() => {
if (uploadQueue.length === 0) return null;
const totalProgress = Math.round(
uploadQueue.reduce((sum, item) => sum + item.progress, 0) /
uploadQueue.length
);
const activeItem =
uploadQueue.find((item) => item.progress < 100) ||
uploadQueue[uploadQueue.length - 1];
return {
count: uploadQueue.length,
fileName: activeItem?.name || "图片",
percent: Math.max(0, Math.min(100, totalProgress)),
statusText: getUploadQueueStatusText(activeItem?.status || "uploading"),
};
}, [uploadQueue]);
const ensureImagePointsBeforeSend = useCallback(async () => {
if (!isImageModel || isVideoModel) return true;
@ -678,18 +784,40 @@ function PureMultimodalInput({
});
}, []);
const uploadFile = useCallback(async (file: File): Promise<Attachment | null> => {
const uploadFile = useCallback(async (
file: File,
uploadQueueItemId?: string
): Promise<Attachment | null> => {
try {
// 先压缩图片
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
updateUploadQueueItem(uploadQueueItemId, {
progress: 2,
status: "preparing",
});
const processedFile = await compressImage(file);
updateUploadQueueItem(uploadQueueItemId, {
progress: 6,
status: "uploading",
});
const formData = new FormData();
formData.append("file", processedFile);
const response = await fetchUploadWithRetry(formData);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateUploadQueueItem(uploadQueueItemId, {
progress: progress.percent,
status: getUploadQueueStatus(progress.phase),
});
},
});
if (response.ok) {
updateUploadQueueItem(uploadQueueItemId, {
progress: 100,
status: "finalizing",
});
const data = await response.json();
const { url, pathname, contentType } = data;
@ -712,7 +840,7 @@ function PureMultimodalInput({
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
return null;
}
}, [compressImage]);
}, [compressImage, updateUploadQueueItem]);
const contextProps = useMemo(
() => ({
@ -759,20 +887,30 @@ function PureMultimodalInput({
event.currentTarget.value = "";
return;
}
const queueItem = createUploadQueueItem(file);
setVideoSlotUploading(slotIndex);
setUploadQueue((prev) => [...prev, file.name]);
setVideoSlotUploadItemIds((current) => ({
...current,
[slotIndex]: queueItem.id,
}));
setUploadQueue((prev) => [...prev, queueItem]);
try {
const uploaded = await uploadFile(file);
const uploaded = await uploadFile(file, queueItem.id);
if (uploaded) {
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
}
} finally {
setVideoSlotUploading(null);
setUploadQueue((prev) => prev.filter((name) => name !== file.name));
setVideoSlotUploadItemIds((current) => {
const next = { ...current };
delete next[slotIndex];
return next;
});
await holdAndRemoveUploadQueueItems([queueItem.id]);
event.currentTarget.value = "";
}
},
[updateAttachmentAtSlot, uploadFile]
[holdAndRemoveUploadQueueItems, updateAttachmentAtSlot, uploadFile]
);
// 🆕 视频模式下的图片数量限制
@ -879,11 +1017,14 @@ function PureMultimodalInput({
return;
}
const filesToUpload = files.slice(0, allowedCount);
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
setUploadQueue(filesToUpload.map((file) => file.name));
setUploadQueue((prev) => [...prev, ...queueItems]);
try {
const uploadPromises = filesToUpload.map((file) => uploadFile(file));
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== null
@ -896,13 +1037,13 @@ function PureMultimodalInput({
} catch (error) {
console.error("Error uploading files!", error);
} finally {
setUploadQueue([]);
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[enforceVideoImageLimit, setAttachments, uploadFile]
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
);
const handlePaste = useCallback(
@ -923,19 +1064,24 @@ function PureMultimodalInput({
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, "已粘贴图片"]);
let queueItems: UploadQueueItem[] = [];
try {
const allowedCount = enforceVideoImageLimit(imageItems.length);
if (allowedCount <= 0) {
setUploadQueue([]);
return;
}
const uploadPromises = imageItems
const filesToUpload = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
.slice(0, allowedCount)
.map((file) => uploadFile(file));
.slice(0, allowedCount);
queueItems = filesToUpload.map((file, index) =>
createUploadQueueItem(file, `已粘贴图片 ${index + 1}`)
);
setUploadQueue((prev) => [...prev, ...queueItems]);
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
@ -953,10 +1099,10 @@ function PureMultimodalInput({
console.error("Error uploading pasted images:", error);
toast.error("粘贴的图片上传失败");
} finally {
setUploadQueue([]);
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
}
},
[enforceVideoImageLimit, setAttachments, uploadFile]
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
);
// Add paste event listener to textarea
@ -1022,20 +1168,20 @@ function PureMultimodalInput({
const allowedCount = enforceVideoImageLimit(imageFiles.length);
if (allowedCount <= 0) {
setUploadQueue([]);
return;
}
const filesToUpload = imageFiles.slice(0, allowedCount);
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
console.log("[拖拽] 开始处理文件:", imageFiles.length, "个");
// 添加到上传队列
setUploadQueue((prev) => [...prev, ...filesToUpload.map((f) => f.name)]);
setUploadQueue((prev) => [...prev, ...queueItems]);
try {
const uploadedAttachments = await Promise.all(
filesToUpload.map((file) => uploadFile(file))
filesToUpload.map((file, index) => uploadFile(file, queueItems[index]?.id))
);
const validAttachments = uploadedAttachments.filter(
@ -1050,10 +1196,10 @@ function PureMultimodalInput({
console.error("拖拽上传失败:", error);
toast.error("拖拽的图片上传失败");
} finally {
setUploadQueue([]);
await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id));
}
},
[uploadFile]
[enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile]
);
// 🆕 拖拽排序处理函数
@ -1155,12 +1301,44 @@ function PureMultimodalInput({
}
}}
>
{uploadProgressSummary && (
<div
aria-live="polite"
className="mb-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2"
data-testid="upload-progress-banner"
>
<div className="mb-1.5 flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate font-medium text-foreground">
{uploadProgressSummary.count > 1
? `正在上传 ${uploadProgressSummary.count} 张图片`
: uploadProgressSummary.statusText}
</span>
<span className="shrink-0 tabular-nums text-primary">
{uploadProgressSummary.percent}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-primary/15">
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
style={{ width: `${uploadProgressSummary.percent}%` }}
/>
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground">
{uploadProgressSummary.fileName}
</div>
</div>
)}
{videoFrameSlots.length > 0 && (
<div className="mb-2 flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
const disabled = index > 0 && !attachments[index - 1];
const isUploading = videoSlotUploading === index;
const uploadItem = isUploading
? uploadQueue.find((item) => item.id === videoSlotUploadItemIds[index])
: null;
const uploadProgress = uploadItem?.progress ?? 0;
return (
<button
key={`video-slot-${label}-${index}`}
@ -1196,10 +1374,22 @@ function PureMultimodalInput({
</div>
</>
) : (
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">{isUploading ? "上传中" : label}</span>
</div>
<>
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">
{isUploading ? `${uploadProgress}%` : label}
</span>
</div>
{isUploading && (
<div className="absolute inset-x-2 bottom-2 h-1 overflow-hidden rounded-full bg-black/15">
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</>
)}
</button>
);
@ -1284,15 +1474,17 @@ function PureMultimodalInput({
/>
))}
{uploadQueue.map((filename) => (
{uploadQueue.map((item) => (
<PreviewAttachment
attachment={{
url: "",
name: filename,
name: item.name,
contentType: "",
}}
isUploading={true}
key={filename}
key={item.id}
uploadProgress={item.progress}
uploadStatus={item.status}
/>
))}
</div>

View File

@ -59,8 +59,6 @@ export function PascalEditorFrame({
const lightLogo = String(siteLogo || "").trim();
return darkLogo || lightLogo;
}, [siteLogo, siteLogoDark]);
const shouldInvertLogo =
!String(siteLogoDark || "").trim() && Boolean(String(siteLogo || "").trim());
const userEmail = String(user?.email || "").trim();
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
const isGuest = guestRegex.test(userEmail);
@ -91,10 +89,7 @@ export function PascalEditorFrame({
{displayLogo ? (
<img
alt={resolvedSiteName}
className={cn(
"h-7 w-7 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
className="h-7 w-7 shrink-0 object-contain"
src={displayLogo}
/>
) : (

View File

@ -22,9 +22,13 @@ export const PreviewAttachment = ({
onAnnotate,
onDrawingComplete,
imageIndex,
uploadProgress,
uploadStatus,
}: {
attachment: Attachment;
isUploading?: boolean;
uploadProgress?: number;
uploadStatus?: "preparing" | "uploading" | "finalizing";
onRemove?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
@ -40,6 +44,16 @@ export const PreviewAttachment = ({
imageIndex?: number;
}) => {
const { name, url, contentType } = attachment;
const normalizedUploadProgress =
typeof uploadProgress === "number" && Number.isFinite(uploadProgress)
? Math.max(0, Math.min(100, Math.round(uploadProgress)))
: null;
const uploadStatusLabel =
uploadStatus === "preparing"
? "处理中"
: uploadStatus === "finalizing"
? "保存中"
: "上传中";
const [isOpen, setIsOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
@ -164,7 +178,7 @@ export const PreviewAttachment = ({
onDragOver={onDragOver}
onDrop={onDrop}
>
{contentType?.startsWith("image") ? (
{!isUploading && contentType?.startsWith("image") ? (
<Image
alt={name ?? "图片附件"}
className="size-full object-cover"
@ -180,10 +194,21 @@ export const PreviewAttachment = ({
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/50"
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 px-2 text-white"
data-testid="input-attachment-loader"
>
<Loader size={16} />
<span className="text-[10px] font-medium leading-none">
{normalizedUploadProgress === null
? uploadStatusLabel
: `${normalizedUploadProgress}%`}
</span>
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-[width] duration-200"
style={{ width: `${normalizedUploadProgress ?? 18}%` }}
/>
</div>
</div>
)}

View File

@ -150,7 +150,7 @@ export function TopNavbar({
}
};
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
// 彩色 logo 不能用滤镜自动反白;暗色主题只在配置了独立 dark logo 时切换。
const displayLogo =
resolvedTheme === "dark"
? siteLogoDark && siteLogoDark.trim() !== ""
@ -159,10 +159,6 @@ export function TopNavbar({
: siteLogo && siteLogo.trim() !== ""
? siteLogo
: siteLogoDark;
const shouldInvertLogo =
resolvedTheme === "dark" &&
(!siteLogoDark || siteLogoDark.trim() === "") &&
Boolean(siteLogo && siteLogo.trim() !== "");
const getShortLabel = (label: string) => {
const trimmed = label.trim();
@ -287,7 +283,7 @@ export function TopNavbar({
{displayLogo && (
<img
alt={siteName}
className={shouldInvertLogo ? "h-7 w-7 object-contain brightness-0 invert" : "h-7 w-7 object-contain"}
className="h-7 w-7 object-contain"
src={displayLogo}
/>
)}

View File

@ -19,6 +19,10 @@ import {
normalizeAgentThreadState,
type AgentThreadState,
} from "@/lib/ai/runtime/agent-thread-state";
import {
buildAgentPlanningBriefMarkdown,
isAgentPlanningBriefMarkdown,
} from "@/lib/ai/runtime/agent-planning-brief";
import {
getAiModelAgentCodingModelId,
isAiModelMultimodal,
@ -1153,17 +1157,59 @@ function buildStrategicFriendlyMessage(
if (existingReply) {
const isStructuredReply =
isAgentPlanningBriefMarkdown(existingReply) ||
/###\s*意图分析/.test(existingReply) ||
/###\s*任务规划/.test(existingReply) ||
/###\s*需求理解/.test(existingReply) ||
/###\s*图片分析/.test(existingReply) ||
/###\s*执行计划/.test(existingReply) ||
/【\s*意图分析\s*】/.test(existingReply) ||
/【\s*任务规划\s*】/.test(existingReply) ||
/【\s*需求理解\s*】/.test(existingReply) ||
/【\s*图片分析\s*】/.test(existingReply) ||
/【\s*执行计划\s*】/.test(existingReply);
if (isStructuredReply || existingReply.length >= 24) {
if (isStructuredReply) {
return Promise.resolve(existingReply);
}
}
if (
analysis.intent === "image_edit" ||
analysis.intent === "image_gen" ||
analysis.intent === "video_gen"
) {
if (existingReply) {
return Promise.resolve(existingReply);
}
const imageParams = heuristicImageParams(state.text, analysis);
const planningBrief = buildAgentPlanningBriefMarkdown({
userText: state.text,
intent: analysis.intent,
subIntent: analysis.subIntent,
nextAction: state.plan?.nextAction,
preferredCapability: state.plan?.preferredCapability,
targetSource: state.plan?.targetSource || state.targetResolutionMode,
hasImage: state.hasImage,
imageFromHistory: state.imageFromHistory,
selectedImageCount: state.selectedImageCount,
currentUploadImageCount: state.currentUploadImageCount,
historyImageCount: state.historyImageCount,
imageCount: imageParams?.imageCount,
aspectRatio: imageParams?.aspectRatio,
imageSize: imageParams?.imageSize,
planGoal: state.plan?.goal,
});
if (process.env.AGENT_LLM_PLANNING_BRIEF_ENABLED !== "true") {
return Promise.resolve(planningBrief);
}
return withTimeoutFallback(
generateAgentConversation(analysis, state),
AGENT_OPERATION_TIMEOUT_MS,
planningBrief,
);
}
return withTimeoutFallback(
generateAgentConversation(analysis, state),
AGENT_OPERATION_TIMEOUT_MS,

View 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);
}

View File

@ -503,6 +503,72 @@ export function getDirectAgentToolLabel(capability: DirectAgentToolCapability):
}
}
export function getDirectAgentToolDisplayName(
capability: DirectAgentToolCapability,
): string {
switch (capability) {
case "analyze":
return "图片分析";
case "generic_edit":
return "图片编辑";
case "remove_background":
return "抠图";
case "remove_watermark":
return "去水印";
case "enhance":
return "图像增强";
case "resize":
return "尺寸调整";
case "extend":
return "图像拓展";
case "angle_variant":
return "多视角";
case "local_edit":
return "局部编辑";
case "box_cutout":
return "框选抠图";
case "generate_image":
return "图片生成";
case "generate_video":
return "视频生成";
default:
return "图像处理";
}
}
export function getDirectAgentToolPrefaceFallback(
capability: DirectAgentToolCapability,
): string {
switch (capability) {
case "analyze":
return "好的,我先帮你看看这张图,稍等我一下。";
case "generic_edit":
return "好的,我来帮你修改这张图,稍等我一下。";
case "remove_background":
return "好的,我来帮你抠出主体,稍等我一下。";
case "remove_watermark":
return "好的,我来帮你去掉水印,稍等我一下。";
case "enhance":
return "好的,我来帮你增强这张图,稍等我一下。";
case "resize":
return "好的,我来帮你调整图片尺寸,稍等我一下。";
case "extend":
return "好的,我来帮你扩展这张图,稍等我一下。";
case "angle_variant":
return "好的,我来帮你生成新的视角,稍等我一下。";
case "local_edit":
return "好的,我来帮你修改这个局部区域,稍等我一下。";
case "box_cutout":
return "好的,我来帮你把框选区域抠出来,稍等我一下。";
case "generate_image":
return "好的,我按你的描述来做一版画面,稍等我处理一下。";
case "generate_video":
return "好的,我按你的需求来做这段视频,稍等我处理一下。";
default:
return "好的,我来继续处理,稍等我一下。";
}
}
export function findAgentToolCallArgs(
toolName: AgentPlannedToolCall["name"],
execution?: AgentPreparedExecution,

View File

@ -169,15 +169,23 @@ export async function executeDirectAgentImageGeneration(
params.imageParams?.imageSize || params.defaultImageQualityParams?.imageSize;
const imageCount =
params.imageParams?.imageCount || params.defaultImageQualityParams?.imageCount;
const shouldIgnoreModelMaxImages =
typeof imageCount === "number" && Number.isFinite(imageCount) && imageCount > 1;
const result = await generateCanvasImage({
prompt,
negativePrompt: params.imageParams?.negativePrompt,
model: params.finalModelId,
ownerKey: params.userId,
debugLabel: "direct-agent-image-generation",
debugMeta: {
agentIgnoreModelMaxImages: shouldIgnoreModelMaxImages,
requestedAgentImageCount: imageCount ?? null,
},
aspectRatio:
params.imageParams?.aspectRatio || params.defaultImageQualityParams?.aspectRatio,
imageSize,
imageCount,
ignoreModelMaxImages: shouldIgnoreModelMaxImages,
referenceImages: params.referenceImageUrls,
});

View File

@ -5,7 +5,7 @@ import {
prependImageModelBadge,
} from "@/lib/ai/runtime/agent-media-markdown";
import {
getDirectAgentToolLabel,
getDirectAgentToolDisplayName,
getDirectAgentToolSuccessMessage,
type DirectAgentToolCapability,
} from "@/lib/ai/runtime/direct-agent-execution";
@ -73,7 +73,7 @@ export function buildDirectAgentAnalysisAssistantText(params: {
]
.filter(Boolean)
.join("\n\n"),
getDirectAgentToolLabel("analyze"),
getDirectAgentToolDisplayName("analyze"),
);
}
@ -121,7 +121,7 @@ export function buildDirectAgentImageEditAssistantText(params: {
}),
followUp: getDirectAgentFollowUpMessage(params.capability),
}),
getDirectAgentToolLabel(params.capability),
getDirectAgentToolDisplayName(params.capability),
);
}
@ -139,6 +139,6 @@ export function buildDirectAgentBatchImageEditAssistantText(params: {
mediaMarkdown: buildAgentToolImagesMarkdown(params.results),
followUp: getDirectAgentFollowUpMessage(params.capability),
}),
getDirectAgentToolLabel(params.capability),
getDirectAgentToolDisplayName(params.capability),
);
}

View File

@ -132,6 +132,7 @@ export interface CanvasGenerateImageRequest {
ownerKey?: string;
debugLabel?: string;
debugMeta?: Record<string, unknown>;
ignoreModelMaxImages?: boolean;
localEditComposite?: CanvasLocalEditCompositeConfig;
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
@ -3949,12 +3950,14 @@ function normalizeCanvasImageParamsByModel(params: {
imageCount: number;
imageSize: string;
imageGenConfig: CanvasImageGenConfig | null | undefined;
ignoreModelMaxImages?: boolean;
}) {
const maxImages =
const configuredMaxImages =
typeof params.imageGenConfig?.maxImages === "number" &&
Number.isFinite(params.imageGenConfig.maxImages)
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
: 12;
const maxImages = params.ignoreModelMaxImages ? 12 : configuredMaxImages;
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
@ -4470,6 +4473,7 @@ export async function generateCanvasImage(
imageCount: requestedImageCount,
imageSize: requestedImageSize,
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
ignoreModelMaxImages: request.ignoreModelMaxImages === true,
});
const imageSize = normalizedImageParams.imageSize;
const imageCount =

View File

@ -1,12 +1,18 @@
const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
const DIRECT_UPLOAD_MIN_BYTES = 1;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 3 * 1000;
const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 15 * 1000;
const DIRECT_UPLOAD_ABORT_TIMEOUT_MS = 8 * 1000;
const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 10 * 1000;
const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000;
const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000;
const RETRYABLE_UPLOAD_STATUSES = new Set([
408,
@ -36,6 +42,23 @@ let activeUploadCount = 0;
const uploadQueue: Array<() => void> = [];
let directUploadSuppressedUntil = 0;
export type UploadProgressPhase =
| "preparing"
| "direct"
| "multipart"
| "server"
| "finalizing"
| "complete";
export type UploadProgress = {
loaded: number;
total: number;
percent: number;
phase: UploadProgressPhase;
};
type UploadProgressHandler = (progress: UploadProgress) => void;
type UploadFormFile = {
blob: Blob;
filename: string;
@ -71,6 +94,12 @@ function createAbortError() {
return error;
}
function createTimeoutError(message: string) {
const error = new Error(message);
error.name = "TimeoutError";
return error;
}
function sleep(ms: number, signal?: AbortSignal) {
if (signal?.aborted) {
return Promise.reject(createAbortError());
@ -146,6 +175,38 @@ function isAbortError(error: unknown, signal?: AbortSignal) {
);
}
function clampPercent(value: number, max = 99) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(max, Math.round(value)));
}
function emitUploadProgress(
onProgress: UploadProgressHandler | undefined,
loaded: number,
total: number,
phase: UploadProgressPhase,
options?: { percent?: number; maxPercent?: number }
) {
if (!onProgress) return;
const safeLoaded = Math.max(0, Math.round(loaded || 0));
const safeTotal = Math.max(0, Math.round(total || 0));
const computedPercent =
typeof options?.percent === "number"
? options.percent
: safeTotal > 0
? (safeLoaded / safeTotal) * 100
: 0;
const maxPercent = options?.maxPercent ?? (phase === "complete" ? 100 : 99);
onProgress({
loaded: safeLoaded,
total: safeTotal,
percent: clampPercent(computedPercent, maxPercent),
phase,
});
}
function cloneFormData(formData: FormData) {
const copy = new FormData();
@ -263,18 +324,90 @@ async function readErrorPayload(response: Response) {
}
}
async function fetchWithControlTimeout(
input: RequestInfo | URL,
init: RequestInit,
timeoutMs: number,
signal?: AbortSignal
) {
if (signal?.aborted) {
throw createAbortError();
}
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | null = null;
let timedOut = false;
let settled = false;
const onAbort = () => {
controller.abort();
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
const request = fetch(input, {
...init,
signal: controller.signal,
}).catch((error) => {
if (signal?.aborted) {
throw createAbortError();
}
if (timedOut) {
throw createTimeoutError(
`Direct upload control request timed out after ${Math.round(
timeoutMs / 1000
)}s`
);
}
throw error;
});
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
timedOut = true;
controller.abort();
if (!settled) {
reject(
createTimeoutError(
`Direct upload control request timed out after ${Math.round(
timeoutMs / 1000
)}s`
)
);
}
}, timeoutMs);
});
return await Promise.race([request, timeoutPromise]);
} finally {
settled = true;
if (timeout) {
clearTimeout(timeout);
}
signal?.removeEventListener("abort", onAbort);
}
}
async function postDirectUploadAction<T>(
payload: Record<string, unknown>,
signal?: AbortSignal
signal?: AbortSignal,
timeoutMs = DIRECT_UPLOAD_ACTION_TIMEOUT_MS
): Promise<T> {
const response = await fetch("/api/files/direct-upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetchWithControlTimeout(
"/api/files/direct-upload",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
body: JSON.stringify(payload),
signal,
});
timeoutMs,
signal
);
if (!response.ok) {
throw new Error(String(await readErrorPayload(response)));
@ -283,18 +416,167 @@ async function postDirectUploadAction<T>(
return (await response.json()) as T;
}
function createResponseFromXhr(xhr: XMLHttpRequest) {
const headers = new Headers();
const rawHeaders = xhr.getAllResponseHeaders();
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
if (!line) continue;
const separatorIndex = line.indexOf(":");
if (separatorIndex <= 0) continue;
headers.append(
line.slice(0, separatorIndex).trim(),
line.slice(separatorIndex + 1).trim()
);
}
return new Response(xhr.responseText || null, {
status: xhr.status,
statusText: xhr.statusText,
headers,
});
}
function createJsonResponse(payload: unknown, init?: ResponseInit) {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return new Response(JSON.stringify(payload), {
...init,
headers,
});
}
function normalizeXhrHeaders(headers?: HeadersInit) {
if (!headers) return [] as Array<[string, string]>;
if (headers instanceof Headers) {
return Array.from(headers.entries());
}
if (Array.isArray(headers)) {
return headers.map(([key, value]) => [key, String(value)] as [string, string]);
}
return Object.entries(headers).map(
([key, value]) => [key, String(value)] as [string, string]
);
}
function xhrUpload(
url: string,
init: {
method: string;
headers?: HeadersInit;
body: XMLHttpRequestBodyInit;
},
options?: {
signal?: AbortSignal;
onProgress?: (loaded: number, total: number) => void;
timeoutMs?: number;
stallTimeoutMs?: number;
}
) {
return new Promise<Response>((resolve, reject) => {
if (options?.signal?.aborted) {
reject(createAbortError());
return;
}
const xhr = new XMLHttpRequest();
let settled = false;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
const clearStallTimer = () => {
if (!stallTimer) return;
clearTimeout(stallTimer);
stallTimer = null;
};
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearStallTimer();
options?.signal?.removeEventListener("abort", onAbort);
callback();
};
const onAbort = () => {
xhr.abort();
settle(() => reject(createAbortError()));
};
xhr.open(init.method, url, true);
xhr.timeout = options?.timeoutMs ?? XHR_UPLOAD_TOTAL_TIMEOUT_MS;
for (const [key, value] of normalizeXhrHeaders(init.headers)) {
xhr.setRequestHeader(key, value);
}
const resetStallTimer = () => {
clearStallTimer();
if (settled) return;
const stallTimeoutMs =
options?.stallTimeoutMs ?? XHR_UPLOAD_STALL_TIMEOUT_MS;
if (stallTimeoutMs <= 0) return;
stallTimer = setTimeout(() => {
const error = new Error(
`Upload stalled for ${Math.round(stallTimeoutMs / 1000)}s`
);
error.name = "TimeoutError";
settle(() => reject(error));
xhr.abort();
}, stallTimeoutMs);
};
xhr.upload.onprogress = (event) => {
resetStallTimer();
const total = event.lengthComputable ? event.total : 0;
options?.onProgress?.(event.loaded, total);
};
xhr.onload = () => {
settle(() => resolve(createResponseFromXhr(xhr)));
};
xhr.onerror = () => {
settle(() => reject(new TypeError("Network request failed")));
};
xhr.ontimeout = () => {
settle(() => reject(new Error("Upload request timed out")));
};
xhr.onabort = () => {
settle(() => reject(createAbortError()));
};
options?.signal?.addEventListener("abort", onAbort, { once: true });
resetStallTimer();
xhr.send(init.body);
});
}
async function fetchDirectPartWithRetry(
url: string,
init: RequestInit,
init: {
method: string;
headers?: HeadersInit;
body: XMLHttpRequestBodyInit;
},
maxRetries: number,
signal?: AbortSignal
signal?: AbortSignal,
onProgress?: (loaded: number, total: number) => void,
options?: { stallTimeoutMs?: number }
) {
for (let attempt = 0; ; attempt += 1) {
try {
const response = await fetch(url, {
...init,
signal,
});
const response = await xhrUpload(
url,
init,
{
onProgress,
signal,
stallTimeoutMs: options?.stallTimeoutMs,
}
);
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
@ -311,15 +593,79 @@ async function fetchDirectPartWithRetry(
}
}
async function fetchServerUploadWithRetry(
formData: FormData,
maxRetries: number,
signal?: AbortSignal,
onProgress?: UploadProgressHandler
) {
for (let attempt = 0; ; attempt++) {
try {
const response = await xhrUpload(
"/api/files/upload",
{
method: "POST",
body: cloneFormData(formData),
},
{
signal,
onProgress: (loaded, total) => {
emitUploadProgress(onProgress, loaded, total, "server");
},
}
);
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
await sleep(computeBackoffMs(attempt, retryAfterMs), signal);
} catch (error) {
if (isAbortError(error, signal) || attempt >= maxRetries) {
throw error;
}
await sleep(computeBackoffMs(attempt, null), signal);
}
}
}
async function uploadMultipartParts(params: {
file: UploadFormFile;
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
maxRetries: number;
signal?: AbortSignal;
onProgress?: UploadProgressHandler;
}) {
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
const partLoadedBytes = new Map<number, number>();
let nextIndex = 0;
const reportPartProgress = (partNumber: number, loaded: number, total: number) => {
partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded));
const uploadedBytes = Array.from(partLoadedBytes.values()).reduce(
(sum, value) => sum + value,
0
);
emitUploadProgress(
params.onProgress,
uploadedBytes,
params.file.blob.size,
"multipart"
);
};
async function worker() {
for (;;) {
const part = params.upload.parts[nextIndex];
@ -336,13 +682,19 @@ async function uploadMultipartParts(params: {
body,
},
params.maxRetries,
params.signal
params.signal,
(loaded, total) => {
reportPartProgress(part.partNumber, loaded, total || body.size);
},
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
);
if (!response.ok) {
throw new Error(`Direct multipart upload failed: ${response.status}`);
}
reportPartProgress(part.partNumber, body.size, body.size);
const etag = response.headers.get("ETag") || response.headers.get("etag");
if (!etag) {
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
@ -368,7 +720,8 @@ async function uploadMultipartParts(params: {
async function fetchDirectUpload(
formData: FormData,
maxRetries: number,
signal?: AbortSignal
signal?: AbortSignal,
onProgress?: UploadProgressHandler
): Promise<Response | null> {
const file = getUploadFormFile(formData);
if (!file || !shouldTryDirectUpload(file)) {
@ -380,6 +733,9 @@ async function fetchDirectUpload(
| null = null;
try {
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
percent: 1,
});
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "initiate",
@ -389,10 +745,14 @@ async function fetchDirectUpload(
purpose: file.purpose,
clientUploadId: file.clientUploadId,
},
signal
signal,
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
);
if (upload.mode === "single") {
emitUploadProgress(onProgress, 0, file.blob.size, "direct", {
percent: 2,
});
const response = await fetchDirectPartWithRetry(
upload.url,
{
@ -403,13 +763,20 @@ async function fetchDirectUpload(
body: file.blob,
},
maxRetries,
signal
signal,
(loaded, total) => {
emitUploadProgress(onProgress, loaded, total || file.blob.size, "direct");
},
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS }
);
if (!response.ok) {
throw new Error(`Direct upload failed: ${response.status}`);
}
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99,
});
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
@ -423,15 +790,25 @@ async function fetchDirectUpload(
signal
);
return Response.json(payload);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100,
});
return createJsonResponse(payload);
}
multipartUpload = upload;
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
percent: 2,
});
const parts = await uploadMultipartParts({
file,
upload,
maxRetries,
signal,
onProgress,
});
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99,
});
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
@ -448,7 +825,10 @@ async function fetchDirectUpload(
signal
);
return Response.json(payload);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100,
});
return createJsonResponse(payload);
} catch (error) {
if (multipartUpload && !isAbortError(error, signal)) {
postDirectUploadAction(
@ -458,13 +838,14 @@ async function fetchDirectUpload(
uploadId: multipartUpload.uploadId,
clientUploadId: file.clientUploadId,
},
signal
signal,
DIRECT_UPLOAD_ABORT_TIMEOUT_MS
).catch(() => {});
}
if (!isAbortError(error, signal)) {
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) {
return Response.json(
return createJsonResponse(
{
error:
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
@ -489,6 +870,7 @@ export async function fetchUploadWithRetry(
options?: {
maxRetries?: number;
signal?: AbortSignal;
onProgress?: UploadProgressHandler;
}
) {
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
@ -497,45 +879,27 @@ export async function fetchUploadWithRetry(
const directResponse = await fetchDirectUpload(
formData,
maxRetries,
options?.signal
options?.signal,
options?.onProgress
);
if (directResponse) {
return directResponse;
}
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch("/api/files/upload", {
method: "POST",
body: cloneFormData(formData),
signal: options?.signal,
});
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
const delayMs = computeBackoffMs(attempt, retryAfterMs);
await sleep(delayMs, options?.signal);
} catch (error) {
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
throw error;
}
const delayMs = computeBackoffMs(attempt, null);
await sleep(delayMs, options?.signal);
}
const response = await fetchServerUploadWithRetry(
formData,
maxRetries,
options?.signal,
options?.onProgress
);
if (response.ok) {
options?.onProgress?.({
loaded: 1,
total: 1,
percent: 100,
phase: "complete",
});
}
return response;
});
}

View File

@ -42,6 +42,28 @@ function isAliyunOssImageHost(hostname: string): boolean {
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
}
function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return (
normalized === "yybb.top" ||
normalized.endsWith(".qiniucs.com") ||
normalized.endsWith(".qiniudn.com") ||
normalized.endsWith(".clouddn.com") ||
normalized.endsWith(".qbox.me")
);
}
function resolveTrustedDirectStorageDisplayUrl(url: string): string | null {
try {
const target = new URL(url);
return isTrustedDirectStorageImageHost(target.hostname)
? target.toString()
: null;
} catch {
return null;
}
}
function resolveAliyunOssDisplayUrl(
url: string,
options: Pick<ImageProxyOptions, "width" | "quality" | "format">
@ -94,6 +116,11 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
return source;
}
const trustedDirectStorageUrl = resolveTrustedDirectStorageDisplayUrl(source);
if (trustedDirectStorageUrl) {
return trustedDirectStorageUrl;
}
if (options.delivery !== "proxy") {
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
if (directDisplayUrl) {

View 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);
}

View File

@ -253,8 +253,7 @@ async function uploadToS3(
Body: Buffer.from(buffer),
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
// 设置为公开可读(如果您的 MinIO 桶支持)
ACL: "public-read",
...(config.provider === "qiniu-kodo" ? {} : { ACL: "public-read" as const }),
});
await withStorageOperationTimeout(

View File

@ -6,23 +6,26 @@ import {
S3Client,
type CORSRule,
} from "@aws-sdk/client-s3";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" });
loadEnv();
type S3UploadConfig = {
name?: string;
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
provider?: "s3" | "qiniu-kodo";
};
const DEFAULT_ORIGINS = [
"https://ai.yydd.ai",
"https://yydd.ai",
"https://bbyy.ai",
"https://yybb.top",
"https://goyingup.chat",
"http://localhost:3000",
"http://localhost:3001",
@ -54,7 +57,7 @@ function createS3Client(config: S3UploadConfig) {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: !isCloudS3,
forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
});
}
@ -69,7 +72,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
if (
!raw ||
raw.type !== "s3" ||
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
raw.isEnabled !== true ||
!endpoint ||
!bucket ||
@ -80,11 +83,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
}
return {
name: raw.name ? String(raw.name) : undefined,
endpoint,
region: String(raw.region || "auto").trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
};
}
@ -101,6 +106,7 @@ function readEnvS3Config(): S3UploadConfig | null {
}
return {
name: "Environment S3",
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
bucket,
@ -109,24 +115,31 @@ function readEnvS3Config(): S3UploadConfig | null {
};
}
async function resolveS3Config() {
async function resolveS3Configs() {
const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"),
import("@/lib/db/schema"),
]);
const [defaultConfig] = await db
const storageConfigs = await db
.select()
.from(storageConfig)
.where(
and(
eq(storageConfig.type, "s3"),
eq(storageConfig.isDefault, true),
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
eq(storageConfig.isEnabled, true)
)
)
.limit(1);
);
return toS3Config(defaultConfig) || readEnvS3Config();
const configs = storageConfigs
.map(toS3Config)
.filter((config): config is S3UploadConfig => Boolean(config));
const envConfig = readEnvS3Config();
if (configs.length === 0 && envConfig) {
return [envConfig];
}
return configs;
}
function readOrigins() {
@ -159,15 +172,21 @@ function isDirectUploadRule(rule: CORSRule, origins: string[]) {
);
}
function isCorsApiUnsupported(error: unknown) {
const value = error as { Code?: string; name?: string; message?: string } | null;
const code = String(value?.Code || value?.name || "");
const message = String(value?.message || "");
return code === "NotImplemented" || /not implemented/i.test(message);
}
async function main() {
const config = await resolveS3Config();
if (!config) {
console.log("[direct-upload-cors] skip: no S3-compatible upload target");
const configs = await resolveS3Configs();
if (configs.length === 0) {
console.log("[direct-upload-cors] skip: no S3-compatible upload targets");
return;
}
const origins = readOrigins();
const client = createS3Client(config);
const directRule: CORSRule = {
AllowedOrigins: origins,
AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
@ -175,41 +194,77 @@ async function main() {
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
MaxAgeSeconds: 3600,
};
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
let existingRules: CORSRule[] = [];
try {
const current = await client.send(
new GetBucketCorsCommand({ Bucket: config.bucket })
);
existingRules = current.CORSRules || [];
} catch (error) {
console.warn(
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
error instanceof Error ? error.message : String(error)
);
for (const config of configs) {
const client = createS3Client(config);
let existingRules: CORSRule[] = [];
try {
const current = await client.send(
new GetBucketCorsCommand({ Bucket: config.bucket })
);
existingRules = current.CORSRules || [];
} catch (error) {
console.warn(
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
{
name: config.name,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
}
);
}
const mergedRules = [
directRule,
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
]
.slice(0, 100)
.map(normalizeRule);
try {
await client.send(
new PutBucketCorsCommand({
Bucket: config.bucket,
CORSConfiguration: {
CORSRules: mergedRules,
},
})
);
console.log("[direct-upload-cors] configured", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
origins,
ruleCount: mergedRules.length,
});
} catch (error) {
if (isCorsApiUnsupported(error)) {
console.warn("[direct-upload-cors] bucket CORS API unsupported; leaving provider/global CORS in place", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
failures.push({ config, error });
console.error("[direct-upload-cors] configure bucket failed", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
}
}
const mergedRules = [
directRule,
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
]
.slice(0, 100)
.map(normalizeRule);
await client.send(
new PutBucketCorsCommand({
Bucket: config.bucket,
CORSConfiguration: {
CORSRules: mergedRules,
},
})
);
console.log("[direct-upload-cors] configured", {
bucket: config.bucket,
origins,
ruleCount: mergedRules.length,
});
if (failures.length > 0) {
throw new Error(
`Failed to configure direct upload CORS for ${failures.length} bucket(s)`
);
}
}
main()

View File

@ -6,12 +6,13 @@ import {
S3Client,
type LifecycleRule,
} from "@aws-sdk/client-s3";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" });
loadEnv();
type S3UploadConfig = {
name?: string;
endpoint: string;
region: string;
bucket: string;
@ -65,7 +66,7 @@ function toS3Config(value: unknown): S3UploadConfig | null {
if (
!raw ||
raw.type !== "s3" ||
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
raw.isEnabled !== true ||
!endpoint ||
!bucket ||
@ -76,12 +77,13 @@ function toS3Config(value: unknown): S3UploadConfig | null {
}
return {
name: raw.name ? String(raw.name) : undefined,
endpoint,
region: String(raw.region || "auto").trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
provider: raw.provider === "qiniu-kodo" ? "qiniu-kodo" : "s3",
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
};
}
@ -98,6 +100,7 @@ function readEnvS3Config(): S3UploadConfig | null {
}
return {
name: "Environment S3",
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
bucket,
@ -106,24 +109,31 @@ function readEnvS3Config(): S3UploadConfig | null {
};
}
async function resolveS3Config() {
async function resolveS3Configs() {
const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"),
import("@/lib/db/schema"),
]);
const [defaultConfig] = await db
const storageConfigs = await db
.select()
.from(storageConfig)
.where(
and(
eq(storageConfig.type, "s3"),
eq(storageConfig.isDefault, true),
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
eq(storageConfig.isEnabled, true)
)
)
.limit(1);
);
return toS3Config(defaultConfig) || readEnvS3Config();
const configs = storageConfigs
.map(toS3Config)
.filter((config): config is S3UploadConfig => Boolean(config));
const envConfig = readEnvS3Config();
if (configs.length === 0 && envConfig) {
return [envConfig];
}
return configs;
}
function readAbortDays() {
@ -148,62 +158,123 @@ function normalizeRule(rule: LifecycleRule): LifecycleRule {
};
}
function isLifecycleApiUnsupported(error: unknown) {
const value = error as { Code?: string; name?: string; message?: string } | null;
const code = String(value?.Code || value?.name || "");
const message = String(value?.message || "");
return (
code === "NotImplemented" ||
/not implemented/i.test(message) ||
/not well-formed/i.test(message) ||
/validate against/i.test(message)
);
}
async function main() {
const config = await resolveS3Config();
if (!config) {
console.log("[multipart-lifecycle] skip: no S3-compatible upload target");
const configs = await resolveS3Configs();
if (configs.length === 0) {
console.log("[multipart-lifecycle] skip: no S3-compatible upload targets");
return;
}
const client = createS3Client(config);
const prefix = readRulePrefix();
const abortDays = readAbortDays();
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
let configuredCount = 0;
let existingRules: LifecycleRule[] = [];
try {
const current = await client.send(
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
);
existingRules = current.Rules || [];
} catch (error) {
console.warn(
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
error instanceof Error ? error.message : String(error)
);
for (const config of configs) {
if (config.provider === "qiniu-kodo") {
console.log("[multipart-lifecycle] skip bucket: qiniu-kodo lifecycle is not configured through S3 compatibility", {
name: config.name,
bucket: config.bucket,
});
continue;
}
const client = createS3Client(config);
let existingRules: LifecycleRule[] = [];
try {
const current = await client.send(
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
);
existingRules = current.Rules || [];
} catch (error) {
console.warn(
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
{
name: config.name,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
}
);
}
const cleanupRule: LifecycleRule = {
ID: ruleId,
Status: "Enabled",
Filter: { Prefix: prefix },
AbortIncompleteMultipartUpload: {
DaysAfterInitiation: abortDays,
},
};
const mergedRules = [
cleanupRule,
...existingRules.filter((rule) => rule.ID !== ruleId),
]
.slice(0, 1000)
.map(normalizeRule);
try {
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: config.bucket,
LifecycleConfiguration: {
Rules: mergedRules,
},
})
);
configuredCount += 1;
console.log("[multipart-lifecycle] configured", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
prefix,
abortDays,
ruleCount: mergedRules.length,
});
} catch (error) {
if (isLifecycleApiUnsupported(error)) {
console.warn("[multipart-lifecycle] bucket lifecycle API unsupported; skipping cleanup rule", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
failures.push({ config, error });
console.error("[multipart-lifecycle] configure bucket failed", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
}
}
const cleanupRule: LifecycleRule = {
ID: ruleId,
Status: "Enabled",
Filter: { Prefix: prefix },
AbortIncompleteMultipartUpload: {
DaysAfterInitiation: abortDays,
},
};
if (configuredCount === 0) {
console.log("[multipart-lifecycle] no compatible buckets required lifecycle configuration");
}
const mergedRules = [
cleanupRule,
...existingRules.filter((rule) => rule.ID !== ruleId),
]
.slice(0, 1000)
.map(normalizeRule);
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: config.bucket,
LifecycleConfiguration: {
Rules: mergedRules,
},
})
);
console.log("[multipart-lifecycle] configured", {
bucket: config.bucket,
prefix,
abortDays,
ruleCount: mergedRules.length,
});
if (failures.length > 0) {
throw new Error(
`Failed to configure multipart lifecycle for ${failures.length} bucket(s)`
);
}
}
main()

View File

@ -54,6 +54,11 @@ import {
} from "@/lib/ai/runtime/canvas-agent-kernel";
import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer";
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
import {
buildAgentPlanningBriefMarkdown,
extractAgentTaskGroups,
isAgentPlanningBriefMarkdown,
} from "@/lib/ai/runtime/agent-planning-brief";
import {
buildRecentDialogueMemory,
isRecentDialogueRecallRequest,
@ -137,7 +142,9 @@ import {
shouldBypassCanvasImagePointsPrecheckForAgentControl,
} from "@/lib/canvas/agent-client-intent";
import {
getDirectAgentToolDisplayName,
getDirectAgentToolLabel,
getDirectAgentToolPrefaceFallback,
getDirectAgentToolSuccessMessage,
hasExplicitDirectAgentBatchImageEditIntent,
isDirectAgentBatchImageEditCapability,
@ -385,6 +392,39 @@ test.describe("canvas chat pure helpers", () => {
expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12);
});
test("builds market-grade planning brief for grouped image generation requests", () => {
const request =
"基于上传的潮玩角色图片,生成3组共12张图片,要求保持角色核心特征一致。第一组为4张动作表情夸张的半身表情包;第二组为4张不同场景的角色活动照;第三组为4张服装发型差异巨大的造型照,所有图片比例保持3:4。";
const groups = extractAgentTaskGroups(request);
expect(groups).toHaveLength(3);
expect(groups.map((group) => group.count)).toEqual([4, 4, 4]);
expect(groups[0].description).toContain("半身表情包");
expect(groups[1].description).toContain("不同场景");
expect(groups[2].description).toContain("服装发型");
const markdown = buildAgentPlanningBriefMarkdown({
userText: request,
intent: "image_gen",
nextAction: "generate_image",
preferredCapability: "generate_image",
targetSource: "current_upload",
hasImage: true,
imageCount: 12,
aspectRatio: "3:4",
imageSize: "2K",
});
expect(isAgentPlanningBriefMarkdown(markdown)).toBe(true);
expect(markdown).toContain("### 意图分析");
expect(markdown).toContain("拆成 3 组,共 12 张");
expect(markdown).toContain("### 任务规划");
expect(markdown).toContain("第1组:生成4张");
expect(markdown).toContain("第2组:生成4张");
expect(markdown).toContain("第3组:生成4张");
expect(markdown).toContain("图片比例:3:4");
});
test("keeps canvas message empty-history access scoped and explicit", () => {
const canvasChatId = "11111111-1111-4111-8111-111111111111";
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
@ -1275,9 +1315,20 @@ test.describe("canvas chat pure helpers", () => {
expect(editText).toContain("### 执行结果");
expect(editText).toContain("- 已完成抠图处理,结果如下。");
expect(editText).toContain("https://cdn.example.com/cutout.png");
expect(editText).toContain("◈ 抠图");
expect(editText).not.toContain("Agent工具:");
expect(editText).toContain("替换背景");
});
test("uses user-facing direct agent tool labels in fallbacks", () => {
expect(getDirectAgentToolDisplayName("generic_edit")).toBe("图片编辑");
expect(getDirectAgentToolDisplayName("local_edit")).toBe("局部编辑");
expect(getDirectAgentToolPrefaceFallback("generic_edit")).toBe(
"好的,我来帮你修改这张图,稍等我一下。"
);
expect(getDirectAgentToolPrefaceFallback("generic_edit")).not.toContain("Agent工具:");
});
test("builds capability-aware agent progress node labels", () => {
const selectingSteps = buildAgentProgressNodeSteps({
stage: "planning",