+ const renderImageSurface = (image: PanoramaReferenceImage, surfaceClass: string) => {
+ const loaded = loadedImages[image.id];
+ const failed = failedImages[image.id];
+ return (
+
+

markImageLoaded(image.id)}
+ onError={() => markImageFailed(image.id)}
+ className={`h-full w-full object-cover transition-opacity duration-300 ${loaded && !failed ? "opacity-100" : "opacity-0"}`}
+ />
+ {!failed && !loaded &&
}
+ {failed && (
+
+ 加载失败
+
+ )}
+
+ );
+ };
+
+ const thumbSizeClass = sourceMode === "image" ? "w-[140px]" : "w-[100px]";
+
+ const selectedThumb = (image: PanoramaReferenceImage, index: number) => (
+
+ {renderImageSurface(image, "aspect-[4/3] rounded-[var(--r-md)]")}
+
+
+ {sourceMode === "image" ? "源图" : `${index + 1}`}
+
-
);
- const modeButtons = (
-
-
-
-
- );
-
- const smartControl = (
-
-
-
- {showSmartHelp && (
-
- AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
-
- )}
-
- );
-
- const body = (
-
-
-
-
-
-
- {sourceMode === "ai" ? (
-
- AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
-
- ) : (
-
- 图生全景图只使用第一张源图作为全景底图。图片接近 {projection === "spherical" ? "2:1" : "4:1"} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
-
- )}
- {sourceMode !== "image" && (
-
- 查看最终提示词
- {composed}
-
- )}
-
- {sourceMode !== "image" ? (
-
使用当前配置生成全景图。
- ) : (
-
使用现成图片作为全景源,比例处理在本地完成。
- )}
-
-
-
-
-
-
-
-
+ const addTile = (
+
);
return (
<>
- {body}
- {isAssetPickerOpen && (
-
-
-
-
-
{sourceMode === "image" ? "选择要转换的全景源图" : "选择全景参考图"}
-
{sourceMode === "image" ? "图生全景图只使用一张现成图片作为全景源,不会提交给 AI" : "AI生成全景图支持多选,可作为风格 / 主体 / 元素参考"}
+
+
+
+
+
+ {/* 生成方式 */}
+
+
生成方式
+
+
+
-
+
+ {/* 参考图 / 源图 */}
+
+
+
{sourceMode === "image" ? "全景源图" : "参考图"}
+
} onClick={openAssetPicker} className="!h-6 !px-2 !text-[10px]">
+ {sourceMode === "image" && selectedReferences.length > 0 ? "替换" : "添加"}
+
+
+
+ {selectedReferences.map((image, index) => selectedThumb(image, index))}
+ {sourceMode !== "image" && addTile}
+ {sourceMode === "image" && selectedReferences.length === 0 && addTile}
+
+
+
+ {/* 场景描述 */}
+ {sourceMode === "ai" && (
+
+
场景描述
+
setPrompt(event.target.value)}
+ placeholder="例如:黄昏的海边木屋,能看到天空、地面和四周环境"
+ autoSize={{ minRows: 2, maxRows: 4 }}
+ />
+
+ )}
+
+ {/* 全景类型 */}
+
+
全景类型
+
+ {(
+ [
+ { value: "spherical" as const, icon: Globe, title: "球形全景", desc: "720° 沉浸式", ratio: "2:1" },
+ { value: "cylindrical" as const, icon: Compass, title: "环绕全景", desc: "360° 街景", ratio: "4:1" },
+ ] as const
+ ).map((option) => {
+ const selected = projection === option.value;
+ const Icon = option.icon;
+ return (
+
+ );
+ })}
+
+
+
+ {/* 高级选项 */}
+
+
高级选项
+
+
+ {sourceMode === "ai" && (
+
} onClick={() => onCopyPrompt?.(composed)} className="!h-6 !px-2 !text-[10px]">
+ 复制完整提示词
+
+ )}
+
+
+
+
+
+
+
+
+ {/* 选图面板 */}
+ {isAssetPickerOpen && (
+
+
+
+
+
{sourceMode === "image" ? "选择源图" : "选择参考图"}
+
+ {sourceMode === "image" ? "只选一张作为全景源" : `已选 ${draftSelectedIds.length} 张`}
+
+
+
-
-
+
+ setAssetQuery(event.target.value)} placeholder="搜索图片名称" prefix={} />
-
+
{filteredReferences.length === 0 ? (
-
没有匹配的图片资产
+
没有匹配的图片
) : (
-
+
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
+ const failed = failedImages[image.id];
+ const loaded = loadedImages[image.id];
return (
);
})}
)}
-
-
@@ -475,11 +503,12 @@ PanoramaSetupForm.displayName = "PanoramaSetupForm";
export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) {
return (
-
-
-
+
+
+
void }) {
- const theme = canvasThemes[useThemeStore((state) => state.theme)];
- const reducedMotion = useReducedMotion();
- const simple = mode === "simple";
- const rootRef = useRef(null);
- const [open, setOpen] = useState(false);
-
- useEffect(() => {
- if (!open) return;
- const closeOnOutsidePress = (event: PointerEvent) => {
- if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
- };
- const closeOnEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") setOpen(false);
- };
- document.addEventListener("pointerdown", closeOnOutsidePress);
- document.addEventListener("keydown", closeOnEscape);
- return () => {
- document.removeEventListener("pointerdown", closeOnOutsidePress);
- document.removeEventListener("keydown", closeOnEscape);
- };
- }, [open]);
-
- const selectMode = (nextMode: CanvasWorkspaceMode) => {
- if (nextMode !== mode) onChange(nextMode);
- setOpen(false);
- };
-
- return (
-
-
setOpen((value) => !value)}
- >
-
- {simple ? : }
-
- {simple ? "简洁模式" : "专业模式"}
-
-
-
-
-
-
-
- {open ? (
-
- } title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
- } title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
-
- ) : null}
-
-
-
- );
-}
-
-type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
-
// 顶栏是 --z-toolbar 上的绝对定位浮层,会为子元素建立层叠上下文。行内绝对定位的提示
// 无论 z-index 多高都无法越过它,会被顶栏下方 --z-panel-floating 的生成任务面板盖住,
// 因此提示必须走 portal 的浮层层级。
@@ -369,25 +291,6 @@ function CanvasTopBarTooltip({ label, children }: { label: string; children: Rea
);
}
-function ModeOption({ active, motionEnabled, icon, title, description, theme, onClick }: { active: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; theme: CanvasTheme; onClick: () => void }) {
- return (
-
- {icon}
- {title}{description}
-
-
- );
-}
-
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
return (
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 792a933f5..04e0db89a 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -82,7 +82,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
-import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
+import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
@@ -90,7 +90,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
-import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
+import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
@@ -295,7 +295,7 @@ function InfiniteCanvasPage() {
const [canvasTool, setCanvasTool] = useState("box-select");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode);
const [projectLoaded, setProjectLoaded] = useState(false);
- const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode);
+ const workspaceMode: CanvasWorkspaceMode = "professional";
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
@@ -352,10 +352,6 @@ function InfiniteCanvasPage() {
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
- useEffect(() => {
- persistCanvasWorkspaceMode(workspaceMode);
- }, [workspaceMode]);
-
useEffect(() => {
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
}, [mediaPerformanceMode]);
@@ -1115,6 +1111,9 @@ function InfiniteCanvasPage() {
setArtCritiqueNodeId(node.id);
} else if (node.type === CanvasNodeType.MediaConversion) {
setDialogNodeId(null);
+ } else if (node.type === CanvasNodeType.Panorama) {
+ // 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
+ setDialogNodeId(null);
} else {
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
@@ -1132,7 +1131,7 @@ function InfiniteCanvasPage() {
const handleNodeDragEnd = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
- if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
+ if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === CanvasNodeType.Panorama || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
setDialogNodeId(null);
return;
}
@@ -2323,19 +2322,6 @@ function InfiniteCanvasPage() {
/>
) : null}
- {!focusMode ? (
- event.stopPropagation()}
- onPointerDown={(event) => event.stopPropagation()}
- onWheel={(event) => event.stopPropagation()}
- >
-
-
- ) : null}
-
setLightingNodeId(null)}
>
setLightingNodeId(null)}
onConfirm={(options, prompt) => {
- void generateLightingNode(lightingNode, options, prompt);
+ generateLightingNode(lightingNode, options, prompt);
}}
/>
@@ -2642,7 +2629,7 @@ function InfiniteCanvasPage() {
/>
) : null}
- {dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
+ {dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && dialogNode.type !== CanvasNodeType.Panorama && !selectionBox && !isCanvasNodeMoving ? (
{
+ const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
- if (!isAiConfigReady(generationConfig, generationConfig.model)) {
- navigateToSettings({ continueCreation: true });
- return;
- }
const childId = nanoid();
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildLightingLabel(options);
const source = nodeReferenceImage(node);
if (!source) return;
- const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
- if (!styleExecution) return;
- const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
setLightingNodeId(null);
- setRunningNodeId(childId);
- setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
+ setNodes((current) => [...current, {
+ id: childId,
+ type: CanvasNodeType.Image,
+ title,
+ position: { x: node.position.x + node.width + 96, y: node.position.y },
+ width: imageSpec.width,
+ height: imageSpec.height,
+ metadata: {
+ prompt,
+ status: NODE_STATUS_IDLE,
+ generationMode: "image",
+ ...generationMetadata,
+ },
+ }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
+ setSelectedConnectionId(null);
setDialogNodeId(childId);
- const controller = startGenerationRequest(childId, node.id, childId);
- try {
- const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
- const image = result.images?.[0];
- if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
- const uploaded = await uploadImage(image.dataUrl);
- const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
- const currentNode = nodesRef.current.find((item) => item.id === childId);
- if (!currentNode) throw new Error("打光生成节点已被删除");
- const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
- setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
- await persistMediaNodes([finalizedNode]);
- } catch (error) {
- if (isGenerationCanceled(error)) return;
- const details = generationErrorMessage(error);
- setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
- } finally {
- finishGenerationRequest(childId, controller);
- setRunningNodeId(null);
- }
- }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
+ }, [effectiveConfig, setConnections, setDialogNodeId, setLightingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
if (!node.metadata?.content) return;
diff --git a/web/src/pages/create/creation-workspace.tsx b/web/src/pages/create/creation-workspace.tsx
index 5288909b1..8604870fe 100644
--- a/web/src/pages/create/creation-workspace.tsx
+++ b/web/src/pages/create/creation-workspace.tsx
@@ -6,7 +6,7 @@ import { AppDrawer } from "@/components/ui/product/app-drawer";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Reorder } from "motion/react";
-import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
+import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
@@ -624,16 +624,19 @@ export function CreationComposer(props: ComposerProps) {
}
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
- const [open, setOpen] = useState(false);
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
- { mode: "video", icon: , label: "视频生成" },
- { mode: "image", icon: , label: "图片生成" },
- { mode: "text", icon: , label: "文本创作" },
+ { mode: "video", icon: , label: "视频" },
+ { mode: "image", icon: , label: "图片" },
+ { mode: "text", icon: , label: "文本" },
];
- const current = items.find((item) => item.mode === mode) || items[0];
- return {items.map((item) => { onModeChange(item.mode); setOpen(false); }}>{item.icon}{item.label}{item.mode === mode ? : null})} }>
-
{current.icon}{current.label}
- ;
+ return
+ {items.map((item) => (
+ onModeChange(item.mode)}>
+ {item.icon}
+ {item.label}
+
+ ))}
+
;
}
function GenerationSettingsMenu(props: ComposerProps) {
diff --git a/web/src/services/resource-blob-cache.ts b/web/src/services/resource-blob-cache.ts
index 3dc2e4738..e619ab6ab 100644
--- a/web/src/services/resource-blob-cache.ts
+++ b/web/src/services/resource-blob-cache.ts
@@ -20,6 +20,7 @@ const sessionBlobs = new Map
();
const inFlight = new Map>();
const scheduled = new Set();
const cacheMetaTouchWarnings = new Set();
+const metaTouchedAt = new Map();
const downloadQueue: Array<() => void> = [];
let activeDownloads = 0;
let persistQueue: Promise = Promise.resolve();
@@ -28,17 +29,9 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
const MAX_CACHE_ENTRIES = 500;
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
+const BUDGET_REFRESH_MS = 5 * 60 * 1000;
+// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
const MAX_CONCURRENT_DOWNLOADS = 16;
-const recentlyTouched = new Set();
-
-export function peekCachedResourceObjectUrl(storageKey: string): string {
- const resourceId = resourceIdFromStorageKey(storageKey);
- if (!resourceId) return "";
- const userScope = getActiveUserScope();
- if (userScope === "guest") return "";
- const key = `${userScope}:${resourceId}:file`;
- return objectUrls.get(key) || "";
-}
export async function getCachedResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
@@ -46,6 +39,15 @@ export async function getCachedResourceObjectUrl(storageKey: string) {
return readCachedObjectUrl(target);
}
+/** 同步读取本会话内存中的 Blob URL,命中时节点首帧即可上屏,不必等 IndexedDB 异步读取。 */
+export function peekCachedResourceObjectUrl(storageKey: string) {
+ const resourceId = resourceIdFromStorageKey(storageKey);
+ if (!resourceId) return "";
+ const userScope = getActiveUserScope();
+ if (userScope === "guest") return "";
+ return objectUrls.get(`${userScope}:${resourceId}:file`) || "";
+}
+
export async function cacheResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
if (!target) return "";
@@ -169,6 +171,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
try {
await write();
+ invalidateCacheStats();
return;
} catch (firstError) {
// 第一次失败通常意味着浏览器配额不足;激进淘汰后只允许再尝试一次,
@@ -176,6 +179,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
await evictFor(blob.size, target.key, true);
try {
await write();
+ invalidateCacheStats();
return;
} catch (retryError) {
const cleanupResults = await Promise.allSettled([blobStore.removeItem(target.key), metaStore.removeItem(target.key)]);
@@ -226,11 +230,15 @@ async function cacheTarget(storageKey: string): Promise(target.key);
- if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
- await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() });
+ if (!current || now - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
+ await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
}
@@ -243,12 +251,20 @@ function touchCacheMetaSafely(target: ResourceCacheMeta) {
});
}
+// 条目数/字节数与存储配额在内存中跟踪,常规写入只做 O(1) 检查,
+// 只有接近预算才执行全量 LRU 扫描,避免每张图持久化都 iterate 全部元数据。
+let cacheStats: { count: number; bytes: number } | null = null;
+let cachedBudget: { value: number; at: number } | null = null;
+
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
const entryCount = await metaStore.length().catch(() => 0);
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
- // 只有当条目数接近上限或需要激进淘汰时,才进行全量迭代,避免写入每个文件都跑全表扫描
- if (!aggressive && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
-
+ // 常规写入只在统计可信或明显未接近阈值时跳过全表扫描;写入会使内存统计失效,
+ // 因此不会用可能过时的字节数做永久放行。
+ if (!aggressive) {
+ if (cacheStats && cacheStats.count < MAX_CACHE_ENTRIES && cacheStats.bytes + incomingBytes <= budget) return;
+ if (!cacheStats && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
+ }
const metas: ResourceCacheMeta[] = [];
await metaStore.iterate((value) => {
if (value?.key) metas.push(value);
@@ -263,6 +279,11 @@ async function evictFor(incomingBytes: number, protectedKey: string, aggressive
total -= Math.max(0, candidate.size || 0);
count -= 1;
}
+ cacheStats = { count, bytes: total };
+}
+
+function invalidateCacheStats() {
+ cacheStats = null;
}
async function removeCacheEntry(meta: ResourceCacheMeta) {
@@ -274,10 +295,15 @@ async function removeCacheEntry(meta: ResourceCacheMeta) {
}
async function cacheBudget() {
- if (!navigator.storage?.estimate) return FALLBACK_CACHE_BYTES;
- const estimate = await navigator.storage.estimate().catch(() => null);
- if (!estimate?.quota) return FALLBACK_CACHE_BYTES;
- return Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
+ // 配额估计是跨进程调用且会话内基本稳定,短暂缓存避免每张图一次 IPC 往返。
+ if (cachedBudget && Date.now() - cachedBudget.at < BUDGET_REFRESH_MS) return cachedBudget.value;
+ let value = FALLBACK_CACHE_BYTES;
+ if (navigator.storage?.estimate) {
+ const estimate = await navigator.storage.estimate().catch(() => null);
+ if (estimate?.quota) value = Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
+ }
+ cachedBudget = { value, at: Date.now() };
+ return value;
}
function objectUrl(key: string, blob: Blob) {
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 75102f8fd..6e0586e53 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -1694,26 +1694,6 @@
background: var(--dock-command-hover) !important;
}
- .canvas-mode-switch-trigger {
- border: 0;
- box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
- transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
- }
-
- .canvas-mode-switch-trigger:hover:not(:disabled) {
- background: var(--dock-command-hover) !important;
- }
-
- .canvas-mode-switch-menu {
- border: 0;
- box-shadow: 0 22px 56px color-mix(in srgb, var(--cn-spatial-shadow) 80%, transparent), inset 0 1px 0 rgba(255,255,255,.04);
- transform-origin: bottom right;
- }
-
- .canvas-mode-switch-option:hover:not([aria-selected="true"]) {
- background: var(--dock-command-hover) !important;
- }
-
.canvas-topbar-project-cluster {
width: max-content;
max-width: min(360px, calc(50vw - 88px));
@@ -2966,54 +2946,6 @@
padding: 0 !important;
}
- .creation-mode-picker-menu {
- width: 176px;
- padding: 6px;
- color: #17191d;
- }
-
- .dark .creation-mode-picker-menu {
- color: #f2f4f6;
- }
-
- .creation-mode-picker-menu button {
- display: flex;
- width: 100%;
- height: 40px;
- align-items: center;
- gap: 10px;
- border-radius: 8px;
- padding: 0 8px;
- color: inherit;
- font-size: 13px;
- text-align: left;
- transition: background-color 150ms ease;
- }
-
- .creation-mode-picker-menu button > svg {
- width: 15px;
- height: 15px;
- margin-left: auto;
- }
-
- .creation-menu-icon {
- display: grid;
- width: 26px;
- height: 26px;
- place-items: center;
- border: 1px solid #e5e7eb;
- border-radius: 7px;
- }
-
- .dark .creation-menu-icon {
- border-color: #30343b;
- }
-
- .creation-menu-icon svg {
- width: 15px;
- height: 15px;
- }
-
.creation-settings-panel {
width: min(400px, calc(100vw - 24px));
padding: 16px;
@@ -3747,7 +3679,6 @@
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { border-color: rgba(255, 255, 255, .12) !important; background: #272b32 !important; transform: translateY(-1px); }
.creation-chat-control:active { transform: scale(.98); }
- .creation-chat-control.is-mode { border-color: rgba(6, 182, 212, .36) !important; background: rgba(5, 80, 98, .24) !important; color: #17c4e2 !important; }
.creation-chat-control > svg { width: 15px; height: 15px; flex: 0 0 auto; }
.creation-chat-control > svg:last-child { width: 14px; height: 14px; color: var(--creation-muted); transition: transform 160ms var(--motion-ease-out); }
.creation-chat-control > svg:last-child.is-open { transform: rotate(180deg); }
@@ -7606,24 +7537,12 @@
0 14px 34px rgba(15, 23, 42, 0.1);
}
- @media (max-width: 1024px) {
- .aceternity-mode-switch {
- top: 56px;
- }
- }
-
.dark .canvas-floating-panel {
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.28),
0 14px 34px rgba(0, 0, 0, 0.34);
}
- .canvas-mode-switch-trigger {
- border: 0 !important;
- border-color: transparent !important;
- box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
- }
-
.canvas-tool-button,
.canvas-node-tool-button {
transform: translate3d(0, 0, 0) scale(1);
@@ -10532,6 +10451,9 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
gap: 2px;
}
+/* 录音条自身可压缩,避免波形 + 胶囊同时出现时把右侧控件挤到第二行。 */
+.creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: nowrap; }
+
.creation-chat-control,
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
display: inline-flex;
@@ -10560,11 +10482,6 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
transform: none;
}
-.creation-chat-control.is-mode {
- background: rgba(255, 255, 255, .075) !important;
- color: var(--creation-text) !important;
-}
-
.creation-chat-control[aria-pressed="true"],
.creation-chat-control[aria-pressed="true"]:hover {
background: rgba(6, 182, 212, .16) !important;
@@ -10581,10 +10498,31 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
html:not(.dark) .creation-chat-control,
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { background: transparent !important; color: var(--creation-muted) !important; }
-html:not(.dark) .creation-chat-control.is-mode,
html:not(.dark) .creation-chat-control:hover,
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; }
+.creation-mode-capsule {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 2px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, .06);
+ padding: 2px;
+}
+.creation-mode-capsule .creation-chat-control { height: 24px !important; border-radius: 999px !important; }
+.creation-mode-capsule .creation-chat-control:hover,
+.creation-home .creation-mode-capsule .creation-chat-control:hover { background: rgba(255, 255, 255, .09) !important; color: var(--creation-text) !important; }
+.creation-mode-capsule .creation-chat-control[aria-pressed="true"],
+.creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover,
+.creation-home .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
+.creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
+html:not(.dark) .creation-mode-capsule { background: rgba(28, 32, 37, .05); }
+html:not(.dark) .creation-mode-capsule .creation-chat-control:hover { background: rgba(28, 32, 37, .07) !important; }
+html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"],
+html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
+html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
+
.creation-chat-control:disabled {
opacity: .42 !important;
cursor: not-allowed;
@@ -10654,14 +10592,6 @@ html:not(.dark) .creation-chat-control[aria-pressed="true"] > svg { color: #0e74
.creation-range-wrap input::-moz-range-thumb { width: 6px; height: 14px; border-radius: 4px; }
.creation-range-labels { margin-top: 2px; color: var(--creation-faint); font-size: 9px; }
-.creation-mode-picker-menu { width: 158px; padding: 5px; color: #f0f1f2; }
-.creation-mode-picker-menu button { display: flex; width: 100%; height: 34px; align-items: center; gap: 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; padding: 0 7px; font-size: 11px; text-align: left; }
-.creation-mode-picker-menu button:hover { background: var(--control-selected-bg); }
-.creation-mode-picker-menu button.is-selected { background: var(--control-selected-bg); color: var(--control-selected-fg); }
-.creation-mode-picker-menu button > svg { width: 13px; height: 13px; margin-left: auto; }
-.creation-menu-icon { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .07); }
-.creation-menu-icon svg { width: 12px; height: 12px; }
-
.creation-model-picker-menu { width: min(332px, calc(100vw - 24px)) !important; max-height: min(420px, calc(100vh - 24px)); padding: 7px !important; background: transparent !important; color: #f0f1f2 !important; }
.creation-model-picker-heading { display: flex; align-items: center; gap: 6px; padding: 4px 7px 7px; color: #8e949d; font-size: 10px; }
.creation-model-picker-heading strong { color: #e6e8eb; font-weight: 500; }
@@ -10688,13 +10618,11 @@ html:not(.dark) .creation-control-popover-surface,
html:not(.dark) .creation-model-picker-surface { background: rgba(255, 255, 255, .94) !important; box-shadow: 0 16px 38px rgba(28, 32, 37, .12) !important; }
html:not(.dark) .creation-parameter-section h3,
html:not(.dark) .creation-duration-menu > div:first-child,
-html:not(.dark) .creation-mode-picker-menu,
html:not(.dark) .creation-model-picker-menu { color: #727980 !important; }
html:not(.dark) .creation-choice-grid button { color: #737a82; }
html:not(.dark) .creation-choice-grid button:hover { background: rgba(28, 32, 37, .06); color: #25292e; }
html:not(.dark) .creation-duration-menu strong,
html:not(.dark) .creation-model-picker-heading strong { color: #20242a; }
-html:not(.dark) .creation-mode-picker-menu button:hover,
html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { background: rgba(28, 32, 37, .06) !important; }
@keyframes creation-composer-arrive {
@@ -10707,6 +10635,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
.creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; }
.creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; }
.creation-chat-controls { max-width: none; }
+ .creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: wrap; }
.creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; }
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; }
.creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); }
@@ -11591,8 +11520,7 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
.creation-home .creation-chat-dock { min-height: 48px; padding: 4px 12px 12px 16px; }
.creation-home .creation-chat-control,
-.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
-.creation-home .creation-chat-control.is-mode {
+.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -11609,16 +11537,14 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
transform: none;
}
-.creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
-.creation-home .creation-chat-control.is-mode > svg:first-child { color: var(--creation-text); }
html:not(.dark) .creation-home .creation-chat-control:hover,
html:not(.dark) .creation-home .creation-chat-control[aria-expanded="true"],
html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(28, 32, 37, .06) !important; }
.creation-home .creation-chat-controls > .voice-recording-inline {
- width: min(236px, calc(100vw - 120px));
max-width: 236px;
- flex: 0 1 236px;
+ flex: 1 1 236px;
+ min-width: 140px;
min-height: 30px;
height: 30px;
overflow: hidden;
@@ -11630,6 +11556,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-home .creation-chat-controls > .voice-recording-inline > svg {
flex: 0 1 160px;
max-width: 160px;
+ min-width: 0;
}
.creation-chat-intro h1 > .creation-intro-emphasis {
@@ -12861,8 +12788,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
/* 旧创作规则位于 utilities 层且使用 !important;必须在同层覆盖。 */
@layer utilities {
.creation-home .creation-chat-control,
- .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
- .creation-home .creation-chat-control.is-mode {
+ .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -12879,8 +12805,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
transform: none;
}
- .creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
-
.creation-parameter-menu {
width: 304px;
padding: 13px 14px 14px;
@@ -14590,8 +14514,6 @@ html:not(.dark) .creation-history-drawer-root {
.skill-install-modal .ant-modal-title { font-size: var(--fs-heading-lg); font-weight: 620; }
.skill-install-modal .ant-upload-wrapper .ant-upload-drag { border-color: color-mix(in srgb, var(--foreground) 14%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); }
.skill-install-modal .ant-upload-wrapper .ant-upload-drag:hover { border-color: color-mix(in srgb, var(--foreground) 28%, transparent); }
-.skill-install-mode { padding: 3px; }
-.skill-install-mode [role="radio"] { min-height: 34px; }
.skill-install-modal .skill-install-form { margin-top: 16px; }
.skill-package-modal .ant-modal-container { border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); border-radius: var(--r-xl); background: var(--popover); box-shadow: var(--elevation-panel); }