diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 62b4069fd..8e98180aa 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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() { {siteName} ) : null}

登录 {siteName}

diff --git a/app/(chat)/api/chat/_lib/agent-output.ts b/app/(chat)/api/chat/_lib/agent-output.ts index 9b04ebc34..9d9334c47 100644 --- a/app/(chat)/api/chat/_lib/agent-output.ts +++ b/app/(chat)/api/chat/_lib/agent-output.ts @@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string { .trim(); const knownTitles = [ + "意图分析", + "任务规划", "需求理解", "图片分析", "执行计划", diff --git a/app/(chat)/api/chat/_lib/image-media.ts b/app/(chat)/api/chat/_lib/image-media.ts index 5b5a8bfac..844f6aa92 100644 --- a/app/(chat)/api/chat/_lib/image-media.ts +++ b/app/(chat)/api/chat/_lib/image-media.ts @@ -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`, diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index fcbfd0b3d..e4015b6e6 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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`, ); diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts index bd96c74ec..af795c131 100644 --- a/app/(chat)/api/files/direct-upload/route.ts +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -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 = { + "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 }, diff --git a/app/api/canvas/clawd-chat/cancel/route.ts b/app/api/canvas/clawd-chat/cancel/route.ts new file mode 100644 index 000000000..138a372e1 --- /dev/null +++ b/app/api/canvas/clawd-chat/cancel/route.ts @@ -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 }); +} diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 354dd11d7..01479493a 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -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 > | 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, diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 0382b454f..092c6190d 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -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 = `\ - - - - -`; - 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(); } } diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 2a48e250c..f55b3e953 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -9640,10 +9640,6 @@ export default function CanvasEditorLegacyPage({ : lightCanvasBrandLogo || darkCanvasBrandLogo : lightCanvasBrandLogo || darkCanvasBrandLogo ).trim(); - const shouldInvertCanvasBrandLogo = - isDarkCanvasTheme && - Boolean(canvasBrandLogo) && - !hasSeparateDarkCanvasBrandLogo; const brandEntry = (
diff --git a/app/layout.tsx b/app/layout.tsx index f26b5bee7..fcee64a6a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -24,19 +24,10 @@ export async function generateMetadata(): Promise { "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", diff --git a/components/agents/AgentCanvasTopNavDark.tsx b/components/agents/AgentCanvasTopNavDark.tsx index ebb5a0613..6e97e2794 100644 --- a/components/agents/AgentCanvasTopNavDark.tsx +++ b/components/agents/AgentCanvasTopNavDark.tsx @@ -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 (
@@ -80,10 +79,7 @@ export function AgentCanvasTopNavDark({ {resolvedSiteName} ) : (
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index cc450b929..313310ae1 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -25,7 +25,7 @@ import { type UiSurfaceSnapshot, } from "@/lib/ui-surface-protocol"; import useSWR, { useSWRConfig } from "swr"; -import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy } from "lucide-react"; +import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -58,7 +58,10 @@ import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; -import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + fetchUploadWithRetry, + type UploadProgress, +} from "@/lib/client/upload-with-retry"; import { downloadCanvasImageAsPng, type CanvasImageDownloadProgress, @@ -458,6 +461,16 @@ type ClawdComposerSubmissionSnapshot = { submittedTexts: string[]; }; +type PendingClawdRequestRestoreSnapshot = { + chatId: string; + userMessageId: string; + textContent: string; + tableFiles: File[]; + attachments: PickedObjectAttachment[]; + skillTag: CanvasSkillTag | null; + shouldRestoreSkillTag: boolean; +}; + function isClawdSubmittedComposerText( snapshot: ClawdComposerSubmissionSnapshot, value: string @@ -717,6 +730,14 @@ const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [ "帮我把这张产品图做成高级电商海报", "基于参考图延展 3 套同风格方案", ]; +const CANVAS_IMAGE_DIMENSION_TIMEOUT_MS = 3500; + +const DEFAULT_CANVAS_IMAGE_DIMENSIONS = { + width: 300, + height: 300, + originalWidth: 0, + originalHeight: 0, +}; function normalizeComposerPreviewTail(text: string, fixedPrefix: string) { const trimmed = text.trim(); @@ -747,6 +768,24 @@ function getImageDimensions(url: string): Promise<{ }> { return new Promise((resolve) => { const img = new Image(); + let settled = false; + const resolveOnce = (dimensions: { + width: number; + height: number; + originalWidth: number; + originalHeight: number; + }) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + img.onload = null; + img.onerror = null; + resolve(dimensions); + }; + const timeout = window.setTimeout(() => { + resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); + }, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS); + img.onload = () => { // 限制最大尺寸,避免节点过大 const maxSize = 500; @@ -770,11 +809,11 @@ function getImageDimensions(url: string): Promise<{ height = Math.round(height * scale); } - resolve({ width, height, originalWidth, originalHeight }); + resolveOnce({ width, height, originalWidth, originalHeight }); }; img.onerror = () => { // 加载失败时使用默认尺寸 - resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 }); + resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); }; img.src = url; }); @@ -791,6 +830,8 @@ function buildPreviewImageUrl(url: string, width: number): string { const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析"; const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。"; const CANVAS_AGENT_SECTION_TITLES = new Set([ + "意图分析", + "任务规划", "需求理解", CANVAS_IMAGE_ANALYSIS_TITLE, "执行计划", @@ -1184,8 +1225,78 @@ type CanvasComposerUploadProgress = { current: number; total: number; fileName?: string; + filePercent?: number; + percent?: number; + phase?: UploadProgress["phase"]; + target?: "composer-reference" | "canvas" | "slot"; }; +type CanvasUploadProgressOptions = { + title: string; + fileName?: string; + fileIndex?: number; + fileTotal?: number; + target?: CanvasComposerUploadProgress["target"]; +}; + +function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) { + switch (phase) { + case "preparing": + return "正在准备上传"; + case "direct": + return "正在上传到云储存"; + case "multipart": + return "正在分片上传到云储存"; + case "server": + return "正在通过服务器上传"; + case "finalizing": + return "正在确认图片地址"; + case "complete": + return "上传完成"; + default: + return "正在上传图片"; + } +} + +function buildCanvasComposerUploadProgress( + options: CanvasUploadProgressOptions, + progress?: UploadProgress +): CanvasComposerUploadProgress { + const total = Math.max(1, Math.round(options.fileTotal || 1)); + const fileIndex = Math.min( + total - 1, + Math.max(0, Math.round(options.fileIndex || 0)) + ); + const current = fileIndex + 1; + const filePercent = Math.max( + 1, + Math.min(100, Math.round(progress?.percent || 1)) + ); + const percent = + total <= 1 + ? filePercent + : Math.max( + 1, + Math.min(100, Math.round(((fileIndex + filePercent / 100) / total) * 100)) + ); + const phaseLabel = getCanvasUploadProgressPhaseLabel(progress?.phase); + + return { + title: options.title, + message: + total > 1 + ? `${phaseLabel},第 ${current}/${total} 张,当前 ${filePercent}%` + : `${phaseLabel},当前 ${filePercent}%`, + current, + total, + fileName: options.fileName, + filePercent, + percent, + phase: progress?.phase, + target: options.target, + }; +} + const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX = /```openclaw-actions\s*([\s\S]*?)```/gi; @@ -1598,9 +1709,13 @@ function OpenClawQuickActions({ function CanvasAssistantTypingPlaceholder({ hint, label = "分析中", + onCancel, + cancelLabel = "停止", }: { hint?: string | null; label?: string; + onCancel?: () => void; + cancelLabel?: string; }) { return (
@@ -1616,6 +1731,19 @@ function CanvasAssistantTypingPlaceholder({ > {label} + {onCancel ? ( + + ) : null}
@@ -1630,16 +1758,20 @@ function CanvasComposerUploadProgressDialog({ if (!progress) return null; const percent = - progress.total > 1 + typeof progress.percent === "number" + ? Math.max(1, Math.min(100, Math.round(progress.percent))) + : progress.total > 1 ? Math.max( 8, Math.min(100, Math.round((progress.current / progress.total) * 100)) ) : 100; - const isSingleFile = progress.total <= 1; + const isIndeterminate = + typeof progress.percent !== "number" && progress.total <= 1; + const phaseLabel = getCanvasUploadProgressPhaseLabel(progress.phase); return createPortal( -
+
@@ -1661,12 +1793,19 @@ function CanvasComposerUploadProgressDialog({
+
+ + {progress.total > 1 ? `${progress.current}/${progress.total}` : phaseLabel} + + {percent}% +
+
{progress.message}
@@ -2257,6 +2396,10 @@ export function CanvasChatPanel({ useRef(null); const recentClawdSubmissionRef = useRef(null); + const clawdAbortControllerRef = useRef(null); + const pendingClawdRequestRestoreRef = + useRef(null); + const canceledClawdMessageIdsRef = useRef>(new Set()); const replaceChatSessionQuery = useCallback( (sessionId: string) => { @@ -2285,6 +2428,35 @@ export function CanvasChatPanel({ useState(null); const [composerUploadProgress, setComposerUploadProgress] = useState(null); + const composerUploadClearTimeoutRef = useRef | null>(null); + const clearComposerUploadProgress = useCallback((delayMs = 0) => { + if (composerUploadClearTimeoutRef.current) { + clearTimeout(composerUploadClearTimeoutRef.current); + composerUploadClearTimeoutRef.current = null; + } + + if (delayMs <= 0) { + setComposerUploadProgress(null); + return; + } + + composerUploadClearTimeoutRef.current = setTimeout(() => { + composerUploadClearTimeoutRef.current = null; + setComposerUploadProgress(null); + }, delayMs); + }, []); + const updateComposerUploadProgress = useCallback( + (options: CanvasUploadProgressOptions, progress?: UploadProgress) => { + if (composerUploadClearTimeoutRef.current) { + clearTimeout(composerUploadClearTimeoutRef.current); + composerUploadClearTimeoutRef.current = null; + } + setComposerUploadProgress( + buildCanvasComposerUploadProgress(options, progress) + ); + }, + [] + ); const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); const [addingClawdImageUrl, setAddingClawdImageUrl] = useState(""); const downloadAbortControllerRef = useRef(null); @@ -2298,6 +2470,14 @@ export function CanvasChatPanel({ format: "webp", }); }, [fullscreenPreview]); + useEffect(() => { + return () => { + if (composerUploadClearTimeoutRef.current) { + clearTimeout(composerUploadClearTimeoutRef.current); + composerUploadClearTimeoutRef.current = null; + } + }; + }, []); // 🆕 图片参数状态 const [imageParams, setImageParams] = useState({ @@ -4850,12 +5030,12 @@ export function CanvasChatPanel({ setAttachments([...attachments, ...toAdd]); return toAdd.length; }, [ - agentUploadSlots.length, + agentUploadSlots, attachments, hasAgentUploadSlots, insertImageTagInEditor, isVideoModel, - videoFrameSlots.length, + videoFrameSlots, ]); // 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签 @@ -7118,6 +7298,76 @@ export function CanvasChatPanel({ }, }); + const restorePendingClawdRequestToComposer = useCallback( + (snapshot: PendingClawdRequestRestoreSnapshot) => { + setMessages((previousMessages) => + previousMessages.filter((message) => message.id !== snapshot.userMessageId) + ); + setClawdTableFiles(snapshot.tableFiles); + setAttachments(snapshot.attachments); + + if (snapshot.skillTag && snapshot.shouldRestoreSkillTag) { + setSessionClawdSkillTag(snapshot.chatId, snapshot.skillTag.id, { + promptApplied: false, + }); + setSelectedSkillTagId(snapshot.skillTag.id); + pendingSelectedClawdSkillTagRef.current = snapshot.skillTag; + } + + composerUserEditedAtRef.current = Date.now(); + inputRef.current = snapshot.textContent; + setInput(snapshot.textContent); + + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (editableDiv) { + editableDiv.innerHTML = ""; + editableDiv.textContent = snapshot.textContent; + window.setTimeout(() => { + snapshot.attachments.forEach((attachment, index) => { + if (!attachment.url) return; + insertImageTagInEditor( + attachment.url, + normalizeChatAttachmentName(attachment.name), + index + ); + }); + editableDiv.focus(); + }, 0); + } + }, + [insertImageTagInEditor, setMessages, setSessionClawdSkillTag] + ); + + const handleCancelClawdSubmission = useCallback(() => { + const snapshot = pendingClawdRequestRestoreRef.current; + if (!snapshot) return; + + canceledClawdMessageIdsRef.current.add(snapshot.userMessageId); + pendingClawdRequestRestoreRef.current = null; + pendingClawdSubmissionRef.current = null; + recentClawdSubmissionRef.current = null; + writeStoredClawdComposerSubmissionSnapshot(null); + + void fetch("/api/canvas/clawd-chat/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: snapshot.chatId, + userMessageId: snapshot.userMessageId, + }), + }).catch((error) => { + console.warn("[CanvasChat] Claw cancel request failed:", error); + }); + + clawdAbortControllerRef.current?.abort(); + clawdAbortControllerRef.current = null; + setIsClawdSubmitting(false); + setClawdProcessingHint(null); + releaseSendGuard(snapshot.chatId); + restorePendingClawdRequestToComposer(snapshot); + toast.message("已停止处理,可以重新编辑后再发送"); + }, [releaseSendGuard, restorePendingClawdRequestToComposer]); + useEffect(() => { activeMessagesRef.current = messages; }, [messages]); @@ -8341,12 +8591,22 @@ export function CanvasChatPanel({ } setVideoSlotUploading(slotIndex); + const progressOptions: CanvasUploadProgressOptions = { + title: `正在上传${videoFrameSlots[slotIndex] || "参考图"}`, + fileName: file.name, + target: "slot", + }; try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); - const response = await fetchUploadWithRetry(formData); + updateComposerUploadProgress(progressOptions); + const response = await fetchUploadWithRetry(formData, { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + }); if (!response.ok) { throw new Error("上传失败"); @@ -8355,6 +8615,7 @@ export function CanvasChatPanel({ const data = await response.json(); const imageUrl = data.url as string; const assetId = normalizeUploadedAssetId(data.assetId); + clearComposerUploadProgress(250); updateAttachmentAtVideoSlot( slotIndex, @@ -8386,11 +8647,12 @@ export function CanvasChatPanel({ } catch (_error) { toast.error("图片上传失败"); } finally { + clearComposerUploadProgress(450); setVideoSlotUploading(null); event.currentTarget.value = ""; } }, - [addNode, nodes.length, updateAttachmentAtVideoSlot] + [addNode, clearComposerUploadProgress, nodes.length, updateAttachmentAtVideoSlot, updateComposerUploadProgress, videoFrameSlots] ); const handleAgentSlotFileChange = useCallback( @@ -8406,12 +8668,23 @@ export function CanvasChatPanel({ } setAgentSlotUploading(slotIndex); + const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`; + const progressOptions: CanvasUploadProgressOptions = { + title: `正在上传${slotLabel}`, + fileName: file.name, + target: "slot", + }; try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); - const response = await fetchUploadWithRetry(formData); + updateComposerUploadProgress(progressOptions); + const response = await fetchUploadWithRetry(formData, { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + }); if (!response.ok) { throw new Error("上传失败"); @@ -8420,6 +8693,7 @@ export function CanvasChatPanel({ const data = await response.json(); const imageUrl = data.url as string; const assetId = normalizeUploadedAssetId(data.assetId); + clearComposerUploadProgress(250); updateAttachmentAtAgentSlot( slotIndex, @@ -8448,16 +8722,16 @@ export function CanvasChatPanel({ connections: [], }); - const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`; toast.success(`${slotLabel}上传成功`); } catch (_error) { toast.error("图片上传失败"); } finally { + clearComposerUploadProgress(450); setAgentSlotUploading(null); event.currentTarget.value = ""; } }, - [addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot] + [addNode, agentUploadSlots, clearComposerUploadProgress, nodes.length, updateAttachmentAtAgentSlot, updateComposerUploadProgress] ); const updateDetailSlotAttachment = useCallback( @@ -8557,12 +8831,22 @@ export function CanvasChatPanel({ } setDetailSlotUploading(slotIndex); + const progressOptions: CanvasUploadProgressOptions = { + title: `正在上传${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}`, + fileName: file.name, + target: "slot", + }; try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); - const response = await fetchUploadWithRetry(formData); + updateComposerUploadProgress(progressOptions); + const response = await fetchUploadWithRetry(formData, { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + }); if (!response.ok) { throw new Error("上传失败"); @@ -8574,6 +8858,7 @@ export function CanvasChatPanel({ if (!imageUrl) { throw new Error("未获取到图片地址"); } + clearComposerUploadProgress(250); updateDetailSlotAttachment( slotIndex, @@ -8583,11 +8868,12 @@ export function CanvasChatPanel({ } catch (_error) { toast.error("图片上传失败"); } finally { + clearComposerUploadProgress(450); setDetailSlotUploading(null); event.currentTarget.value = ""; } }, - [updateDetailSlotAttachment] + [clearComposerUploadProgress, updateComposerUploadProgress, updateDetailSlotAttachment] ); const updateLocalDetailArchive = useCallback( @@ -10729,6 +11015,19 @@ export function CanvasChatPanel({ pendingClawdSubmissionRef.current = clawdSubmissionSnapshot; recentClawdSubmissionRef.current = clawdSubmissionSnapshot; writeStoredClawdComposerSubmissionSnapshot(clawdSubmissionSnapshot); + const clawdAbortController = new AbortController(); + const clawdRequestRestoreSnapshot: PendingClawdRequestRestoreSnapshot = { + chatId: activeChatId, + userMessageId, + textContent, + tableFiles: tableFilesSnapshot, + attachments: attachmentsSnapshot, + skillTag: clawdSkillTagForRequest || null, + shouldRestoreSkillTag: hasPendingClawdSkillTagPrompt, + }; + canceledClawdMessageIdsRef.current.delete(userMessageId); + clawdAbortControllerRef.current = clawdAbortController; + pendingClawdRequestRestoreRef.current = clawdRequestRestoreSnapshot; setClawdProcessingHint(nextProcessingHint); setIsClawdSubmitting(true); setMessages((previousMessages) => @@ -10771,8 +11070,13 @@ export function CanvasChatPanel({ const response = await fetch("/api/canvas/clawd-chat", { method: "POST", body: formData, + signal: clawdAbortController.signal, }); const responseData = await response.json().catch(() => null); + if (response.status === 499 || responseData?.canceled === true) { + canceledClawdMessageIdsRef.current.add(userMessageId); + throw new DOMException("Claw request canceled", "AbortError"); + } if (!response.ok) { console.error("[CanvasChat] Claw API returned error:", { status: response.status, @@ -10822,6 +11126,17 @@ export function CanvasChatPanel({ void mutateClawdHistory(); hasHydratedHistoryRef.current = true; } catch (error) { + const wasCanceled = + canceledClawdMessageIdsRef.current.has(userMessageId) || + (error instanceof Error && error.name === "AbortError"); + if (wasCanceled) { + if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { + pendingClawdRequestRestoreRef.current = null; + restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot); + toast.message("已停止处理,可以重新编辑后再发送"); + } + return; + } console.error("[CanvasChat] Claw request failed:", error); pendingClawdSubmissionRef.current = null; if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) { @@ -10863,13 +11178,25 @@ export function CanvasChatPanel({ } } finally { pendingClawdSubmissionRef.current = null; + if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) { + pendingClawdRequestRestoreRef.current = null; + } + if (clawdAbortControllerRef.current === clawdAbortController) { + clawdAbortControllerRef.current = null; + } + const wasCanceled = canceledClawdMessageIdsRef.current.delete(userMessageId); if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) { + if (wasCanceled) { + recentClawdSubmissionRef.current = null; + writeStoredClawdComposerSubmissionSnapshot(null); + } else { const refreshedSubmissionSnapshot = { ...clawdSubmissionSnapshot, expiresAt: Date.now() + CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS, }; recentClawdSubmissionRef.current = refreshedSubmissionSnapshot; writeStoredClawdComposerSubmissionSnapshot(refreshedSubmissionSnapshot); + } } setIsClawdSubmitting(false); setClawdProcessingHint(null); @@ -11242,6 +11569,7 @@ export function CanvasChatPanel({ markSessionClawdSkillTagPromptApplied, updateChatSessions, buildHistoryMessagesUrl, + restorePendingClawdRequestToComposer, workflowContext, messages, ]); @@ -11311,7 +11639,8 @@ export function CanvasChatPanel({ const uploadImageFileAndAddToCanvas = useCallback(async ( file: File, - nodeIndex: number + nodeIndex: number, + progressOptions?: CanvasUploadProgressOptions ): Promise<{ imageUrl: string; name: string; @@ -11338,7 +11667,16 @@ export function CanvasChatPanel({ formData.append('file', file); formData.append('purpose', 'canvas-upload'); - const response = await fetchUploadWithRetry(formData); + if (progressOptions) { + updateComposerUploadProgress(progressOptions); + } + const response = await fetchUploadWithRetry(formData, progressOptions + ? { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + } + : undefined); if (!response.ok) { throw new Error('上传失败'); @@ -11348,6 +11686,7 @@ export function CanvasChatPanel({ const imageUrl = data.url as string; const assetId = typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; + clearComposerUploadProgress(250); const dimensions = await getImageDimensions(imageUrl); const spacing = 20; const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); @@ -11383,10 +11722,11 @@ export function CanvasChatPanel({ toast.error('图片上传失败'); return null; } - }, [addNode]); + }, [addNode, clearComposerUploadProgress, updateComposerUploadProgress]); const uploadImageFileForComposerAttachment = useCallback(async ( - file: File + file: File, + progressOptions?: CanvasUploadProgressOptions ): Promise<{ imageUrl: string; name: string; @@ -11414,7 +11754,16 @@ export function CanvasChatPanel({ formData.append("file", file); formData.append("purpose", "canvas-upload"); - const response = await fetchUploadWithRetry(formData); + if (progressOptions) { + updateComposerUploadProgress(progressOptions); + } + const response = await fetchUploadWithRetry(formData, progressOptions + ? { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + } + : undefined); if (!response.ok) { throw new Error("上传失败"); } @@ -11425,6 +11774,7 @@ export function CanvasChatPanel({ typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; + clearComposerUploadProgress(250); const dimensions = await getImageDimensions(imageUrl); return { @@ -11439,7 +11789,7 @@ export function CanvasChatPanel({ toast.error("图片上传失败"); return null; } - }, []); + }, [clearComposerUploadProgress, updateComposerUploadProgress]); const handlePastedImages = useCallback(async (files: File[]) => { if (files.length === 0) return; @@ -11452,18 +11802,21 @@ export function CanvasChatPanel({ let handledCount = 0; try { for (const [index, file] of files.entries()) { - setComposerUploadProgress({ + const progressOptions: CanvasUploadProgressOptions = { title: files.length === 1 ? "正在上传图片" : `正在上传 ${files.length} 张图片`, - message: `正在上传第 ${index + 1}/${files.length} 张,图片较大时可能需要一点时间。`, - current: index + 1, - total: files.length, fileName: file.name, - }); + fileIndex: index, + fileTotal: files.length, + target: "canvas", + }; - const uploaded = await uploadImageFileForComposerAttachment(file); + const uploaded = await uploadImageFileForComposerAttachment( + file, + progressOptions + ); if (!uploaded) continue; handledCount += 1; @@ -11488,7 +11841,7 @@ export function CanvasChatPanel({ }); } } finally { - setComposerUploadProgress(null); + clearComposerUploadProgress(450); } if (handledCount > 0) { @@ -11506,11 +11859,24 @@ export function CanvasChatPanel({ const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; - for (const file of files) { + try { + for (const [index, file] of files.entries()) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; - const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + const slotLabel = + agentUploadSlots[targetSlotIndex]?.label || `第${targetSlotIndex + 1}张`; + const uploaded = await uploadImageFileAndAddToCanvas( + file, + baseNodeCount + uploadIndex, + { + title: `正在上传${slotLabel}`, + fileName: file.name, + fileIndex: index, + fileTotal: files.length, + target: "slot", + } + ); if (!uploaded) continue; const attachment: PickedObjectAttachment = { @@ -11529,6 +11895,9 @@ export function CanvasChatPanel({ firstImageUrl = uploaded.imageUrl; } } + } finally { + clearComposerUploadProgress(450); + } if (handledCount > 0) { toast.success( @@ -11549,11 +11918,22 @@ export function CanvasChatPanel({ const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; - for (const file of files) { + try { + for (const [index, file] of files.entries()) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; - const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + const uploaded = await uploadImageFileAndAddToCanvas( + file, + baseNodeCount + uploadIndex, + { + title: `正在上传${videoFrameSlots[targetSlotIndex] || "参考图"}`, + fileName: file.name, + fileIndex: index, + fileTotal: files.length, + target: "slot", + } + ); if (!uploaded) continue; const attachment: PickedObjectAttachment = { @@ -11571,6 +11951,9 @@ export function CanvasChatPanel({ firstImageUrl = uploaded.imageUrl; } } + } finally { + clearComposerUploadProgress(450); + } if (handledCount > 0) { toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`); @@ -11583,8 +11966,22 @@ export function CanvasChatPanel({ } let handledCount = 0; - for (const file of files) { - const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + try { + for (const [index, file] of files.entries()) { + const uploaded = await uploadImageFileAndAddToCanvas( + file, + baseNodeCount + uploadIndex, + { + title: + files.length === 1 + ? "正在上传参考图" + : `正在上传 ${files.length} 张参考图`, + fileName: file.name, + fileIndex: index, + fileTotal: files.length, + target: "composer-reference", + } + ); if (!uploaded) continue; handledCount += 1; @@ -11613,6 +12010,9 @@ export function CanvasChatPanel({ ]; }); } + } finally { + clearComposerUploadProgress(450); + } if (handledCount > 0) { toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`); @@ -11620,8 +12020,9 @@ export function CanvasChatPanel({ void maybeAutoSetCanvasTitle(firstImageUrl); }, [ - agentUploadSlots.length, + agentUploadSlots, attachments, + clearComposerUploadProgress, hasAgentUploadSlots, insertImageTagInEditor, isClawdMode, @@ -11632,7 +12033,7 @@ export function CanvasChatPanel({ updateAttachmentAtVideoSlot, uploadImageFileForComposerAttachment, uploadImageFileAndAddToCanvas, - videoFrameSlots.length, + videoFrameSlots, ]); const handleComposerPaste = useCallback((event: ReactClipboardEvent) => { @@ -11714,18 +12115,21 @@ export function CanvasChatPanel({ if (imageFiles.length > 0) { try { for (const [index, file] of imageFiles.entries()) { - setComposerUploadProgress({ + const progressOptions: CanvasUploadProgressOptions = { title: imageFiles.length === 1 ? "正在上传图片" : `正在上传 ${imageFiles.length} 张图片`, - message: `正在上传第 ${index + 1}/${imageFiles.length} 张,图片较大时可能需要一点时间。`, - current: index + 1, - total: imageFiles.length, fileName: file.name, - }); + fileIndex: index, + fileTotal: imageFiles.length, + target: "canvas", + }; - const uploaded = await uploadImageFileForComposerAttachment(file); + const uploaded = await uploadImageFileForComposerAttachment( + file, + progressOptions + ); if (!uploaded) continue; handledImageCount += 1; @@ -11750,7 +12154,7 @@ export function CanvasChatPanel({ }); } } finally { - setComposerUploadProgress(null); + clearComposerUploadProgress(450); } } @@ -11775,11 +12179,24 @@ export function CanvasChatPanel({ const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; - for (const file of selectedFiles) { + try { + for (const [index, file] of selectedFiles.entries()) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; - const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + const slotLabel = + agentUploadSlots[targetSlotIndex]?.label || `第${targetSlotIndex + 1}张`; + const uploaded = await uploadImageFileAndAddToCanvas( + file, + baseNodeCount + uploadIndex, + { + title: `正在上传${slotLabel}`, + fileName: file.name, + fileIndex: index, + fileTotal: selectedFiles.length, + target: "slot", + } + ); if (!uploaded) continue; updateAttachmentAtAgentSlot(targetSlotIndex, { @@ -11805,6 +12222,9 @@ export function CanvasChatPanel({ firstImageUrl = uploaded.imageUrl; } } + } finally { + clearComposerUploadProgress(450); + } if (handledCount > 0) { toast.success( @@ -11821,8 +12241,9 @@ export function CanvasChatPanel({ e.target.value = ''; return; } - - for (const file of selectedFiles) { + + try { + for (const [index, file] of selectedFiles.entries()) { if (!file.type.startsWith('image/')) { toast.error('请选择图片文件'); continue; @@ -11838,8 +12259,23 @@ export function CanvasChatPanel({ const formData = new FormData(); formData.append('file', file); formData.append('purpose', 'canvas-upload'); - - const response = await fetchUploadWithRetry(formData); + + const progressOptions: CanvasUploadProgressOptions = { + title: + selectedFiles.length === 1 + ? "正在上传参考图" + : `正在上传 ${selectedFiles.length} 张参考图`, + fileName: file.name, + fileIndex: index, + fileTotal: selectedFiles.length, + target: "composer-reference", + }; + updateComposerUploadProgress(progressOptions); + const response = await fetchUploadWithRetry(formData, { + onProgress: (progress) => { + updateComposerUploadProgress(progressOptions, progress); + }, + }); if (!response.ok) { throw new Error('上传失败'); @@ -11849,6 +12285,7 @@ export function CanvasChatPanel({ const imageUrl = data.url; const assetId = typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined; + clearComposerUploadProgress(250); const dimensions = await getImageDimensions(imageUrl); // 记录第一张图片的 URL(用于自动命名和缩略图) @@ -11906,6 +12343,9 @@ export function CanvasChatPanel({ toast.error('图片上传失败'); } } + } finally { + clearComposerUploadProgress(450); + } console.log('[CanvasChat] 检查是否需要自动命名:', { firstImageUrl: !!firstImageUrl, @@ -12046,6 +12486,10 @@ export function CanvasChatPanel({ const isComposerUploading = Boolean(composerUploadProgress); const isSubmitting = isActiveChatPending || isDetailGenerating || isClawdSubmitting || isComposerUploading; + const composerReferenceUploadProgress = + composerUploadProgress?.target === "composer-reference" + ? composerUploadProgress + : null; const hasInputOrAttachments = Boolean( input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag) ); @@ -12090,6 +12534,7 @@ export function CanvasChatPanel({ isClawdMode && activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || "" : ""; + const isClawdStopAvailable = isClawdMode && isClawdSubmitting; const handleClawdPresetPlaceholderDoubleClick = useCallback( (event: ReactMouseEvent) => { if (isSubmitting || !isClawdMode || !clawdEditablePresetPlaceholder) return; @@ -13876,7 +14321,12 @@ export function CanvasChatPanel({ ) : null} {isClawdMode && isClawdSubmitting ? ( - + ) : null}
@@ -15122,7 +15572,7 @@ export function CanvasChatPanel({ {!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? ( ) : null} {isClawdMode && clawdTableFiles.length > 0 ? ( @@ -15754,7 +16234,9 @@ export function CanvasChatPanel({ isWorkspacePresentation ? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0" : "h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0", - isVideoToolbarDark + isClawdStopAvailable + ? "bg-rose-500 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400" + : isVideoToolbarDark ? canSend ? "border border-[#e7ff1a]/20 bg-[#e7ff1a] text-zinc-950 shadow-sm hover:bg-[#dff40f]" : "border border-white/10 bg-[#242529] text-zinc-500 hover:bg-[#2c2d33]" @@ -15766,15 +16248,28 @@ export function CanvasChatPanel({ ? "bg-[#eef0f3] text-[#20242a] shadow-sm ring-1 ring-inset ring-[#d5dae2] hover:bg-[#e4e7ec] dark:bg-white dark:text-zinc-950 dark:ring-white/15 dark:hover:bg-zinc-100" : "bg-[#f1f1f2] text-[#b6bac2] hover:bg-[#ececee] dark:bg-[#242529] dark:text-[#777d88] dark:hover:bg-[#2c2d33]" )} - onClick={() => void handleSend()} - disabled={isSubmitting || !canSend} + onClick={() => { + if (isClawdStopAvailable) { + handleCancelClawdSubmission(); + return; + } + void handleSend(); + }} + disabled={isClawdStopAvailable ? false : isSubmitting || !canSend} title={ - isWorkspacePresentation || isDetailGenerationMode + isClawdStopAvailable + ? "停止处理并重新编辑" + : isWorkspacePresentation || isDetailGenerationMode ? getToolbarItem("chatStartGeneration").label : getToolbarItem("chatSend").label } > - {isSubmitting ? ( + {isClawdStopAvailable ? ( + <> + + {isWorkspacePresentation ? 停止 : null} + + ) : isSubmitting ? ( <> {isWorkspacePresentation ? 生成中 : null} diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 227473f3e..6e48ad0e1 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -6695,17 +6695,13 @@ function StylizedBrandLogo({ }) { const resolvedSiteName = resolveSiteName(siteName); const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || ""; - const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim()); return ( {displayLogo ? ( {resolvedSiteName} ) : ( diff --git a/components/canvas/canvas-logo-loading.tsx b/components/canvas/canvas-logo-loading.tsx index 531fd401f..73f273231 100644 --- a/components/canvas/canvas-logo-loading.tsx +++ b/components/canvas/canvas-logo-loading.tsx @@ -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} /> diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 109e435b1..f1340ec0b 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -10193,7 +10193,7 @@ export function ImageNode({ createPortal(
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} diff --git a/components/elements/response.tsx b/components/elements/response.tsx index 5ce92c208..82deb547e 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -47,6 +47,8 @@ type ResponseProps = ComponentProps & { 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 + ) => ( ); + 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 ( +
+ {images.map((image, index) => ( +
+ {renderImagePreview( + image.src, + image.alt, + `${image.src}-${processingKey}-${keyPrefix}-grid-${index}`, + true + )} +
+ ))} +
+ ); + }; + 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( ); }); + flushImages(segments.length); + return nodes; } } diff --git a/components/favicon-updater.tsx b/components/favicon-updater.tsx index 9c41c507b..6c9c5a0d4 100644 --- a/components/favicon-updater.tsx +++ b/components/favicon-updater.tsx @@ -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); }; }, []); diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index 6751449a0..fe1a95412 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -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({ {siteName} ) : (
diff --git a/components/home/lovart/lovart-hero-composer.tsx b/components/home/lovart/lovart-hero-composer.tsx index 9f5e73568..62ae0c66c 100644 --- a/components/home/lovart/lovart-hero-composer.tsx +++ b/components/home/lovart/lovart-hero-composer.tsx @@ -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({ {siteName} ) : (
diff --git a/components/home/lovart/lovart-sidebar.tsx b/components/home/lovart/lovart-sidebar.tsx index e00a52458..cc9fdc394 100644 --- a/components/home/lovart/lovart-sidebar.tsx +++ b/components/home/lovart/lovart-sidebar.tsx @@ -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({ {siteName} ) : ( diff --git a/components/image-preview.tsx b/components/image-preview.tsx index 6908df6fd..1750c9332 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -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 ( <> {/* 缩略图(显示压缩后的缩略图)*/} -
+
Generated Image 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 ? ( {resolvedSiteName} ) : ( diff --git a/components/message.tsx b/components/message.tsx index 4ff575742..6aa4d0c1b 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -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; diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 145daf751..2f996561d 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -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 | 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(null); const videoSlotInputRefs = useRef>([]); - const [uploadQueue, setUploadQueue] = useState([]); + const [uploadQueue, setUploadQueue] = useState([]); const [isDragging, setIsDragging] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); + const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState>({}); + + const updateUploadQueueItem = useCallback( + ( + id: string | undefined, + patch: Partial> + ) => { + 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((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 => { + const uploadFile = useCallback(async ( + file: File, + uploadQueueItemId?: string + ): Promise => { 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 && ( +
+
+ + {uploadProgressSummary.count > 1 + ? `正在上传 ${uploadProgressSummary.count} 张图片` + : uploadProgressSummary.statusText} + + + {uploadProgressSummary.percent}% + +
+
+
+
+
+ {uploadProgressSummary.fileName} +
+
+ )} + {videoFrameSlots.length > 0 && (
{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 ( ); @@ -1284,15 +1474,17 @@ function PureMultimodalInput({ /> ))} - {uploadQueue.map((filename) => ( + {uploadQueue.map((item) => ( ))}
diff --git a/components/pascal-editor/pascal-editor-frame.tsx b/components/pascal-editor/pascal-editor-frame.tsx index d2479a71e..cf9f93f47 100644 --- a/components/pascal-editor/pascal-editor-frame.tsx +++ b/components/pascal-editor/pascal-editor-frame.tsx @@ -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 ? ( {resolvedSiteName} ) : ( diff --git a/components/preview-attachment.tsx b/components/preview-attachment.tsx index 482748ef6..6debbc9ec 100644 --- a/components/preview-attachment.tsx +++ b/components/preview-attachment.tsx @@ -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") ? ( {name + + {normalizedUploadProgress === null + ? uploadStatusLabel + : `${normalizedUploadProgress}%`} + +
+
+
)} diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index 9e9ede5a1..0b944d4ab 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -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 && ( {siteName} )} diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts index bf3b9fd64..bc722c0b7 100644 --- a/lib/ai/auto-agent-graph.ts +++ b/lib/ai/auto-agent-graph.ts @@ -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, diff --git a/lib/ai/runtime/agent-planning-brief.ts b/lib/ai/runtime/agent-planning-brief.ts new file mode 100644 index 000000000..943a6db17 --- /dev/null +++ b/lib/ai/runtime/agent-planning-brief.ts @@ -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 = { + 一: 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); +} diff --git a/lib/ai/runtime/direct-agent-execution.ts b/lib/ai/runtime/direct-agent-execution.ts index 79a6f6642..517da2047 100644 --- a/lib/ai/runtime/direct-agent-execution.ts +++ b/lib/ai/runtime/direct-agent-execution.ts @@ -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, diff --git a/lib/ai/runtime/direct-agent-media-executors.ts b/lib/ai/runtime/direct-agent-media-executors.ts index c99d93168..c261a5430 100644 --- a/lib/ai/runtime/direct-agent-media-executors.ts +++ b/lib/ai/runtime/direct-agent-media-executors.ts @@ -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, }); diff --git a/lib/ai/runtime/direct-agent-response-builders.ts b/lib/ai/runtime/direct-agent-response-builders.ts index 0576f56d1..6a867f3e7 100644 --- a/lib/ai/runtime/direct-agent-response-builders.ts +++ b/lib/ai/runtime/direct-agent-response-builders.ts @@ -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), ); } diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index f7fa5656f..e66bc01ef 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -132,6 +132,7 @@ export interface CanvasGenerateImageRequest { ownerKey?: string; debugLabel?: string; debugMeta?: Record; + 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 = diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index 80ded40f2..4690aad1c 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -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 | 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((_, 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( payload: Record, - signal?: AbortSignal + signal?: AbortSignal, + timeoutMs = DIRECT_UPLOAD_ACTION_TIMEOUT_MS ): Promise { - 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( 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((resolve, reject) => { + if (options?.signal?.aborted) { + reject(createAbortError()); + return; + } + + const xhr = new XMLHttpRequest(); + let settled = false; + let stallTimer: ReturnType | 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; maxRetries: number; signal?: AbortSignal; + onProgress?: UploadProgressHandler; }) { const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []; + const partLoadedBytes = new Map(); 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 { 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( { 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>( { 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>( { @@ -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; }); } diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index e9d56ef35..db3bc49bc 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -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 @@ -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) { diff --git a/lib/openclaw-chat/cancel-registry.ts b/lib/openclaw-chat/cancel-registry.ts new file mode 100644 index 000000000..8896d37be --- /dev/null +++ b/lib/openclaw-chat/cancel-registry.ts @@ -0,0 +1,76 @@ +const CANCELED_REQUEST_TTL_MS = 10 * 60 * 1000; + +type ActiveClawdRequest = { + controller: AbortController; + expiresAt: number; +}; + +const activeRequests = new Map(); +const canceledRequests = new Map(); + +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); +} diff --git a/lib/storage/service.ts b/lib/storage/service.ts index b10224424..8365a54e7 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -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( diff --git a/scripts/storage/configure-direct-upload-cors.ts b/scripts/storage/configure-direct-upload-cors.ts index 4d842bdcd..2d88eb173 100644 --- a/scripts/storage/configure-direct-upload-cors.ts +++ b/scripts/storage/configure-direct-upload-cors.ts @@ -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() diff --git a/scripts/storage/configure-multipart-lifecycle.ts b/scripts/storage/configure-multipart-lifecycle.ts index ba8bcb971..7959d1b7e 100644 --- a/scripts/storage/configure-multipart-lifecycle.ts +++ b/scripts/storage/configure-multipart-lifecycle.ts @@ -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() diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index ee23944a0..c4b94d227 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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",