Update canvas generation UI and downloads

This commit is contained in:
134 2026-06-24 17:55:59 +08:00
parent 41522cc481
commit 05387d08f3
8 changed files with 988 additions and 246 deletions

View File

@ -63,6 +63,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import {
type ChangeEvent,
type ComponentType,
type PointerEvent as ReactPointerEvent,
startTransition,
useCallback,
useDeferredValue,
@ -1132,6 +1133,9 @@ function normalizeImportedCanvasState(payload: unknown): {
}
const FLOATING_PANEL_WIDTH = 400;
const FLOATING_PANEL_MAX_WIDTH = 760;
const FLOATING_PANEL_MIN_CANVAS_WIDTH = 520;
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
@ -1145,6 +1149,33 @@ const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
detail_replacement: "细节替换",
sku_replacement: "SKU 替换",
};
function getMaxFloatingPanelWidth(viewportWidth: number) {
if (viewportWidth <= 0) {
return FLOATING_PANEL_MAX_WIDTH;
}
const viewportLimitedMax = Math.max(
FLOATING_PANEL_WIDTH,
viewportWidth - FLOATING_PANEL_MIN_CANVAS_WIDTH - DOCKED_PANEL_INSET
);
return Math.max(
FLOATING_PANEL_WIDTH,
Math.min(FLOATING_PANEL_MAX_WIDTH, viewportLimitedMax)
);
}
function clampFloatingPanelWidth(width: number, viewportWidth: number) {
if (!Number.isFinite(width)) {
return FLOATING_PANEL_WIDTH;
}
return Math.min(
Math.max(width, FLOATING_PANEL_WIDTH),
getMaxFloatingPanelWidth(viewportWidth)
);
}
const VIDEO_CANVAS_PANEL_CLASS =
"pointer-events-auto overflow-hidden rounded-[24px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.42),0_24px_52px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950";
const VIDEO_CANVAS_PANEL_BADGE_CLASS =
@ -1932,6 +1963,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [floatingPanelWidth, setFloatingPanelWidth] =
useState(FLOATING_PANEL_WIDTH);
const [
isFloatingPanelWidthStorageReady,
setIsFloatingPanelWidthStorageReady,
] = useState(false);
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
useState(false);
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
useState(false);
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
@ -2956,10 +2995,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const resolvedPanelWidth =
viewportWidth > 0
? Math.min(
FLOATING_PANEL_WIDTH,
floatingPanelWidth,
Math.max(viewportWidth - DOCKED_PANEL_INSET, 0)
)
: FLOATING_PANEL_WIDTH;
: floatingPanelWidth;
const isFloatingSidebarVisible = !isVideoMode && isPanelOpen;
const panelOcclusionRight = isFloatingSidebarVisible
? resolvedPanelWidth + DOCKED_PANEL_INSET
@ -2969,6 +3008,82 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
: 0;
const compactControlsLeft = FLOATING_UI_GAP;
const handleFloatingPanelResizePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const handleElement = event.currentTarget;
const pointerId = event.pointerId;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const updateWidthFromClientX = (clientX: number) => {
const currentViewportWidth = window.innerWidth;
const nextWidth =
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
setFloatingPanelWidth(
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
);
};
const cleanup = () => {
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerEnd);
document.removeEventListener("pointercancel", handlePointerEnd);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
setIsFloatingPanelResizing(false);
try {
if (handleElement.hasPointerCapture(pointerId)) {
handleElement.releasePointerCapture(pointerId);
}
} catch {
// Pointer capture can already be released by the browser.
}
};
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
moveEvent.preventDefault();
updateWidthFromClientX(moveEvent.clientX);
};
const handlePointerEnd = (endEvent: PointerEvent) => {
if (endEvent.pointerId !== pointerId) {
return;
}
cleanup();
};
try {
handleElement.setPointerCapture(pointerId);
} catch {
// Some browsers may reject capture on synthetic pointer events.
}
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setIsFloatingPanelResizing(true);
updateWidthFromClientX(event.clientX);
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerEnd);
document.addEventListener("pointercancel", handlePointerEnd);
},
[]
);
const updateCanvasSearchParams = useCallback(
(updates: Record<string, string | null>) => {
const nextParams = new URLSearchParams(searchParams.toString());
@ -3343,6 +3458,49 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
useEffect(() => {
if (typeof window === "undefined") {
setIsFloatingPanelWidthStorageReady(true);
return;
}
const storedWidth = Number(
window.localStorage.getItem(FLOATING_PANEL_WIDTH_STORAGE_KEY)
);
if (Number.isFinite(storedWidth)) {
setFloatingPanelWidth(
clampFloatingPanelWidth(storedWidth, window.innerWidth)
);
}
setIsFloatingPanelWidthStorageReady(true);
}, []);
useEffect(() => {
if (viewportWidth <= 0) {
return;
}
setFloatingPanelWidth((currentWidth) =>
clampFloatingPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
useEffect(() => {
if (
!isFloatingPanelWidthStorageReady ||
typeof window === "undefined"
) {
return;
}
window.localStorage.setItem(
FLOATING_PANEL_WIDTH_STORAGE_KEY,
String(Math.round(floatingPanelWidth))
);
}, [floatingPanelWidth, isFloatingPanelWidthStorageReady]);
useEffect(() => {
if (!videoCanvasInsertMenu) return;
@ -10434,7 +10592,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
{isVideoMode ? null : (
<div
className={cn(
"absolute z-10 transition-all duration-300 ease-in-out",
"absolute z-10 ease-in-out",
isFloatingPanelResizing
? "transition-none"
: "transition-all duration-300",
isPanelOpen
? "translate-x-0 opacity-100"
: "pointer-events-none translate-x-[120%] opacity-0"
@ -10444,10 +10605,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
width: resolvedPanelWidth,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}
>
{isPanelOpen ? (
<div
aria-label="拖动调整对话框宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(
getMaxFloatingPanelWidth(viewportWidth)
)}
aria-valuemin={FLOATING_PANEL_WIDTH}
aria-valuenow={Math.round(resolvedPanelWidth)}
className="group absolute inset-y-0 left-[-6px] z-30 w-3 cursor-col-resize touch-none"
onPointerDown={handleFloatingPanelResizePointerDown}
role="separator"
tabIndex={0}
>
<div
className={cn(
"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors",
"group-hover:bg-sky-400/70 dark:group-hover:bg-sky-300/70",
isFloatingPanelResizing &&
"bg-sky-500 dark:bg-sky-300"
)}
/>
</div>
) : null}
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
{!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." />

View File

@ -59,6 +59,10 @@ 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 {
downloadCanvasImageAsPng,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
import { canvasService } from "@/services/canvas";
import {
inferOriginalImageUrl,
@ -125,6 +129,7 @@ import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-ic
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { Response } from "@/components/elements/response";
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
@ -250,7 +255,6 @@ import {
} from "@/components/canvas/chat-panel/canvas-chat-session";
import {
buildCanvasHistoryMessagesSignature,
buildDownloadFilename,
extractAssistantImageUrls,
extractCanvasModelName,
extractImageUrls,
@ -270,7 +274,6 @@ import {
sortCanvasMessagesByCreatedAt,
stripCanvasModelRoutingLines,
stripMediaMarkup,
triggerBrowserDownload,
} from "@/components/canvas/chat-panel/canvas-chat-media";
// 标签悬停预览信息
@ -1653,6 +1656,10 @@ export function CanvasChatPanel({
url: string;
alt?: string;
} | null>(null);
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
@ -2129,41 +2136,62 @@ export function CanvasChatPanel({
);
const { mutate } = useSWRConfig();
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
if (downloadAbortControllerRef.current) {
toast.info("原图正在下载中");
return;
}
const originalUrl = inferOriginalImageUrl(raw) || raw;
const filename = buildDownloadFilename(originalUrl);
try {
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
triggerBrowserDownload(blobUrl, filename);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
return;
} catch (error) {
console.error("下载原图失败:", error);
try {
triggerBrowserDownload(originalUrl, filename);
toast.message("代理下载失败,已切换为原图直链下载");
return;
} catch (fallbackError) {
console.error("原图直链下载失败:", fallbackError);
}
const controller = new AbortController();
downloadAbortControllerRef.current = controller;
setDownloadingOriginalUrl(raw);
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
triggerBrowserDownload(originalUrl, undefined, { openInNewTab: true });
toast.message("已打开原图,可右键另存为");
try {
await downloadCanvasImageAsPng({
url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图下载成功");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
} else {
console.error("下载原图失败:", error);
toast.error(
error instanceof Error
? `${error.message},请稍后重试或重新生成图片`
: "图片源文件暂时不可访问,请稍后重试或重新生成图片"
);
}
} finally {
if (downloadAbortControllerRef.current === controller) {
downloadAbortControllerRef.current = null;
}
setDownloadingOriginalUrl("");
setDownloadProgress(null);
}
}, []);
const handleCancelOriginalImageDownload = useCallback(() => {
downloadAbortControllerRef.current?.abort();
downloadAbortControllerRef.current = null;
setDownloadingOriginalUrl("");
setDownloadProgress(null);
}, []);
// 从 API 加载历史消息
const { data: historyData, mutate: mutateHistoryData } = useSWR<{
@ -3424,6 +3452,7 @@ export function CanvasChatPanel({
const [composerModeOverride, setComposerModeOverride] =
useState<CanvasComposerMode | null>(null);
const activeComposerMode = composerModeOverride || computedComposerMode;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
const agentSelectableModels = useMemo(
() =>
@ -3794,18 +3823,18 @@ export function CanvasChatPanel({
(mode: CanvasComposerMode) =>
cn(
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal outline-none transition-colors focus:text-[#1f2937] dark:focus:text-white [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground focus:bg-background/80"
isVideoToolbarDark
? "text-zinc-100 focus:bg-white/12 focus:text-white"
: "text-[#2e3238] focus:bg-[#eef0f3] dark:text-zinc-100 dark:focus:bg-white/12",
activeComposerMode === mode
? isVideoWorkspace
? "bg-background text-foreground"
? isVideoToolbarDark
? "bg-white/12 text-white"
: "bg-[#eef0f3] text-[#1f2937] dark:bg-white/12 dark:text-white"
: isVideoWorkspace
? "hover:bg-background/80"
: isVideoToolbarDark
? "hover:bg-white/8"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
),
[activeComposerMode, isVideoWorkspace]
[activeComposerMode, isVideoToolbarDark]
);
const shouldMaskAgentRuntimeModelName =
activeComposerMode === "agent" || selectedModelId === "auto";
@ -11436,7 +11465,8 @@ export function CanvasChatPanel({
/>
<button
type="button"
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80"
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@ -11444,8 +11474,12 @@ export function CanvasChatPanel({
}}
title="下载原图"
>
<Download className="h-3.5 w-3.5" />
原图
{downloadingOriginalUrl === url ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{downloadingOriginalUrl === url ? "下载中" : "原图"}
</button>
</div>
))}
@ -11583,7 +11617,8 @@ export function CanvasChatPanel({
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20 transition-colors"
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(fullscreenPreview.url);
@ -11591,8 +11626,12 @@ export function CanvasChatPanel({
aria-label="下载原图"
title="下载原图"
>
<Download className="h-4 w-4" />
下载原图
{isDownloadingOriginalImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
</button>
<img
@ -11611,6 +11650,11 @@ export function CanvasChatPanel({
document.body
)}
<CanvasImageDownloadProgressDialog
onCancel={handleCancelOriginalImageDownload}
progress={downloadProgress}
/>
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
<div className="px-[10px] pb-0">
<div
@ -13070,8 +13114,8 @@ export function CanvasChatPanel({
size="icon"
className={cn(
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoWorkspace
? "bg-background"
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33] hover:text-white"
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
@ -13100,8 +13144,8 @@ export function CanvasChatPanel({
variant="ghost"
className={cn(
"relative h-9 max-w-[122px] shrink-0 cursor-pointer rounded-full px-2.5 text-[15px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isVideoWorkspace
? "bg-muted/80 text-foreground hover:bg-muted dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
: "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14"
)}
>
@ -13148,8 +13192,8 @@ export function CanvasChatPanel({
sideOffset={10}
className={cn(
"w-[200px] space-y-1 rounded-[18px] border border-[#d8dce3] bg-white p-2 text-[#20242a] shadow-[0_22px_54px_rgba(15,23,42,0.16)]",
isVideoWorkspace
? "border border-border/60 bg-background/95 shadow-[0_14px_30px_-20px_rgba(15,23,42,0.35)] backdrop-blur-xl"
isVideoToolbarDark
? "border-white/10 bg-zinc-900/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)] backdrop-blur-xl"
: "dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100"
)}
>
@ -13167,7 +13211,7 @@ export function CanvasChatPanel({
Agent
</span>
{activeComposerMode === "agent" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
@ -13179,7 +13223,7 @@ export function CanvasChatPanel({
<MessageCircle className="h-5 w-5" />
<span className="flex-1">{CLAW_MODE_LABEL}</span>
{activeComposerMode === "clawd" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
@ -13197,7 +13241,7 @@ export function CanvasChatPanel({
{getToolbarItem("chatModeDetailMenu").label}
</span>
{activeComposerMode === "detail" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
@ -13214,7 +13258,7 @@ export function CanvasChatPanel({
图像
</span>
{activeComposerMode === "image" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
@ -13230,7 +13274,7 @@ export function CanvasChatPanel({
视频
</span>
{activeComposerMode === "video" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
<Check className={cn("h-4 w-4", isVideoToolbarDark ? "text-white" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
@ -13239,12 +13283,13 @@ export function CanvasChatPanel({
{isVideoModel ? (
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<div className="w-max pr-1 pl-1">
<div className="w-max px-1">
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
inlineTheme={isVideoToolbarDark ? "dark" : "default"}
providerType={currentModel?.providerType}
capabilities={currentModel?.videoToolCapability}
/>
@ -13296,16 +13341,18 @@ export function CanvasChatPanel({
<SelectTrigger
className={cn(
"h-8 w-8 shrink-0 justify-center rounded-full p-0 outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&>svg]:hidden",
isVideoWorkspace
? "border border-[#cfd8e6] bg-white/85 text-zinc-700 hover:bg-white dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
isVideoToolbarDark
? "border border-white/12 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
: "border border-[#cfd4dc] bg-white text-[#20242a] dark:border-white/12 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
)}
title={getToolbarItem("chatModelSelector").label}
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white text-zinc-700 leading-none ring-1 ring-black/5 dark:bg-white dark:text-zinc-900 dark:ring-white/20",
isVideoWorkspace ? "border border-border/60" : "border border-transparent"
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none ring-1",
isVideoToolbarDark
? "border border-white/12 bg-[#15161a] text-zinc-100 ring-white/12"
: "border border-transparent bg-white text-zinc-700 ring-black/5 dark:bg-white dark:text-zinc-900 dark:ring-white/20"
)}
>
{selectDisplayModel?.avatarUrl ? (
@ -13323,7 +13370,10 @@ export function CanvasChatPanel({
/>
) : getToolbarItem("chatModelSelector").iconUrl ? (
<CanvasToolbarIcon
className="h-4 w-4 text-zinc-700 dark:text-zinc-900"
className={cn(
"h-4 w-4",
isVideoToolbarDark ? "text-zinc-100" : "text-zinc-700 dark:text-zinc-900"
)}
fallback={Bot}
item={getToolbarItem("chatModelSelector")}
/>
@ -13331,7 +13381,7 @@ export function CanvasChatPanel({
<span
className={cn(
"flex h-full w-full items-center justify-center text-[10px] font-medium leading-none",
isVideoWorkspace ? "text-zinc-700" : "text-[#4b5563] dark:text-zinc-900"
isVideoToolbarDark ? "text-zinc-100" : "text-[#4b5563] dark:text-zinc-900"
)}
>
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
@ -13347,8 +13397,8 @@ export function CanvasChatPanel({
<div
className={cn(
"ml-0 flex h-7 max-w-[60px] shrink items-center gap-1 rounded-full px-2 text-[10px] sm:ml-1 sm:max-w-none sm:gap-1 sm:px-2 sm:text-[11px]",
isVideoWorkspace
? "border border-border/60 bg-background text-muted-foreground"
isVideoToolbarDark
? "border border-white/12 bg-[#242529] text-zinc-300"
: "bg-muted text-muted-foreground"
)}
title={estimatedPointsCost.title}
@ -13358,7 +13408,7 @@ export function CanvasChatPanel({
fallback={Sparkles}
item={getToolbarItem("chatPoints")}
/>
<span className="font-medium text-foreground">
<span className={cn("font-medium", isVideoToolbarDark ? "text-zinc-100" : "text-foreground")}>
{estimatedPointsCost.cost}
</span>
<span className="hidden sm:inline">{getToolbarItem("chatPoints").label}</span>
@ -13371,10 +13421,10 @@ 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",
isVideoWorkspace
isVideoToolbarDark
? canSend
? "border border-primary/20 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90"
: "border border-border/60 bg-background text-muted-foreground hover:bg-background/90"
? "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]"
: isDetailGenerationMode
? canSend
? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500"
@ -13402,13 +13452,13 @@ export function CanvasChatPanel({
className={cn(
"h-3.5 w-3.5",
canSend &&
(isVideoWorkspace || isDetailGenerationMode
(isVideoToolbarDark || isDetailGenerationMode
? "opacity-95"
: "opacity-95")
)}
fallback={Sparkles}
item={getToolbarItem("chatStartGeneration")}
active={canSend && (isVideoWorkspace || isDetailGenerationMode)}
active={canSend && (isVideoToolbarDark || isDetailGenerationMode)}
/>
{isWorkspacePresentation ? (
<span className="ml-2">
@ -13422,7 +13472,7 @@ export function CanvasChatPanel({
className={cn(
"h-3.5 w-3.5",
canSend &&
(isVideoWorkspace || isDetailGenerationMode
(isVideoToolbarDark || isDetailGenerationMode
? "opacity-95"
: "opacity-95")
)}
@ -13432,7 +13482,7 @@ export function CanvasChatPanel({
? "chatStartGeneration"
: "chatSend"
)}
active={canSend && (isVideoWorkspace || isDetailGenerationMode)}
active={canSend && (isVideoToolbarDark || isDetailGenerationMode)}
/>
{isWorkspacePresentation ? (
<span className="ml-2">

View File

@ -0,0 +1,93 @@
"use client";
import { createPortal } from "react-dom";
import { Download, Loader2, X } from "lucide-react";
import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
import { cn } from "@/lib/utils";
type CanvasImageDownloadProgressDialogProps = {
progress: CanvasImageDownloadProgress | null;
onCancel?: () => void;
};
function formatBytes(bytes?: number) {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return "";
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
}
export function CanvasImageDownloadProgressDialog({
onCancel,
progress,
}: CanvasImageDownloadProgressDialogProps) {
if (!progress || typeof document === "undefined") return null;
const percent =
typeof progress.percent === "number" && Number.isFinite(progress.percent)
? Math.max(0, Math.min(100, Math.round(progress.percent)))
: null;
const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes);
return createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/42 px-4 backdrop-blur-[2px]"
data-canvas-download-progress="true"
>
<div className="w-full max-w-[380px] rounded-[18px] border border-white/12 bg-[#111317] p-5 text-white shadow-[0_24px_80px_rgba(0,0,0,0.42)]">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
{progress.phase === "saving" ? (
<Download className="h-5 w-5" />
) : (
<Loader2 className="h-5 w-5 animate-spin" />
)}
</span>
<div>
<div className="text-[15px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
</div>
</div>
{onCancel ? (
<button
className="flex h-8 w-8 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
onClick={onCancel}
title="取消下载"
type="button"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className="mt-5">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span>
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
</div>
</div>
<style jsx global>{`
@keyframes download-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
`}</style>
</div>,
document.body
);
}

View File

@ -50,6 +50,7 @@ import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import {
areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle,
@ -89,8 +90,6 @@ import {
normalizeMediaUrl,
resolveRegeneratePromptSeed,
resolveOriginalImageUrl,
sanitizeFileName,
triggerBrowserDownload,
} from "@/components/canvas/nodes/image-node/image-node-utils";
import {
createLocalEditPromptDraftMap,
@ -164,6 +163,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import {
@ -488,21 +491,6 @@ const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72;
const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46;
const ANGLE_PANEL_SOURCE_WIDTH = 520;
const ANGLE_PANEL_SOURCE_HEIGHT = 700;
const parseAttachmentFilename = (contentDisposition: string | null) => {
if (!contentDisposition) return "";
const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1]);
} catch (_error) {
return encodedMatch[1];
}
}
const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i);
if (quotedMatch?.[1]) return quotedMatch[1];
const plainMatch = contentDisposition.match(/filename=([^;]+)/i);
return plainMatch?.[1]?.trim() || "";
};
const ANGLE_PANEL_PREVIEW_SIZE = 392;
const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384;
const ANGLE_PANEL_WIREFRAME_RADIUS = 132;
@ -662,6 +650,9 @@ const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80;
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
);
@ -1669,6 +1660,9 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
@ -2021,6 +2015,7 @@ export function ImageNode({
Record<string, LocalEditPromptCompositionSnapshot | undefined>
>({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -2039,6 +2034,128 @@ export function ImageNode({
(state) => state.clearImageNodeActionRequest
);
const allNodes = useCanvasStore((state) => state.nodes);
useEffect(() => {
if (data.status !== "generating" || data.src) return;
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? (data.metadata as Record<string, unknown>)
: {};
const taskId = String(metadata.taskId || "").trim();
if (!taskId || hydratedImageTaskRecoveryRef.current === taskId) return;
const createdAtTime = new Date(node.createdAt).getTime();
if (
Number.isFinite(createdAtTime) &&
Date.now() - createdAtTime < HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS
) {
return;
}
let cancelled = false;
hydratedImageTaskRecoveryRef.current = taskId;
const updateCurrentGeneratingTaskNode = (
nextData: (currentData: ImageNodeData) => ImageNodeData
) => {
const currentNode = useCanvasStore
.getState()
.nodes.find((item) => item.id === node.id);
if (!currentNode || currentNode.type !== "image") return;
const currentData = currentNode.data as ImageNodeData;
const currentMetadata =
currentData.metadata &&
typeof currentData.metadata === "object" &&
!Array.isArray(currentData.metadata)
? (currentData.metadata as Record<string, unknown>)
: {};
if (
currentData.status !== "generating" ||
currentData.src ||
String(currentMetadata.taskId || "").trim() !== taskId
) {
return;
}
updateNode(node.id, {
data: nextData(currentData),
});
};
const markFailed = (message: string) => {
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: "",
status: "failed",
prompt: message,
title: currentData.title?.includes("失败")
? currentData.title
: `${currentData.title || "图片生成"}-失败`,
metadata: {
...(currentData.metadata || {}),
error: message,
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "failed",
},
}));
};
void (async () => {
let lastError = "";
for (let attempt = 0; attempt < HYDRATED_IMAGE_TASK_POLL_LIMIT; attempt += 1) {
if (cancelled) return;
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
if (!taskResult.success || !task) {
lastError = taskResult.error || "查询生图任务失败";
} else {
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
if (!imageUrl) {
markFailed("任务已完成但未返回图片");
return;
}
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: imageUrl,
status: "completed",
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
model: task.result?.model || task.result?.modelRef || currentData.model,
metadata: {
...(currentData.metadata || {}),
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "succeeded",
},
}));
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
markFailed(task.error || "图片生成任务失败");
return;
}
}
await sleep(HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS);
}
if (!cancelled) {
markFailed(lastError || "图片生成任务等待超时,请重新发起生成");
}
})();
return () => {
cancelled = true;
};
}, [data.metadata, data.src, data.status, node.createdAt, node.id, updateNode]);
const selectedTripoModelEntry = useMemo(
() =>
tripoModelOptions.find((entry) => entry.id === selectedTripoModelVersion) ||
@ -2276,26 +2393,53 @@ export function ImageNode({
() => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))),
[compareButtonVisualSize]
);
const displayImageWidth = useMemo(
() =>
Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))),
[node.size.width]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
typeof window !== "undefined"
? clampNumber(window.devicePixelRatio || 1, 1, 3)
: 2;
const zoomMultiplier = clampNumber(
viewportZoom || 1,
0.75,
shouldUseHighFidelityDisplayImage ? 2.4 : 1.6
);
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return Math.max(
minWidth,
Math.min(
maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
)
);
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo(
() =>
buildImageProxyUrl(data.src, {
width: displayImageWidth,
quality: 86,
quality: displayImageQuality,
format: "webp",
}),
[data.src, displayImageWidth]
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 2400,
quality: 90,
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
@ -2303,10 +2447,10 @@ export function ImageNode({
() =>
buildImageProxyUrl(compareSource, {
width: displayImageWidth,
quality: 86,
quality: displayImageQuality,
format: "webp",
}),
[compareSource, displayImageWidth]
[compareSource, displayImageQuality, displayImageWidth]
);
const shouldRequestDisplayImage =
hasEnteredViewport ||
@ -8055,84 +8199,61 @@ export function ImageNode({
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
if (!data.src) return;
const originalUrl = String(resolveOriginalImageUrl(data.src) || data.src || "").trim();
const downloadCandidates = Array.from(
new Set(
[
originalUrl,
data.src,
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
}),
]
.map((value) => String(value || "").trim())
.filter(Boolean)
)
);
if (!originalUrl) {
toast.error("下载失败,请重试");
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
return;
}
const controller = new AbortController();
downloadAbortControllerRef.current = controller;
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
toast.info("正在生成 PS 兼容 PNG...");
const response = await fetch("/api/canvas/download-image", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: downloadCandidates[0],
urls: downloadCandidates,
name: displayTitle,
}),
await downloadCanvasImageAsPng({
url: data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(payload?.error || `下载失败(${response.status})`);
}
const blob = await response.blob();
if (!blob.type.toLowerCase().includes("image/png")) {
throw new Error("下载结果不是标准 PNG");
}
const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${sanitizeFileName(displayTitle)}.png`;
// 创建 Blob URL
const blobUrl = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放 Blob URL
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
toast.success("下载成功");
toast.success("原图下载成功");
} catch (error) {
console.error("下载失败:", error);
toast.error(
error instanceof Error
? `${error.message},源图可能已失效,请稍后重试或重新生成图片`
: "图片源文件暂时不可访问,请稍后重试或重新生成图片"
);
if (controller.signal.aborted) {
toast.message("已取消下载");
} else {
console.error("下载失败:", error);
toast.error(
error instanceof Error
? `${error.message},源图可能已失效,请稍后重试或重新生成图片`
: "图片源文件暂时不可访问,请稍后重试或重新生成图片"
);
}
} finally {
if (downloadAbortControllerRef.current === controller) {
downloadAbortControllerRef.current = null;
}
setDownloadProgress(null);
}
};
const handleCancelDownload = () => {
downloadAbortControllerRef.current?.abort();
setDownloadProgress(null);
};
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
if (boxCutoutMetrics) {
return {
@ -9463,12 +9584,21 @@ export function ImageNode({
<DropdownMenuSeparator />
) : null}
{imageDownloadEnabled ? (
<DropdownMenuItem onClick={handleDownload}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
<DropdownMenuItem
disabled={isDownloadingOriginalImage}
onClick={() => {
void handleDownload();
}}
>
{isDownloadingOriginalImage ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
)}
{imageDownloadItem.label}
</DropdownMenuItem>
) : null}
@ -10571,17 +10701,23 @@ export function ImageNode({
{imageDownloadEnabled ? (
<Button
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={handleDownload}
disabled={isProcessing || isDownloadingOriginalImage}
onClick={() => {
void handleDownload();
}}
size="icon"
title={imageDownloadItem.label}
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
{isDownloadingOriginalImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
)}
</Button>
) : null}
</div>
@ -11604,6 +11740,24 @@ export function ImageNode({
/>
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownload();
}}
title="下载原图"
type="button"
>
{isDownloadingOriginalImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载原图
</button>
{/* 图片 */}
<img
@ -11623,6 +11777,10 @@ export function ImageNode({
</div>,
document.body
)}
<CanvasImageDownloadProgressDialog
onCancel={handleCancelDownload}
progress={downloadProgress}
/>
</>
);
}

View File

@ -87,6 +87,7 @@ interface VideoParamsBarProps {
initialParams?: Partial<VideoGenerationParams>;
onParamsChange?: (params: VideoGenerationParams) => void;
inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器
inlineTheme?: "default" | "dark";
providerType?: string;
capabilities?: VideoToolCapabilityContract | null;
}
@ -297,6 +298,7 @@ export function VideoParamsBar({
initialParams,
onParamsChange,
inline = false,
inlineTheme = "default",
providerType = "volcano-video",
capabilities,
}: VideoParamsBarProps) {
@ -440,45 +442,79 @@ export function VideoParamsBar({
if (inline) {
const modeLabel = modeOptions.find((o) => o.value === params.mode)?.label || MODE_LABELS[params.mode];
const durationLabel = `${params.duration}s`;
const isDarkInline = inlineTheme === "dark";
const panelClass = isDarkInline
? "border-white/10 bg-[#15161a]/95 text-zinc-100 shadow-[0_18px_44px_rgba(0,0,0,0.38)]"
: "border-[#d8dde4] bg-[#f3f4f6]/95 text-[#2f3b48] shadow-[0_10px_24px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-100 dark:shadow-[0_18px_44px_rgba(0,0,0,0.38)]";
const groupPanelClass = isDarkInline
? "bg-white/8"
: "bg-[#eceff2]/90 dark:bg-white/8";
const labelClass = isDarkInline
? "text-zinc-500"
: "text-[#8a94a3] dark:text-zinc-500";
const dividerClass = isDarkInline
? "bg-white/12"
: "bg-[#cfd5de] dark:bg-white/12";
const primaryTriggerClass = isDarkInline
? "border-white/10 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
: "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#2f3b48] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]";
const iconClass = isDarkInline
? "text-zinc-100"
: "text-[#20262f] dark:text-zinc-100";
const activeOptionClass = isDarkInline
? "border-white/12 bg-white/10 text-white shadow-none"
: "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)] dark:border-white/12 dark:bg-white/10 dark:text-white dark:shadow-none";
const inactiveOptionClass = isDarkInline
? "border-transparent text-zinc-400 hover:bg-white/8 hover:text-zinc-100"
: "border-transparent text-[#5f6b7a] hover:bg-white/70 dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100";
return (
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
<div
className={cn(
"ml-1 flex w-max shrink-0 items-center gap-1 border-l pl-1",
isDarkInline ? "border-white/10" : "border-border/50"
)}
>
{/* 主参数面板:模式 | 比例 | 时长 */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 max-w-[200px] shrink rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2 text-[11px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
className={cn(
"h-8 shrink-0 justify-start rounded-[12px] border px-2 text-[11px] font-semibold",
primaryTriggerClass
)}
title={`${modeLabel},${params.ratio},${durationLabel}`}
>
<Clapperboard className="mr-1 h-3.5 w-3.5 text-[#20262f]" />
<span className="truncate">{modeLabel}</span>
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
<span className="truncate">{params.ratio}</span>
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
<span className="truncate">{durationLabel}</span>
<Clapperboard className={cn("mr-1 h-3.5 w-3.5 shrink-0", iconClass)} />
<span className="whitespace-nowrap">{modeLabel}</span>
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
<span className="shrink-0">{params.ratio}</span>
<span className={cn("mx-1.5 h-3 w-px shrink-0", dividerClass)} />
<span className="shrink-0">{durationLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className="w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
className={cn(
"w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
panelClass
)}
>
<div className="space-y-2.5">
{/* 模式 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择模式</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<p className={cn("text-[11px] font-medium", labelClass)}>选择模式</p>
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
{modeOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.mode === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
params.mode === option.value ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("mode", option.value)}
>
@ -490,17 +526,15 @@ export function VideoParamsBar({
{/* 比例 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择比例</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<p className={cn("text-[11px] font-medium", labelClass)}>选择比例</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
{ratioOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.ratio === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
params.ratio === option.value ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("ratio", option.value as VideoGenerationParams["ratio"])}
>
@ -513,8 +547,8 @@ export function VideoParamsBar({
{/* 分辨率(非 fal) */}
{showResolutionControl && (
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
<p className={cn("text-[11px] font-medium", labelClass)}>选择分辨率</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1", groupPanelClass)}>
{resolutionOptions.map((option) => (
<button
key={option.value}
@ -522,8 +556,8 @@ export function VideoParamsBar({
className={cn(
"h-8 rounded-[9px] text-[12px] font-semibold transition",
params.resolution === option.value
? "bg-white text-[#111827] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("resolution", option.value)}
>
@ -536,9 +570,10 @@ export function VideoParamsBar({
{/* 时长 */}
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择时长</p>
<p className={cn("text-[11px] font-medium", labelClass)}>选择时长</p>
<div className={cn(
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
"mt-1.5 grid gap-1.5 rounded-[12px] p-1.5",
groupPanelClass,
durationOptions.length > 6 ? "grid-cols-5" : "grid-cols-2"
)}>
{durationOptions.map((duration) => (
@ -547,9 +582,7 @@ export function VideoParamsBar({
type="button"
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
params.duration === duration
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
params.duration === duration ? activeOptionClass : inactiveOptionClass
)}
onClick={() => updateParam("duration", duration)}
>
@ -567,7 +600,12 @@ export function VideoParamsBar({
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90"
className={cn(
"h-8 w-8 shrink-0 rounded-[12px] border p-0",
isDarkInline
? "border-white/10 bg-[#242529] text-zinc-300 hover:bg-[#2c2d33] hover:text-zinc-100"
: "border-[#d6dbe2] bg-[#f3f4f6]/90 text-[#5f6b7a] hover:bg-[#edeff2]/90 dark:border-white/10 dark:bg-[#242529] dark:text-zinc-300 dark:hover:bg-[#2c2d33] dark:hover:text-zinc-100"
)}
title="更多参数"
>
<SlidersHorizontal size={14} />
@ -577,20 +615,23 @@ export function VideoParamsBar({
align="end"
side="top"
sideOffset={10}
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
className={cn(
"w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border p-2",
panelClass
)}
>
<div className="space-y-2.5">
{showGenerateAudioControl ? (
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">音频</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<p className={cn("text-[11px] font-medium", labelClass)}>音频</p>
<div className={cn("mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
<button
type="button"
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
(params.generateAudio ?? true)
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("generateAudio", true)}
>
@ -602,8 +643,8 @@ export function VideoParamsBar({
className={cn(
"flex h-8 items-center justify-center gap-1.5 rounded-[9px] border text-[12px] font-semibold transition",
params.generateAudio === false
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("generateAudio", false)}
>
@ -613,15 +654,22 @@ export function VideoParamsBar({
</div>
</div>
) : !showFpsControl && !showCameraMoveControl ? (
<div className="rounded-[12px] bg-[#eceff2]/90 p-2 text-[11px] text-[#7b8796]">
<div
className={cn(
"rounded-[12px] p-2 text-[11px]",
isDarkInline
? "bg-white/8 text-zinc-400"
: "bg-[#eceff2]/90 text-[#7b8796] dark:bg-white/8 dark:text-zinc-400"
)}
>
当前模型支持图生视频(1 张起始图),已自动按官方参数约束处理。
</div>
) : (
<>
{showFpsControl && (
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">帧率</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<p className={cn("text-[11px] font-medium", labelClass)}>帧率</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
{FPS_OPTIONS.map((option) => (
<button
key={option.value}
@ -629,8 +677,8 @@ export function VideoParamsBar({
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
(params.fps || 24) === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => updateParam("fps", option.value as VideoGenerationParams["fps"])}
>
@ -643,8 +691,8 @@ export function VideoParamsBar({
{showCameraMoveControl && (
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">运镜模式</p>
<div className="mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5">
<p className={cn("text-[11px] font-medium", labelClass)}>运镜模式</p>
<div className={cn("mt-1.5 grid grid-cols-3 gap-1.5 rounded-[12px] p-1.5", groupPanelClass)}>
{CAMERA_MOVE_OPTIONS.map((option) => (
<button
key={option.value}
@ -652,8 +700,8 @@ export function VideoParamsBar({
className={cn(
"flex h-8 items-center justify-center rounded-[9px] border text-[11px] font-semibold transition",
params.cameraMove === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
? activeOptionClass
: inactiveOptionClass
)}
onClick={() => {
updateParam("cameraMove", option.value);
@ -670,10 +718,15 @@ export function VideoParamsBar({
)}
{/* 高级选项 */}
<div className="space-y-2.5 border-t border-[#dce1e8] pt-2.5">
<div
className={cn(
"space-y-2.5 border-t pt-2.5",
isDarkInline ? "border-white/10" : "border-[#dce1e8] dark:border-white/10"
)}
>
{showReturnLastFrameControl && (
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-[#8a94a3]">返回尾帧</span>
<span className={cn("text-[11px] font-medium", labelClass)}>返回尾帧</span>
<Switch
checked={params.returnLastFrame || false}
onCheckedChange={(checked) => updateParam("returnLastFrame", checked)}
@ -682,7 +735,7 @@ export function VideoParamsBar({
)}
{showGenerateAudioControl && (
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-[#8a94a3]">生成音频</span>
<span className={cn("text-[11px] font-medium", labelClass)}>生成音频</span>
<Switch
checked={params.generateAudio ?? true}
onCheckedChange={(checked) => updateParam("generateAudio", checked)}
@ -691,13 +744,18 @@ export function VideoParamsBar({
)}
{showSeedControl && (
<div className="space-y-1">
<span className="text-[11px] font-medium text-[#8a94a3]">随机种子</span>
<span className={cn("text-[11px] font-medium", labelClass)}>随机种子</span>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ""}
onChange={(e) => updateParam("seed", Number(e.target.value) || undefined)}
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
className={cn(
"h-7 rounded-[8px] text-[12px]",
isDarkInline
? "border-white/10 bg-white/8 text-zinc-100 placeholder:text-zinc-500"
: "border-[#dce1e8] bg-[#eef1f4]/90 text-[#2f3b48] placeholder:text-[#b0b8c4] dark:border-white/10 dark:bg-white/8 dark:text-zinc-100 dark:placeholder:text-zinc-500"
)}
/>
</div>
)}

View File

@ -380,7 +380,9 @@ async function runSingleTask(entry: PendingTaskEntry) {
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasImageTask.id, entry.taskId));
.where(
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
);
} catch (error) {
await db
.update(canvasImageTask)
@ -390,7 +392,9 @@ async function runSingleTask(entry: PendingTaskEntry) {
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasImageTask.id, entry.taskId));
.where(
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
);
} finally {
const modelKey = releaseRunningTask(entry.taskId);
if (modelKey) {
@ -597,29 +601,35 @@ export async function getCanvasImageTaskForUser(params: {
if (task.status === "running" && task.startedAt) {
const staleBefore = new Date(Date.now() - STALE_RUNNING_MS);
if (task.startedAt < staleBefore) {
await db
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
const completedAt = new Date();
const [timedOutTask] = await db
.update(canvasImageTask)
.set({
status: "pending",
startedAt: null,
completedAt: null,
updatedAt: new Date(),
status: "failed",
error: timeoutMessage,
completedAt,
updatedAt: completedAt,
})
.where(
and(
eq(canvasImageTask.id, task.id),
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore)
)
)
);
await enqueueLocalTask(task.id, {
modelKey: normalizeTaskModelKey(task.request),
userId: params.userId,
});
await drainCanvasImageTaskQueue();
task.status = "pending" as CanvasImageTaskStatus;
task.startedAt = null;
task.completedAt = null;
.returning({
status: canvasImageTask.status,
error: canvasImageTask.error,
completedAt: canvasImageTask.completedAt,
});
if (timedOutTask) {
releaseRunningTask(task.id);
task.status = timedOutTask.status as CanvasImageTaskStatus;
task.error = timedOutTask.error;
task.completedAt = timedOutTask.completedAt;
}
}
}

View File

@ -0,0 +1,188 @@
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase;
message: string;
loadedBytes?: number;
totalBytes?: number;
percent?: number;
};
export type CanvasImageDownloadResult = {
filename: string;
bytes: number;
};
type DownloadCanvasImageParams = {
url: string;
name?: string;
candidates?: string[];
signal?: AbortSignal;
onProgress?: (progress: CanvasImageDownloadProgress) => void;
};
function parseAttachmentFilename(contentDisposition: string | null) {
if (!contentDisposition) return "";
const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1]);
} catch {
return encodedMatch[1];
}
}
const quotedMatch = contentDisposition.match(/filename="([^"]+)"/i);
if (quotedMatch?.[1]) return quotedMatch[1];
const plainMatch = contentDisposition.match(/filename=([^;]+)/i);
return plainMatch?.[1]?.trim() || "";
}
function sanitizeDownloadName(value: string, fallback: string) {
const cleaned = String(value || "")
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim()
.slice(0, 120);
return cleaned || fallback;
}
function inferDownloadNameFromUrl(url: string) {
try {
const pathname = new URL(url).pathname || "";
return pathname.split("/").pop() || "";
} catch {
return String(url || "").split("/").pop()?.split("?")[0] || "";
}
}
function triggerBlobDownload(blob: Blob, filename: string) {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
function toDownloadCandidates(url: string, candidates: string[] = []) {
const raw = String(url || "").trim();
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
return Array.from(
new Set(
[
originalUrl,
raw,
...candidates,
originalUrl ? buildImageProxyUrl(originalUrl, { delivery: "proxy" }) : "",
]
.map((value) => String(value || "").trim())
.filter(Boolean)
)
);
}
async function readResponseBlobWithProgress(
response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void
) {
const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) {
const blob = await response.blob();
onProgress?.({
phase: "downloading",
message: "正在下载原图...",
loadedBytes: blob.size,
totalBytes: totalBytes || blob.size,
percent: 100,
});
return blob;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let loadedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
chunks.push(value);
loadedBytes += value.byteLength;
onProgress?.({
phase: "downloading",
message: "正在下载原图...",
loadedBytes,
totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
});
}
return new Blob(chunks, {
type: response.headers.get("content-type") || "image/png",
});
}
export async function downloadCanvasImageAsPng({
candidates,
name,
onProgress,
signal,
url,
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim();
if (!raw) {
throw new Error("图片地址无效");
}
const urls = toDownloadCandidates(raw, candidates);
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
const filenameBase = sanitizeDownloadName(
name || inferDownloadNameFromUrl(originalUrl || raw),
"canvas-image"
);
onProgress?.({
phase: "preparing",
message: "正在准备原图...",
percent: 8,
});
const response = await fetch("/api/canvas/download-image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: urls[0],
urls,
name: filenameBase,
}),
signal,
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || `下载失败(${response.status})`);
}
const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`;
const blob = await readResponseBlobWithProgress(response, onProgress);
onProgress?.({
phase: "saving",
message: "正在保存文件...",
loadedBytes: blob.size,
totalBytes: blob.size,
percent: 100,
});
triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size };
}

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.